Compare commits

...
4 Commits
Author SHA1 Message Date
Denozordec 4224db8eb3 feat(services): enhance service health management with IP health tracking
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
quality / api (push) Successful in 41s
CD / quality (push) Successful in 1m45s
CD / publish (push) Successful in 1m33s
- Introduced IP health tracking in service views, allowing for detailed monitoring of individual IP statuses and latencies.
- Updated the service configuration to include an `ip_health` array, providing structured health data for each IP.
- Enhanced the `attachServiceHealth` function to aggregate IP health data alongside overall service health.
- Modified relevant components to display IP health information, improving visibility and user experience in service management interfaces.

This commit significantly improves the health monitoring capabilities of services, enabling better insights into the status of individual IPs associated with each service.
2026-08-19 14:57:19 +07:00
Denozordec 4ca948292d refactor(services): streamline service edit functionality and improve component structure
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
CD / quality (push) Successful in 1m2s
CD / publish (push) Successful in 1m35s
- Removed unused imports and refactored the ServiceEditSheet component to enhance readability and maintainability.
- Introduced new utility functions for managing service binding drafts and handling common FQDN changes.
- Updated the ServiceCatalogSection to improve layout responsiveness and enhance the display of service units.
- Simplified the ServiceUnitCard component by removing unnecessary elements and optimizing the layout for better user experience.

This commit improves the overall structure and functionality of service management components, making them more efficient and user-friendly.
2026-08-19 14:27:02 +07:00
Denozordec 50c5c21c18 feat(services): enhance service edit and display components with common domain support
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 1m1s
CD / publish (push) Successful in 1m36s
- Added a common FQDN input field in the ServiceEditSheet to streamline domain management for services.
- Updated binding handling to synchronize the common domain across service bindings.
- Improved the ServiceKanbanCard to display both common domain and IP addresses for better visibility.
- Refactored the ServiceFqdnList to support customizable text classes and added a new ServiceIpList component for IP display.

This commit enhances the user experience by simplifying domain management and improving the display of service information.
2026-08-19 13:29:15 +07:00
Denozordec ab4ccbd7a1 feat(services): add copyable FQDN functionality and improve service display
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 59s
CD / publish (push) Successful in 1m41s
- Enhanced the ServiceFqdnList component to support a copyable feature, allowing users to easily copy FQDNs to the clipboard.
- Updated the ServiceKanbanCard to utilize the new copyable functionality in the FQDN list.
- Refined the services grouped catalog logic to improve data handling and display.
- Removed the obsolete services-grouped-columns file to streamline the codebase.

This commit improves user experience by facilitating easier access to FQDNs and optimizing service management components.
2026-08-19 13:03:52 +07:00
19 changed files with 1056 additions and 964 deletions
@@ -307,6 +307,7 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
domains: domainViews,
health_status: "unknown",
health_latency_ms: null,
ip_health: [],
};
}
@@ -314,16 +315,27 @@ function attachServiceHealth(
db: Db,
views: ServiceView[],
): ServiceView[] {
const healthByService = repos.aggregateIpHealthByServiceIds(
db,
views.map((v) => v.id),
);
const ids = views.map((v) => v.id);
const healthByService = repos.aggregateIpHealthByServiceIds(db, ids);
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
return views.map((view) => {
const health = healthByService.get(view.id);
const byIp = new Map(
(ipHealthByService.get(view.id) ?? []).map((row) => [row.ip, row]),
);
const ip_health = (view.ips ?? []).map((ip) => {
const row = byIp.get(ip);
return {
ip,
status: row?.status ?? ("unknown" as const),
latency_ms: row?.latency_ms ?? null,
};
});
return {
...view,
health_status: health?.health_status ?? "unknown",
health_latency_ms: health?.health_latency_ms ?? null,
ip_health,
};
});
}
@@ -372,7 +384,7 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
const groupViews = groupViewsRaw.map((group) => {
const services = group.services.map(
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null },
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null, ip_health: [] },
);
const groupScopeHealth = groupHealthById.get(group.id);
// Only enabled services feed the group badge — a disabled service with a
@@ -401,6 +413,7 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
...s,
health_status: "unknown" as const,
health_latency_ms: null,
ip_health: [],
},
);
@@ -39,6 +39,7 @@ describe("service groups health enrichment", () => {
const service = repos.createService(app.db, "Panel", "panel");
repos.setServiceGroup(app.db, service.id, group.id);
repos.setServiceEnabled(app.db, service.id, true);
repos.replaceServiceIps(app.db, service.id, ["1.2.3.4"]);
const binding = repos.insertBinding(
app.db,
domain.id,
@@ -85,6 +86,11 @@ describe("service groups health enrichment", () => {
id: number;
health_status: string;
health_latency_ms: number | null;
ip_health: Array<{
ip: string;
status: string;
latency_ms: number | null;
}>;
}>;
}>;
};
@@ -92,6 +98,9 @@ describe("service groups health enrichment", () => {
expect(groupView).toBeDefined();
expect(groupView!.services[0]?.health_status).toBe("degraded");
expect(groupView!.services[0]?.health_latency_ms).toBe(120);
expect(groupView!.services[0]?.ip_health).toEqual([
{ ip: "1.2.3.4", status: "degraded", latency_ms: 120 },
]);
// group worst = degraded (from service) over up (group scope)
expect(groupView!.health_status).toBe("degraded");
@@ -3,7 +3,7 @@ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
import { ServiceFqdnList, ServiceIpList } from '@/components/services/service-fqdn-list'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
@@ -78,7 +78,22 @@ export function ServiceKanbanCard({
</ItemHeader>
<ItemContent className="min-w-0 gap-2">
<ServiceFqdnList service={service} />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-muted-foreground text-xs">Общий домен</span>
<ServiceFqdnList
copyable
service={service}
emptyLabel="Не задан"
/>
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-muted-foreground text-xs">IP</span>
<ServiceIpList
copyable
ips={service.ips ?? []}
ipHealth={service.ip_health ?? []}
/>
</div>
</ItemContent>
<ItemFooter className="min-w-0 justify-between gap-2">
+283 -323
View File
@@ -1,8 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { Link2Icon, PlusIcon, Trash2Icon } from 'lucide-react'
import { PlusIcon, Trash2Icon } from 'lucide-react'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { EmptyState } from '@/components/empty-state'
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
import {
@@ -38,7 +36,6 @@ import {
ItemGroup,
} from '@cfdm/ui/components/item'
import { LoadingButton } from '@/components/loading-button'
import { TabsContent } from '@cfdm/ui/components/tabs'
import {
Select,
SelectContent,
@@ -46,7 +43,6 @@ import {
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { Separator } from '@cfdm/ui/components/separator'
interface BindingHealthConfig {
enabled: boolean
@@ -161,6 +157,33 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
)
}
function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
return {
fqdn,
record_type: 'A',
target_ips: [],
target_cname: '',
lb_mode: 'round_robin',
health: { ...defaultHealth },
target_ip_weights: {},
target_ip_priorities: {},
}
}
function withPoolIps(draft: ServiceBindingDraft, pool: string[]): ServiceBindingDraft {
if (draft.record_type !== 'A' || draft.target_ips.length > 0 || pool.length === 0) {
return draft
}
return {
...draft,
target_ips: pool,
target_ip_weights: Object.fromEntries(pool.map((ip) => [ip, draft.target_ip_weights[ip] ?? 1])),
target_ip_priorities: Object.fromEntries(
pool.map((ip) => [ip, draft.target_ip_priorities[ip] ?? 1]),
),
}
}
export function ServiceEditSheet({
mode,
service,
@@ -179,10 +202,10 @@ export function ServiceEditSheet({
const [slug, setSlug] = useState('')
const [serviceGroupId, setServiceGroupId] = useState('none')
const [ips, setIps] = useState<string[]>([])
const [commonFqdn, setCommonFqdn] = useState('')
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
const [lbWeight, setLbWeight] = useState(1)
const [lbPriority, setLbPriority] = useState(1)
const [activeTab, setActiveTab] = useState('general')
const groupItems = useMemo(
() => [
@@ -192,16 +215,8 @@ export function ServiceEditSheet({
[groups],
)
const selectedGroup = useMemo(() => {
if (serviceGroupId === 'none') return null
return groups.find((g) => String(g.id) === serviceGroupId) ?? null
}, [groups, serviceGroupId])
const groupHasDomain = Boolean(selectedGroup?.domain?.trim())
useEffect(() => {
if (!open) return
setActiveTab('general')
if (mode === 'edit' && service) {
setName(service.name)
setSlug(service.slug)
@@ -209,7 +224,9 @@ export function ServiceEditSheet({
service.service_group_id != null ? String(service.service_group_id) : 'none',
)
setIps(service.ips ?? [])
setBindings(toBindingDrafts(service))
const drafts = toBindingDrafts(service)
setBindings(drafts)
setCommonFqdn(drafts[0]?.fqdn ?? '')
setLbWeight(service.lb_weight ?? 1)
setLbPriority(service.lb_priority ?? 1)
return
@@ -221,6 +238,7 @@ export function ServiceEditSheet({
defaultGroupId != null ? String(defaultGroupId) : 'none',
)
setIps([])
setCommonFqdn('')
setBindings([])
setLbWeight(1)
setLbPriority(1)
@@ -232,23 +250,28 @@ export function ServiceEditSheet({
[knownDomains],
)
function handleAddBinding() {
setBindings((current) => [
...current,
{
fqdn: '',
record_type: 'A',
target_ips: [],
target_cname: '',
lb_mode: 'round_robin',
health: { ...defaultHealth },
target_ip_weights: {},
target_ip_priorities: {},
},
])
const extraBindings = bindings.slice(1)
function handleCommonFqdnChange(value: string) {
setCommonFqdn(value)
setBindings((current) => {
if (current.length === 0) return current
return current.map((item, i) => (i === 0 ? { ...item, fqdn: value } : item))
})
}
function handleRemoveBinding(index: number) {
function handleAddExtraBinding() {
setBindings((current) => {
const extra = withPoolIps(emptyBindingDraft(), ips)
if (current.length === 0) {
return [emptyBindingDraft(commonFqdn), extra]
}
return [...current, extra]
})
}
function handleRemoveExtraBinding(extraIndex: number) {
const index = extraIndex + 1
setBindings((current) => current.filter((_, i) => i !== index))
}
@@ -298,69 +321,75 @@ export function ServiceEditSheet({
)
}
function handleBindingMetaChange(
index: number,
ip: string,
meta: { weight?: number; priority?: number },
) {
setBindings((current) =>
current.map((item, i) => {
if (i !== index) return item
const weights = { ...item.target_ip_weights }
const priorities = { ...item.target_ip_priorities }
if (meta.weight !== undefined) weights[ip] = meta.weight
if (meta.priority !== undefined) priorities[ip] = meta.priority
return { ...item, target_ip_weights: weights, target_ip_priorities: priorities }
}),
)
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
return {
enabled: next.enabled,
type: next.type,
port: next.port,
path: next.path,
expected_status: next.expected_status,
interval_sec: next.interval_sec,
timeout_ms: next.timeout_ms,
verify_tls: next.verify_tls,
provider: next.provider,
}
}
function handleBindingHealthChange(index: number, next: LbAndHealthConfig) {
setBindings((current) =>
current.map((item, i) =>
i === index
? {
...item,
lb_mode: next.lb_mode,
health: {
enabled: next.enabled,
type: next.type,
port: next.port,
path: next.path,
expected_status: next.expected_status,
interval_sec: next.interval_sec,
timeout_ms: next.timeout_ms,
verify_tls: next.verify_tls,
provider: next.provider ?? 'local',
},
}
: item,
),
)
function handlePrimaryHealthChange(next: LbAndHealthConfig) {
const health = healthFromConfig(next)
setBindings((current) => {
if (current.length === 0) {
return [
{
...withPoolIps(emptyBindingDraft(commonFqdn), ips),
lb_mode: next.lb_mode,
health,
},
]
}
return current.map((item, index) =>
index === 0 ? { ...item, lb_mode: next.lb_mode, health } : item,
)
})
}
const primaryHealthValue: LbAndHealthConfig = {
lb_mode: bindings[0]?.lb_mode ?? 'round_robin',
...(bindings[0]?.health ?? defaultHealth),
}
function resolveServiceGroupId(): number | null {
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
}
function syncCommonDomain(current: ServiceBindingDraft[]): ServiceBindingDraft[] {
const trimmed = commonFqdn.trim()
if (!trimmed) return current
if (current.length === 0) {
return [withPoolIps(emptyBindingDraft(trimmed), ips)]
}
return current.map((item, index) => {
if (index !== 0) return item
return withPoolIps({ ...item, fqdn: trimmed }, ips)
})
}
function handleSubmit() {
const domains = buildDomainsPayload(bindings)
const syncedBindings = syncCommonDomain(bindings)
const domains = buildDomainsPayload(syncedBindings)
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
const hasDuplicateFqdn =
new Set(normalizedFqdns).size !== normalizedFqdns.length
if (hasDuplicateFqdn) {
toast.error('Укажите уникальные FQDN — дубликаты привязок недопустимы')
setActiveTab('bindings')
return
}
const groupId = resolveServiceGroupId()
const lbFields = groupHasDomain
? { lb_weight: lbWeight, lb_priority: lbPriority }
: {}
const configPayload = {
ips,
domains,
...lbFields,
lb_weight: lbWeight,
lb_priority: lbPriority,
}
if (mode === 'create') {
onCreate?.({
@@ -368,7 +397,8 @@ export function ServiceEditSheet({
slug: slug.trim(),
service_group_id: groupId,
ips,
...lbFields,
lb_weight: lbWeight,
lb_priority: lbPriority,
domains,
})
return
@@ -398,29 +428,16 @@ export function ServiceEditSheet({
<SheetHeader className="shrink-0 border-b pb-4">
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription>
Настройте параметры сервиса и привязки FQDN IP или CNAME. Один
сервис может иметь несколько FQDN в разных зонах; зона определяется
из FQDN автоматически.
Общий домен и IP задаются у сервиса. Дополнительные FQDN ниже, зона
определяется автоматически.
</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
<CountedLineTabs
tabs={[
{ id: 'general', label: 'Основное' },
{
id: 'bindings',
label: 'Привязки',
count: bindings.length > 0 ? bindings.length : undefined,
},
]}
value={activeTab}
onValueChange={setActiveTab}
className="flex w-full flex-col gap-4"
listClassName="mb-0 w-full"
>
<TabsContent value="general" className="flex flex-col gap-4">
<FieldGroup className="flex flex-col gap-4">
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-4">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Сервис</h3>
<FieldGroup className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
<Input
@@ -439,239 +456,182 @@ export function ServiceEditSheet({
onChange={(e) => setSlug(e.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
<Select
items={groupItems}
value={serviceGroupId}
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
>
<SelectTrigger id="edit-service-group" className="w-full">
<SelectValue placeholder="Без группы" />
</SelectTrigger>
<SelectContent>
{groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
<TaggedInput
id="edit-service-ips"
value={ips}
onChange={setIps}
placeholder="192.168.1.1"
validate={isValidIpv4}
/>
</Field>
</FieldGroup>
{groupHasDomain && (
<>
<Separator />
<div className="flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
Балансировка внутри группы «{selectedGroup?.name}»: вес и приоритет
сервиса для общего домена группы.
</p>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="service-lb-weight">Вес</FieldLabel>
<Input
id="service-lb-weight"
type="number"
inputMode="numeric"
min={1}
max={100}
value={lbWeight}
onChange={(e) =>
setLbWeight(Math.max(1, Number(e.target.value) || 1))
}
/>
</Field>
<Field>
<FieldLabel htmlFor="service-lb-priority">Приоритет</FieldLabel>
<Input
id="service-lb-priority"
type="number"
inputMode="numeric"
min={1}
max={100}
value={lbPriority}
onChange={(e) =>
setLbPriority(Math.max(1, Number(e.target.value) || 1))
}
/>
</Field>
</div>
</div>
</>
)}
</TabsContent>
<TabsContent value="bindings" className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
Несколько FQDN в разных зонах IP или CNAME для DNS Cloudflare
</p>
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
Добавить
</Button>
</div>
{bindings.length === 0 ? (
<EmptyState
icon={Link2Icon}
title="Нет привязок"
description="Необязательно. Можно добавить несколько FQDN: api.ivx.su и www.other.su — зоны определятся автоматически."
centered={false}
action={
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
<PlusIcon data-icon="inline-start" />
Добавить привязку
</Button>
<Field>
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
<Select
items={groupItems}
value={serviceGroupId}
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
>
<SelectTrigger id="edit-service-group" className="w-full">
<SelectValue placeholder="Без группы" />
</SelectTrigger>
<SelectContent>
{groupItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="edit-service-common-domain">
Общий домен (FQDN)
</FieldLabel>
<Input
id="edit-service-common-domain"
className="font-mono"
value={commonFqdn}
placeholder={
zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'
}
onChange={(e) => handleCommonFqdnChange(e.target.value)}
/>
) : (
<ItemGroup className="gap-2">
{bindings.map((binding, index) => {
const showLbBlock =
(binding.record_type === 'A' && binding.target_ips.length > 0) ||
(binding.record_type === 'CNAME' && binding.target_cname.trim().length > 0)
const showMeta =
binding.record_type === 'A' &&
binding.target_ips.length > 1 &&
binding.lb_mode !== 'round_robin'
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
return (
<Item key={`binding-${index}`} variant="outline" className="items-stretch">
<ItemContent className="w-full flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="text-sm font-medium">
Привязка {index + 1}
</span>
{parsedZone ? (
<Badge variant="outline" size="xs" className="font-mono">
{parsedZone.zoneName}
</Badge>
) : binding.fqdn.trim() ? (
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
aria-label="Удалить привязку"
onClick={() => handleRemoveBinding(index)}
>
<Trash2Icon />
</Button>
</div>
<Field className="min-w-0">
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
<Input
id={`binding-fqdn-${index}`}
className="font-mono"
value={binding.fqdn}
onChange={(event) =>
handleFqdnChange(index, event.target.value)
}
placeholder={
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
}
/>
</Field>
<Field>
<FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel>
<Select
items={[
{ label: 'A (IP)', value: 'A' },
{ label: 'CNAME', value: 'CNAME' },
]}
value={binding.record_type}
onValueChange={(value) =>
handleRecordTypeChange(index, (value ?? 'A') as 'A' | 'CNAME')
}
>
<SelectTrigger id={`binding-type-${index}`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A (IP)</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</Field>
{binding.record_type === 'CNAME' ? (
<Field>
<FieldLabel htmlFor={`binding-cname-${index}`}>
CNAME-цель
</FieldLabel>
<Input
id={`binding-cname-${index}`}
value={binding.target_cname}
placeholder="mmsk.rkns.top"
onChange={(event) => handleCnameChange(index, event.target.value)}
/>
</Field>
) : (
<Field>
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
<ServiceBindingIpInput
id={`binding-ip-${index}`}
value={binding.target_ips}
pool={ips}
onChange={(targetIps) => handleIpsChange(index, targetIps)}
showMeta={showLbBlock && showMeta}
weights={binding.target_ip_weights}
priorities={binding.target_ip_priorities}
onMetaChange={(ip, meta) =>
handleBindingMetaChange(index, ip, meta)
}
/>
</Field>
)}
</Field>
<Field>
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
<TaggedInput
id="edit-service-ips"
value={ips}
onChange={setIps}
placeholder="192.168.1.1"
validate={isValidIpv4}
/>
</Field>
</FieldGroup>
</section>
{showLbBlock ? (
<HealthCheckConfigFields
value={{
lb_mode: binding.lb_mode,
enabled: binding.health.enabled,
type: binding.health.type,
port: binding.health.port,
path: binding.health.path,
expected_status: binding.health.expected_status,
interval_sec: binding.health.interval_sec,
timeout_ms: binding.health.timeout_ms,
verify_tls: binding.health.verify_tls,
provider: binding.health.provider ?? 'local',
}}
onChange={(next) => handleBindingHealthChange(index, next)}
lbModeLabel="Режим балансировки"
showLbMode={
binding.record_type === 'A' && binding.target_ips.length > 1
}
idPrefix={`binding-${index}-health`}
/>
) : null}
</ItemContent>
</Item>
)
})}
</ItemGroup>
)}
</TabsContent>
</CountedLineTabs>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Health check</h3>
<HealthCheckConfigFields
idPrefix="service-health"
value={primaryHealthValue}
onChange={handlePrimaryHealthChange}
/>
</section>
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">Доп. FQDN</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddExtraBinding}
>
<PlusIcon data-icon="inline-start" />
Добавить
</Button>
</div>
{extraBindings.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет дополнительных FQDN
</p>
) : (
<ItemGroup className="gap-2">
{extraBindings.map((binding, extraIndex) => {
const index = extraIndex + 1
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
return (
<Item
key={`extra-binding-${index}`}
variant="outline"
size="sm"
className="items-stretch"
>
<ItemContent className="flex w-full min-w-0 flex-col gap-2">
<div className="flex items-center gap-2">
{parsedZone ? (
<Badge variant="outline" size="xs" className="font-mono">
{parsedZone.zoneName}
</Badge>
) : binding.fqdn.trim() ? (
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
) : (
<span className="text-muted-foreground text-xs">
FQDN
</span>
)}
<Button
type="button"
variant="ghost"
size="icon-sm"
className="ml-auto shrink-0"
aria-label="Удалить FQDN"
onClick={() => handleRemoveExtraBinding(extraIndex)}
>
<Trash2Icon />
</Button>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_7.5rem]">
<Input
id={`extra-fqdn-${index}`}
className="font-mono"
value={binding.fqdn}
onChange={(event) =>
handleFqdnChange(index, event.target.value)
}
placeholder={
zoneHints[0]
? `api.${zoneHints[0]}`
: 'api.ivx.su'
}
/>
<Select
items={[
{ label: 'A (IP)', value: 'A' },
{ label: 'CNAME', value: 'CNAME' },
]}
value={binding.record_type}
onValueChange={(value) =>
handleRecordTypeChange(
index,
(value ?? 'A') as 'A' | 'CNAME',
)
}
>
<SelectTrigger
id={`extra-type-${index}`}
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="A">A (IP)</SelectItem>
<SelectItem value="CNAME">CNAME</SelectItem>
</SelectContent>
</Select>
</div>
{binding.record_type === 'CNAME' ? (
<Input
id={`extra-cname-${index}`}
value={binding.target_cname}
placeholder="mmsk.rkns.top"
onChange={(event) =>
handleCnameChange(index, event.target.value)
}
/>
) : (
<ServiceBindingIpInput
id={`extra-ip-${index}`}
value={binding.target_ips}
pool={ips}
onChange={(targetIps) =>
handleIpsChange(index, targetIps)
}
/>
)}
</ItemContent>
</Item>
)
})}
</ItemGroup>
)}
</section>
</div>
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import {
@@ -12,10 +12,6 @@ import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { AppFieldGroup } from '@/components/app-field'
import { AppInput } from '@/components/app-input'
import {
HealthCheckConfigFields,
type LbAndHealthConfig,
} from '@/components/health-check-config-fields'
import {
Select,
SelectContent,
@@ -44,19 +40,6 @@ interface ServiceGroupEditSheetProps {
onSave?: (id: number, body: CreateServiceGroupInput) => void
}
const defaultLbHealth: LbAndHealthConfig = {
lb_mode: 'round_robin',
enabled: false,
type: 'tcp',
port: null,
path: null,
expected_status: null,
interval_sec: 30,
timeout_ms: 3000,
verify_tls: false,
provider: 'local',
}
export function ServiceGroupEditSheet({
mode,
group,
@@ -74,7 +57,6 @@ export function ServiceGroupEditSheet({
domain: null,
},
})
const [lbHealth, setLbHealth] = useState<LbAndHealthConfig>(defaultLbHealth)
useEffect(() => {
if (!open) return
@@ -82,44 +64,19 @@ export function ServiceGroupEditSheet({
form.reset({
name: group.name,
type: group.type,
domain: group.domain ?? null,
})
setLbHealth({
lb_mode: group.lb_mode,
enabled: group.health_check_enabled,
type: group.health_check_type === 'http' ? 'http' : 'tcp',
port: group.health_check_port,
path: group.health_check_path,
expected_status: group.health_check_expected_status,
interval_sec: group.health_check_interval_sec,
timeout_ms: group.health_check_timeout_ms,
verify_tls: group.health_check_verify_tls,
provider: 'local',
domain: null,
})
} else {
form.reset({ name: '', type: 'custom', domain: null })
setLbHealth(defaultLbHealth)
}
}, [open, mode, group, form])
const domainValue = form.watch('domain')
const hasDomain = Boolean(domainValue?.trim())
function handleSubmit(values: ServiceGroupFormValues) {
const body: CreateServiceGroupInput = {
name: values.name,
type: values.type ?? 'custom',
icon: values.icon,
domain: values.domain?.trim() || null,
lb_mode: lbHealth.lb_mode,
health_check_enabled: lbHealth.enabled,
health_check_type: lbHealth.type,
health_check_port: lbHealth.port,
health_check_path: lbHealth.path,
health_check_expected_status: lbHealth.expected_status,
health_check_interval_sec: lbHealth.interval_sec,
health_check_timeout_ms: lbHealth.timeout_ms,
health_check_verify_tls: lbHealth.verify_tls,
domain: null,
}
if (mode === 'create') {
onCreate?.(body)
@@ -133,7 +90,7 @@ export function ServiceGroupEditSheet({
open={open}
onOpenChange={onOpenChange}
title={mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
description="Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов."
description="Группа нужна только для сортировки каталога. Общий домен и IP задаются у каждого сервиса."
form={form}
onSubmit={handleSubmit}
contentClassName="gap-6"
@@ -186,26 +143,7 @@ export function ServiceGroupEditSheet({
)}
/>
</FormFieldSimple>
<FormFieldSimple
label="Домен группы (FQDN, необязательно)"
htmlFor="group-domain"
>
<AppInput
id="group-domain"
placeholder="domain.new.ivx.su"
{...form.register('domain')}
/>
</FormFieldSimple>
</AppFieldGroup>
{hasDomain ? (
<HealthCheckConfigFields
value={lbHealth}
onChange={setLbHealth}
lbModeLabel="Режим балансировки общего домена"
idPrefix="group-lb-health"
/>
) : null}
</FormSheet>
)
}
@@ -0,0 +1,160 @@
import { FolderOpenIcon, MoreHorizontalIcon, PlusIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import { ServiceGroupIcon } from '@/components/service-group-icon'
import { ServiceUnitCard } from '@/components/services/service-unit-card'
import type { ServiceGroup, ServiceGroupView, ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
const GROUP_TYPE_LABELS: Record<ServiceGroup['type'], string> = {
vpn: 'VPN',
network: 'Сеть',
internet: 'Интернет',
bgp: 'BGP',
custom: 'Другое',
}
interface ServiceCatalogSectionProps {
group: ServiceGroupView | null
services: ServiceView[]
togglingId: number | null
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup: (group: ServiceGroupView) => void
onAddServiceToGroup: (groupId: number | null) => void
}
export function ServiceCatalogSection({
group,
services,
togglingId,
onEditService,
onDeleteService,
onToggleService,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
}: ServiceCatalogSectionProps) {
const title = group?.name ?? 'Без группы'
const typeLabel = group ? GROUP_TYPE_LABELS[group.type] : null
const groupId = group?.id ?? null
return (
<section
className="@container flex w-full flex-col gap-2"
aria-labelledby={`group-${groupId ?? 'none'}`}
>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<IconTile
variant="elevated"
size="sm"
className="text-muted-foreground"
aria-hidden="true"
>
{group ? (
<ServiceGroupIcon type={group.type} />
) : (
<FolderOpenIcon />
)}
</IconTile>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<h2
id={`group-${groupId ?? 'none'}`}
className="truncate text-sm font-medium"
>
{title}
</h2>
{typeLabel ? (
<span className="text-muted-foreground text-xs">{typeLabel}</span>
) : null}
<Badge variant="outline" size="xs" className="shrink-0 tabular-nums">
{services.length}
</Badge>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={`Добавить сервис в ${title}`}
onClick={() => onAddServiceToGroup(groupId)}
>
<PlusIcon aria-hidden />
</Button>
{group ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия группы ${group.name}`}
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onAddServiceToGroup(group.id)}>
<PlusIcon aria-hidden />
Добавить сервис
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditGroup(group)}>
Изменить группу
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteGroup(group)}
>
Удалить группу
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
{services.length === 0 ? (
<div className="flex flex-wrap items-center justify-between gap-2 py-1">
<span className="text-muted-foreground text-sm">Нет сервисов</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onAddServiceToGroup(groupId)}
>
<PlusIcon data-icon="inline-start" aria-hidden />
Добавить сервис
</Button>
</div>
) : (
<div className="grid grid-cols-1 gap-2 @xl:grid-cols-2 @4xl:grid-cols-3">
{services.map((service) => (
<ServiceUnitCard
key={service.id}
service={service}
togglingId={togglingId}
onEditService={onEditService}
onDeleteService={onDeleteService}
onToggleService={onToggleService}
/>
))}
</div>
)}
</section>
)
}
@@ -1,7 +1,13 @@
import { CheckIcon, CopyIcon } from 'lucide-react'
import { toast } from 'sonner'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { Badge } from '@/components/reui/badge'
import { TruncatedText } from '@/components/truncated-text'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { serviceDisplayFqdns } from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
Tooltip,
TooltipContent,
@@ -10,16 +16,53 @@ import {
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
export function CopyFqdnButton({
value,
className,
}: {
value: string
className?: string
}) {
const { isCopied, copyToClipboard } = useCopyToClipboard({
onCopy: () => toast.success('Скопировано'),
})
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className={className}
aria-label={isCopied ? 'Скопировано' : `Скопировать ${value}`}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
copyToClipboard(value)
}}
>
{isCopied ? (
<CheckIcon className="text-success" aria-hidden />
) : (
<CopyIcon aria-hidden />
)}
</Button>
)
}
interface ServiceFqdnListProps {
service: ServiceView
className?: string
emptyLabel?: string
copyable?: boolean
textClassName?: string
}
export function ServiceFqdnList({
service,
className,
emptyLabel = 'FQDN не задан',
emptyLabel = 'Нет FQDN',
copyable = false,
textClassName,
}: ServiceFqdnListProps) {
const fqdns = serviceDisplayFqdns(service)
if (fqdns.length === 0) {
@@ -32,10 +75,16 @@ export function ServiceFqdnList({
const [first, ...rest] = fqdns
const extraCount = rest.length
const copyValue = fqdns.join('\n')
return (
<div className={cn('flex min-w-0 items-center gap-1.5', className)}>
<TruncatedText className="text-muted-foreground min-w-0 font-mono text-xs">
<TruncatedText
className={cn(
'text-muted-foreground min-w-0 font-mono text-xs',
textClassName,
)}
>
{first}
</TruncatedText>
{extraCount > 0 ? (
@@ -62,6 +111,89 @@ export function ServiceFqdnList({
</Tooltip>
</TooltipProvider>
) : null}
{copyable ? <CopyFqdnButton value={copyValue} /> : null}
</div>
)
}
const VISIBLE_IP_LIMIT = 6
interface ServiceIpListProps {
ips: string[]
ipHealth?: ServiceView['ip_health']
className?: string
emptyLabel?: string
copyable?: boolean
textClassName?: string
}
export function ServiceIpList({
ips,
ipHealth = [],
className,
emptyLabel = 'Нет IP',
copyable = false,
textClassName,
}: ServiceIpListProps) {
if (ips.length === 0) {
return (
<span className={cn('text-muted-foreground text-xs', className)}>
{emptyLabel}
</span>
)
}
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
const visible = ips.slice(0, VISIBLE_IP_LIMIT)
const extraCount = ips.length - visible.length
return (
<div className={cn('flex min-w-0 flex-col gap-1', className)}>
{visible.map((ip) => {
const health = healthByIp.get(ip)
return (
<div key={ip} className="flex min-w-0 items-center gap-1.5">
<HealthCheckBadge
status={health?.status ?? 'unknown'}
latencyMs={health?.latency_ms}
size="xs"
/>
<TruncatedText
className={cn(
'text-muted-foreground min-w-0 font-mono text-xs',
textClassName,
)}
>
{ip}
</TruncatedText>
{copyable ? <CopyFqdnButton value={ip} /> : null}
</div>
)
})}
{extraCount > 0 ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Badge
variant="outline"
size="xs"
className="w-fit shrink-0 tabular-nums"
/>
}
>
ещё {extraCount}
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<ul className="flex flex-col gap-0.5 font-mono text-xs">
{ips.slice(VISIBLE_IP_LIMIT).map((ip) => (
<li key={ip}>{ip}</li>
))}
</ul>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
)
}
@@ -0,0 +1,128 @@
import { Link } from '@tanstack/react-router'
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import {
ServiceFqdnList,
ServiceIpList,
} from '@/components/services/service-fqdn-list'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Switch } from '@cfdm/ui/components/switch'
interface ServiceUnitCardProps {
service: ServiceView
togglingId: number | null
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
}
export function ServiceUnitCard({
service,
togglingId,
onEditService,
onDeleteService,
onToggleService,
}: ServiceUnitCardProps) {
return (
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden">
<FrameHeader className="flex-row items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<IconTile
variant="elevated"
size="sm"
className="text-muted-foreground"
aria-hidden="true"
>
<ServerIcon />
</IconTile>
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle className="min-w-0 truncate text-sm">
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
className="hover:underline"
>
{service.name}
</Link>
</FrameTitle>
<FrameDescription className="truncate font-mono text-xs">
{service.slug}
</FrameDescription>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Switch
size="sm"
checked={service.enabled}
disabled={togglingId === service.id}
onCheckedChange={(checked) =>
onToggleService(service.id, Boolean(checked))
}
aria-label={
service.enabled ? 'Выключить сервис' : 'Включить сервис'
}
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия ${service.name}`}
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
render={
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditService(service)}>
Изменить
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteService(service)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</FrameHeader>
<FramePanel className="flex min-w-0 flex-col gap-1.5 pt-0 shadow-none!">
<ServiceFqdnList copyable service={service} emptyLabel="Не задан" />
<ServiceIpList
copyable
ips={service.ips ?? []}
ipHealth={service.ip_health ?? []}
/>
</FramePanel>
</Frame>
)
}
@@ -1,45 +1,26 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import {
getCoreRowModel,
getExpandedRowModel,
useReactTable,
type ExpandedState,
} from '@tanstack/react-table'
import { useMemo, useState, type ReactNode } from 'react'
import {
ChevronDownIcon,
FilterIcon,
FolderPlusIcon,
FunnelXIcon,
PlusIcon,
SearchIcon,
ServerIcon,
} from 'lucide-react'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { Filters, type Filter } from '@/components/reui/filters'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { EmptyState } from '@/components/empty-state'
import { applyFiltersToData } from '@/components/reui-kit/filter-utils'
import { createServicesGroupedColumns } from '@/components/services/services-grouped-columns'
import type { ServiceCatalogTreeRow } from '@/components/services/services-grouped-columns'
import { ServiceCatalogSection } from '@/components/services/service-catalog-section'
import {
SERVICE_TABS,
createDefaultServiceFilters,
serviceFilterFieldValue,
serviceTabFilter,
useServiceFilterFields,
type ServiceCatalogRow,
} from '@/components/columns/services-columns'
import { serviceDisplayFqdns } from '@/lib/service-utils'
import type {
ServiceGroupView,
ServiceGroupsResponse,
@@ -52,23 +33,29 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Separator } from '@cfdm/ui/components/separator'
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from '@cfdm/ui/components/input-group'
import { Skeleton } from '@cfdm/ui/components/skeleton'
const HEALTH_TABS = [
{ id: 'health-ok', label: 'OK' },
{ id: 'health-slow', label: 'Slow' },
{ id: 'health-down', label: 'Down' },
{ id: 'health-unknown', label: '—' },
] as const
const ALL_TABS = [...SERVICE_TABS, ...HEALTH_TABS] as const
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
if (domainId == null) return true
return service.domains.some((d) => d.domain_id === domainId)
}
function serviceMatchesQuery(service: ServiceView, query: string) {
const needle = query.trim().toLowerCase()
if (!needle) return true
if (service.name.toLowerCase().includes(needle)) return true
if (service.slug.toLowerCase().includes(needle)) return true
return serviceDisplayFqdns(service).some((fqdn) =>
fqdn.toLowerCase().includes(needle),
)
}
function toCatalogRow(
service: ServiceView,
groupId: number | null,
@@ -86,72 +73,51 @@ function toCatalogRow(
}
}
function catalogTabFilter(row: ServiceCatalogRow, tabId: string) {
if (tabId.startsWith('health-')) {
const status = row.service.health_status ?? 'unknown'
if (tabId === 'health-ok') return status === 'up'
if (tabId === 'health-slow') return status === 'degraded'
if (tabId === 'health-down') return status === 'down'
if (tabId === 'health-unknown') return status === 'unknown'
return true
}
return serviceTabFilter(row, tabId)
interface GroupUnitData {
id: string
group: ServiceGroupView | null
services: ServiceView[]
}
function buildTreeRows(
function buildGroupUnits(
data: ServiceGroupsResponse,
filteredServiceIds: Set<number>,
domainId?: number,
): ServiceCatalogTreeRow[] {
const rows: ServiceCatalogTreeRow[] = []
domainId: number | undefined,
showEmptyGroups: boolean,
): GroupUnitData[] {
const units: GroupUnitData[] = []
for (const group of data.groups) {
const services = group.services
.filter((s) => serviceMatchesDomain(s, domainId))
.filter((s) => filteredServiceIds.has(s.id))
if (services.length === 0) continue
const matchingDomain = group.services.filter((service) =>
serviceMatchesDomain(service, domainId),
)
const services = matchingDomain.filter((service) =>
filteredServiceIds.has(service.id),
)
rows.push({
kind: 'group',
id: `group-${group.id}`,
group,
subRows: services.map((service) => ({
kind: 'service' as const,
id: `service-${service.id}`,
service,
groupId: group.id,
groupName: group.name,
})),
})
if (services.length === 0) {
if (showEmptyGroups && matchingDomain.length === 0) {
units.push({ id: `group-${group.id}`, group, services: [] })
}
continue
}
units.push({ id: `group-${group.id}`, group, services })
}
const ungrouped = data.ungrouped
.filter((s) => serviceMatchesDomain(s, domainId))
.filter((s) => filteredServiceIds.has(s.id))
.filter((service) => serviceMatchesDomain(service, domainId))
.filter((service) => filteredServiceIds.has(service.id))
if (ungrouped.length > 0) {
rows.push({
kind: 'group',
units.push({
id: 'group-ungrouped',
group: null,
subRows: ungrouped.map((service) => ({
kind: 'service' as const,
id: `service-${service.id}`,
service,
groupId: null,
groupName: null,
})),
services: ungrouped,
})
}
return rows
}
function defaultExpanded(rows: ServiceCatalogTreeRow[]): ExpandedState {
return rows.reduce<Record<string, boolean>>((acc, row) => {
acc[row.id] = true
return acc
}, {})
return units
}
export function ServicesAddMenu({
@@ -213,8 +179,7 @@ export function ServicesGroupedCatalog({
primaryAction,
hideHeader = false,
togglingId,
activeTab: controlledTab,
onTabChange,
activeTab = 'all',
onEditService,
onDeleteService,
onToggleService,
@@ -223,14 +188,7 @@ export function ServicesGroupedCatalog({
onAddServiceToGroup,
emptyAction,
}: ServicesGroupedCatalogProps) {
const [internalTab, setInternalTab] = useState('all')
const tab = controlledTab ?? internalTab
const setTab = onTabChange ?? setInternalTab
const [filters, setFilters] = useState<Filter[]>(() =>
createDefaultServiceFilters(),
)
const [expanded, setExpanded] = useState<ExpandedState>({})
const filterFields = useServiceFilterFields()
const [query, setQuery] = useState('')
const flatRows = useMemo(() => {
const rows: ServiceCatalogRow[] = []
@@ -247,76 +205,33 @@ export function ServicesGroupedCatalog({
return rows
}, [data, domainId])
const tabCounts = useMemo(() => {
const counts: Record<string, number> = {}
for (const t of ALL_TABS) {
counts[t.id] = flatRows.filter((row) => catalogTabFilter(row, t.id)).length
}
return counts
}, [flatRows])
const filteredIds = useMemo(() => {
const afterTab = flatRows.filter((row) => catalogTabFilter(row, tab))
const afterFilters = applyFiltersToData(afterTab, filters, (item, field) =>
serviceFilterFieldValue(item, field),
const afterTab = flatRows.filter((row) => serviceTabFilter(row, activeTab))
const afterQuery = afterTab.filter((row) =>
serviceMatchesQuery(row.service, query),
)
return new Set(afterFilters.map((r) => r.id))
}, [flatRows, tab, filters])
return new Set(afterQuery.map((r) => r.id))
}, [flatRows, activeTab, query])
const treeData = useMemo(
() => buildTreeRows(data, filteredIds, domainId),
[data, filteredIds, domainId],
const showEmptyGroups =
activeTab === 'all' && domainId == null && query.trim().length === 0
const units = useMemo(
() => buildGroupUnits(data, filteredIds, domainId, showEmptyGroups),
[data, filteredIds, domainId, showEmptyGroups],
)
const expandedKey = treeData.map((r) => r.id).join(',')
useEffect(() => {
setExpanded(defaultExpanded(treeData))
}, [expandedKey, treeData])
const columns = useMemo(
() =>
createServicesGroupedColumns({
onEditService,
onDeleteService,
onToggleService,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
togglingId,
}),
[
onEditService,
onDeleteService,
onToggleService,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
togglingId,
],
)
const table = useReactTable({
data: treeData,
columns,
state: { expanded },
onExpandedChange: setExpanded,
getSubRows: (row) => (row.kind === 'group' ? row.subRows : undefined),
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getRowId: (row) => row.id,
})
if (isLoading) {
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="mt-1 h-4 w-72" />
<Skeleton className="h-4 w-72" />
</FrameHeader>
<FramePanel className="flex flex-col gap-3 p-4">
<Skeleton className="h-9 w-full max-w-md" />
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
<Skeleton className="h-8 w-full max-w-md" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-36 w-full rounded-xl" />
))}
</FramePanel>
</Frame>
@@ -342,112 +257,64 @@ export function ServicesGroupedCatalog({
}
return (
<DataGrid
table={table}
recordCount={filteredIds.size}
emptyMessage="Нет записей по выбранным фильтрам."
tableLayout={{ dense: true }}
>
<Frame dense spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle>Сервисы</FrameTitle>
<FrameDescription>
{domainLabel
? `Каталог сервисов с привязками к ${domainLabel}`
: 'Группы, FQDN и доступность сервисов'}
</FrameDescription>
<Frame dense spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle>Сервисы</FrameTitle>
<FrameDescription>
{domainLabel
? `Каталог сервисов с привязками к ${domainLabel}`
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'}
</FrameDescription>
</div>
{primaryAction ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{primaryAction}
</div>
{primaryAction ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{primaryAction}
</div>
) : null}
</FrameHeader>
) : null}
) : null}
</FrameHeader>
) : null}
<FramePanel className="p-0 shadow-none!">
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={ALL_TABS.map((t) => ({
id: t.id,
label: t.label,
count: tabCounts[t.id] ?? 0,
}))}
value={tab}
onValueChange={setTab}
/>
</div>
<FramePanel className="flex flex-col gap-4">
<InputGroup className="max-w-md">
<InputGroupAddon>
<InputGroupText>
<SearchIcon aria-hidden />
</InputGroupText>
</InputGroupAddon>
<InputGroupInput
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Поиск по названию"
aria-label="Поиск по названию"
/>
</InputGroup>
<Separator />
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
<Filters
filters={filters}
fields={filterFields}
onChange={setFilters}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden />
Фильтры
</Button>
}
/>
<Button
type="button"
variant="outline"
onClick={() => {
setTab('all')
setFilters(createDefaultServiceFilters())
}}
>
<FunnelXIcon className="size-4" aria-hidden />
Сбросить
</Button>
</div>
<Separator />
{treeData.length === 0 ? (
<div className="p-6">
<EmptyState
title="Нет совпадений"
description="Измените фильтры или вкладку."
action={
<Button
type="button"
variant="outline"
onClick={() => {
setTab('all')
setFilters(createDefaultServiceFilters())
}}
>
Сбросить
</Button>
}
{units.length === 0 ? (
<EmptyState
title="Нет совпадений"
description="Измените запрос поиска."
/>
) : (
<div className="flex flex-col gap-6">
{units.map((unit) => (
<ServiceCatalogSection
key={unit.id}
group={unit.group}
services={unit.services}
togglingId={togglingId}
onEditService={onEditService}
onDeleteService={onDeleteService}
onToggleService={onToggleService}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
onAddServiceToGroup={onAddServiceToGroup}
/>
</div>
) : (
<>
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination
sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/>
</FrameFooter>
</>
)}
</FramePanel>
</Frame>
</DataGrid>
))}
</div>
)}
</FramePanel>
</Frame>
)
}
@@ -1,308 +0,0 @@
import { useMemo } from 'react'
import { Link } from '@tanstack/react-router'
import type { ColumnDef } from '@tanstack/react-table'
import {
ChevronRightIcon,
FolderIcon,
MoreHorizontalIcon,
PlusIcon,
} from 'lucide-react'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { Switch } from '@cfdm/ui/components/switch'
import { Badge } from '@/components/reui/badge'
import { cn } from '@cfdm/ui/lib/utils'
export type ServiceTreeServiceRow = {
kind: 'service'
id: string
service: ServiceView
groupId: number | null
groupName: string | null
}
export type ServiceTreeGroupRow = {
kind: 'group'
id: string
group: ServiceGroupView | null
subRows: ServiceTreeServiceRow[]
}
export type ServiceCatalogTreeRow = ServiceTreeGroupRow | ServiceTreeServiceRow
function isServiceRow(row: ServiceCatalogTreeRow): row is ServiceTreeServiceRow {
return row.kind === 'service'
}
export function createServicesGroupedColumns({
onEditService,
onDeleteService,
onToggleService,
onEditGroup,
onDeleteGroup,
onAddServiceToGroup,
togglingId,
}: {
onEditService: (service: ServiceView) => void
onDeleteService: (service: ServiceView) => void
onToggleService: (serviceId: number, enabled: boolean) => void
onEditGroup: (group: ServiceGroupView) => void
onDeleteGroup: (group: ServiceGroupView) => void
onAddServiceToGroup: (groupId: number | null) => void
togglingId: number | null
}): ColumnDef<ServiceCatalogTreeRow>[] {
return [
{
id: 'name',
accessorFn: (row) =>
isServiceRow(row) ? row.service.name : (row.group?.name ?? 'Без группы'),
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Группа / сервис" />
),
cell: ({ row }) => {
const original = row.original
if (!isServiceRow(original)) {
const title = original.group?.name ?? 'Без группы'
const domain = original.group?.domain
return (
<div className="flex min-w-0 items-center gap-2">
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label={
row.getIsExpanded() ? `Свернуть ${title}` : `Развернуть ${title}`
}
aria-expanded={row.getIsExpanded()}
className="text-muted-foreground hover:text-foreground size-6 shrink-0 p-0 shadow-none"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
row.getToggleExpandedHandler()()
}}
>
<ChevronRightIcon
className={cn(
'size-3.5 shrink-0 transition-transform duration-150',
row.getIsExpanded() && 'rotate-90',
)}
aria-hidden
/>
</Button>
<FolderIcon
className="text-muted-foreground size-4 shrink-0"
aria-hidden
/>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold">{title}</span>
<Badge variant="outline" size="xs" className="shrink-0">
{original.subRows.length}
</Badge>
</div>
{domain ? (
<span className="text-muted-foreground truncate font-mono text-xs">
{domain}
</span>
) : null}
</div>
</div>
)
}
return (
<div className="flex min-w-0 flex-col gap-0.5 pl-8">
<span className="truncate text-sm font-medium">
{original.service.name}
</span>
<ServiceFqdnList service={original.service} emptyLabel="—" />
</div>
)
},
enableSorting: false,
minSize: 260,
meta: { headerTitle: 'Группа / сервис', autoSize: true },
},
{
id: 'health',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Доступность" />
),
cell: ({ row }) => {
const original = row.original
if (!isServiceRow(original)) {
return (
<HealthCheckBadge
status={original.group?.health_status ?? 'unknown'}
latencyMs={original.group?.health_latency_ms}
size="xs"
/>
)
}
return (
<HealthCheckBadge
status={original.service.health_status ?? 'unknown'}
latencyMs={original.service.health_latency_ms}
size="xs"
/>
)
},
size: 120,
enableSorting: false,
},
{
id: 'enabled',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Статус" />
),
cell: ({ row }) => {
const original = row.original
if (!isServiceRow(original)) return null
return (
<div className="flex items-center gap-2">
<Switch
checked={original.service.enabled}
disabled={togglingId === original.service.id}
onCheckedChange={(checked) =>
onToggleService(original.service.id, Boolean(checked))
}
aria-label={
original.service.enabled
? 'Выключить сервис'
: 'Включить сервис'
}
/>
<StatusBadge
status={original.service.enabled ? 'active' : 'disabled'}
label={original.service.enabled ? 'Вкл' : 'Выкл'}
/>
</div>
)
},
size: 140,
enableSorting: false,
},
{
id: 'actions',
enableSorting: false,
header: () => <span className="sr-only">Действия</span>,
cell: ({ row }) => {
const original = row.original
if (!isServiceRow(original)) {
if (!original.group) {
return (
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="Добавить сервис без группы"
onClick={() => onAddServiceToGroup(null)}
>
<PlusIcon aria-hidden />
</Button>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия группы ${original.group.name}`}
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => onAddServiceToGroup(original.group!.id)}
>
<PlusIcon aria-hidden />
Добавить сервис
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditGroup(original.group!)}>
Изменить группу
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteGroup(original.group!)}
>
Удалить группу
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия ${original.service.name}`}
/>
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
render={
<Link
to="/services/$serviceId"
params={{ serviceId: String(original.service.id) }}
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditService(original.service)}>
Изменить
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteService(original.service)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
size: 56,
},
]
}
export function useServicesGroupedColumns(
args: Parameters<typeof createServicesGroupedColumns>[0],
) {
return useMemo(() => createServicesGroupedColumns(args), [
args.onEditService,
args.onDeleteService,
args.onToggleService,
args.onEditGroup,
args.onDeleteGroup,
args.onAddServiceToGroup,
args.togglingId,
])
}
+7
View File
@@ -94,6 +94,12 @@ export const serviceDomainBindingSchema = z
: (binding.record_type ?? 'A'),
}))
export const serviceIpHealthSchema = z.object({
ip: z.string(),
status: z.enum(['up', 'down', 'degraded', 'unknown']),
latency_ms: z.number().nullable(),
})
export const serviceViewSchema = serviceSchema.extend({
subdomain: z.string().default(''),
enabled: z.coerce.boolean().default(false),
@@ -101,6 +107,7 @@ export const serviceViewSchema = serviceSchema.extend({
domains: z.array(serviceDomainBindingSchema).default([]),
health_status: z.enum(['up', 'down', 'degraded', 'unknown']).default('unknown'),
health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]),
})
export const serviceGroupViewSchema = serviceGroupSchema.extend({
+1 -2
View File
@@ -456,7 +456,6 @@ function ServicesPage() {
board.columns.map((column, index) => ({
id: column.id,
title: column.title,
description: column.domain ?? undefined,
healthStatus: column.group?.health_status,
healthLatencyMs: column.group?.health_latency_ms,
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
@@ -494,7 +493,7 @@ function ServicesPage() {
const pageDescription = filteredDomain
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
: 'Группы, FQDN и доступность сервисов'
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'
const sheets = (
<>
+10 -1
View File
File diff suppressed because one or more lines are too long
+34
View File
@@ -631,6 +631,7 @@ __export(repos_exports, {
listGroups: () => listGroups,
listHealthCheckTargets: () => listHealthCheckTargets,
listHealthChecks: () => listHealthChecks,
listIpHealthByServiceIds: () => listIpHealthByServiceIds,
listIpHealthStatus: () => listIpHealthStatus,
listNodes: () => listNodes,
listNotificationLog: () => listNotificationLog,
@@ -1822,6 +1823,39 @@ function aggregateIpHealthByServiceIds(db, serviceIds) {
}
return result;
}
function listIpHealthByServiceIds(db, serviceIds) {
const result = /* @__PURE__ */ new Map();
if (serviceIds.length === 0) return result;
const idList = sql2.join(
serviceIds.map((id) => sql2`${id}`),
sql2`, `
);
const rows = db.all(sql2`
SELECT sb.service_id AS service_id,
ihs.ip AS ip,
${WORST_HEALTH_SQL} AS health_status,
MAX(ihs.latency_ms) AS health_latency_ms
FROM ip_health_status ihs
INNER JOIN service_bindings sb
ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
WHERE sb.service_id IN (${idList})
GROUP BY sb.service_id, ihs.ip
`);
for (const row of rows) {
const parsed = parseHealthAggregateRow({
health_status: row.health_status,
health_latency_ms: row.health_latency_ms
});
const list = result.get(row.service_id) ?? [];
list.push({
ip: row.ip,
status: parsed.health_status,
latency_ms: parsed.health_latency_ms
});
result.set(row.service_id, list);
}
return result;
}
function mergeHealthAggregates(parts) {
const rank = {
unknown: 0,
+49
View File
@@ -2070,6 +2070,55 @@ export function aggregateIpHealthByServiceIds(
return result;
}
export type ServiceIpHealthRow = {
ip: string;
status: IpHealthState;
latency_ms: number | null;
};
/** Per-IP binding-scope health, worst status if the same IP is on several bindings. */
export function listIpHealthByServiceIds(
db: Db,
serviceIds: number[],
): Map<number, ServiceIpHealthRow[]> {
const result = new Map<number, ServiceIpHealthRow[]>();
if (serviceIds.length === 0) return result;
const idList = sql.join(
serviceIds.map((id) => sql`${id}`),
sql`, `,
);
const rows = db.all<{
service_id: number;
ip: string;
health_status: string | null;
health_latency_ms: number | null;
}>(sql`
SELECT sb.service_id AS service_id,
ihs.ip AS ip,
${WORST_HEALTH_SQL} AS health_status,
MAX(ihs.latency_ms) AS health_latency_ms
FROM ip_health_status ihs
INNER JOIN service_bindings sb
ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
WHERE sb.service_id IN (${idList})
GROUP BY sb.service_id, ihs.ip
`);
for (const row of rows) {
const parsed = parseHealthAggregateRow({
health_status: row.health_status,
health_latency_ms: row.health_latency_ms,
});
const list = result.get(row.service_id) ?? [];
list.push({
ip: row.ip,
status: parsed.health_status,
latency_ms: parsed.health_latency_ms,
});
result.set(row.service_id, list);
}
return result;
}
export function mergeHealthAggregates(
parts: Array<HealthAggregate | undefined | null>,
): HealthAggregate {
+58 -1
View File
@@ -132,6 +132,7 @@ interface ServiceView$1 {
domains: ServiceDomainBindingView[];
health_status: IpHealthState;
health_latency_ms: number | null;
ip_health: ServiceIpHealth$1[];
}
interface SyncJob {
id: string;
@@ -192,6 +193,11 @@ interface IpHealthStatus {
last_checked_at: string | null;
last_error: string | null;
}
interface ServiceIpHealth$1 {
ip: string;
status: IpHealthState;
latency_ms: number | null;
}
interface ServiceNode {
id: number;
service_id: number;
@@ -372,6 +378,17 @@ declare const ipHealthStatusSchema: z.ZodObject<{
last_checked_at: z.ZodNullable<z.ZodString>;
last_error: z.ZodNullable<z.ZodString>;
}, z.core.$strip>;
declare const serviceIpHealthSchema: z.ZodObject<{
ip: z.ZodString;
status: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
}, z.core.$strip>;
type ServiceIpHealth = z.infer<typeof serviceIpHealthSchema>;
declare const groupSchema: z.ZodObject<{
id: z.ZodNumber;
name: z.ZodString;
@@ -619,6 +636,16 @@ declare const serviceViewSchema: z.ZodObject<{
degraded: "degraded";
}>>;
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
ip_health: z.ZodDefault<z.ZodArray<z.ZodObject<{
ip: z.ZodString;
status: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
}, z.core.$strip>>>;
}, z.core.$strip>;
declare const serviceGroupViewSchema: z.ZodObject<{
id: z.ZodNumber;
@@ -752,6 +779,16 @@ declare const serviceGroupViewSchema: z.ZodObject<{
degraded: "degraded";
}>>;
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
ip_health: z.ZodDefault<z.ZodArray<z.ZodObject<{
ip: z.ZodString;
status: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
}, z.core.$strip>>>;
}, z.core.$strip>>>;
health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown";
@@ -894,6 +931,16 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
degraded: "degraded";
}>>;
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
ip_health: z.ZodDefault<z.ZodArray<z.ZodObject<{
ip: z.ZodString;
status: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
}, z.core.$strip>>>;
}, z.core.$strip>>>;
health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown";
@@ -1002,6 +1049,16 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
degraded: "degraded";
}>>;
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
ip_health: z.ZodDefault<z.ZodArray<z.ZodObject<{
ip: z.ZodString;
status: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
}, z.core.$strip>>>;
}, z.core.$strip>>>;
}, z.core.$strip>;
declare const domainSchema: z.ZodObject<{
@@ -1835,4 +1892,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{
}, z.core.$strip>;
type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>;
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, notificationLogSchema, originHealthCheckSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, notificationLogSchema, originHealthCheckSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
+8 -1
View File
@@ -192,6 +192,11 @@ var ipHealthStatusSchema = z.object({
last_checked_at: z.string().nullable(),
last_error: z.string().nullable()
});
var serviceIpHealthSchema = z.object({
ip: z.string(),
status: ipHealthStateSchema,
latency_ms: z.number().nullable()
});
var groupSchema = z.object({
id: z.number(),
name: z.string(),
@@ -277,7 +282,8 @@ var serviceViewSchema = serviceSchema.extend({
ips: z.array(z.string()).default([]),
domains: z.array(serviceDomainBindingSchema).default([]),
health_status: ipHealthStateSchema.default("unknown"),
health_latency_ms: z.number().nullable().default(null)
health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([])
});
var serviceGroupViewSchema = serviceGroupSchema.extend({
services: z.array(serviceViewSchema).default([]),
@@ -843,6 +849,7 @@ export {
serviceGroupTypeSchema,
serviceGroupViewSchema,
serviceGroupsResponseSchema,
serviceIpHealthSchema,
serviceNodeSchema,
serviceSchema,
serviceViewSchema,
+9
View File
@@ -49,6 +49,14 @@ export const ipHealthStatusSchema = z.object({
export type IpHealthStatus = z.infer<typeof ipHealthStatusSchema>
export const serviceIpHealthSchema = z.object({
ip: z.string(),
status: ipHealthStateSchema,
latency_ms: z.number().nullable(),
})
export type ServiceIpHealth = z.infer<typeof serviceIpHealthSchema>
export const groupSchema = z.object({
id: z.number(),
name: z.string(),
@@ -150,6 +158,7 @@ export const serviceViewSchema = serviceSchema.extend({
domains: z.array(serviceDomainBindingSchema).default([]),
health_status: ipHealthStateSchema.default('unknown'),
health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]),
})
export const serviceGroupViewSchema = serviceGroupSchema.extend({
+7
View File
@@ -196,6 +196,7 @@ export interface ServiceView {
domains: ServiceDomainBindingView[];
health_status: IpHealthState;
health_latency_ms: number | null;
ip_health: ServiceIpHealth[];
}
export interface GroupWithStats extends Group {
@@ -293,6 +294,12 @@ export interface IpHealthStatus {
last_error: string | null;
}
export interface ServiceIpHealth {
ip: string;
status: IpHealthState;
latency_ms: number | null;
}
export interface ServiceNode {
id: number;
service_id: number;