Compare commits

..
24 Commits
Author SHA1 Message Date
DenozordecandCursor 4c59780ff0 fix(health): импортировать isHealthy при нескольких FQDN на одни IP
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / web (push) Skipped
quality / docker-check (push) Skipped
quality / api (push) Successful in 42s
CD / quality (push) Successful in 54s
CD / publish (push) Successful in 1m36s
Схлопывание дубликатов в группе вызывало ReferenceError при сохранении сервиса.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 14:01:43 +07:00
DenozordecandCursor 1457389ae7 feat(services): объединить адреса сервиса в один блок формы
CD / update-wiki (push) Successful in 7s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 55s
CD / quality (push) Successful in 1m6s
CD / publish (push) Successful in 1m36s
Общий FQDN, пул IP и доп. домен на строке IP — в одном Frame, без смены API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 13:33:02 +07:00
DenozordecandCursor 153be28799 fix(health): показывать пинг при наведении на график uptime
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m11s
CD / quality (push) Successful in 1m23s
CD / publish (push) Successful in 1m57s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:44:17 +07:00
DenozordecandCursor 39fac7834f fix(ui): не дублировать крошку Настройки при смене раздела
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m2s
CD / quality (push) Successful in 1m15s
CD / publish (push) Successful in 1m58s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:28:06 +07:00
DenozordecandCursor d267e40157 fix(health): выровнять график источников и KPI на карточке сервиса
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:27:21 +07:00
DenozordecandCursor 634a9dc362 fix(health): заменить плитки источников на компактный мультивыбор
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 43s
CD / quality (push) Successful in 1m48s
CD / publish (push) Successful in 1m39s
Крупные KPI-кнопки и вкладка «Все» заменены на ToggleGroup: фильтр занимает меньше места и не сбрасывает последний источник.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 02:13:57 +07:00
DenozordecandCursor 1bf6cfa0d0 fix(health): разворачивать CNAME до IP в статусе таблицы
Пробы CNAME больше не ключуются hostname: цель разворачивается до origin/пула, а уже сохранённый статус по CNAME копируется на IP сервиса.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 02:13:09 +07:00
DenozordecandCursor 3da6de9311 fix(health): сузить тип переключателя источников для tsc -b
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 1m3s
CD / publish (push) Successful in 1m53s
ALL_TAB как литерал 'all', чтобы SourceTab не попадал в HealthCheckProvider[].

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 01:49:42 +07:00
DenozordecandCursor 7a8bacade9 feat(health): объединить источники, график и таймлайн в один блок мониторинга
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 9s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Failing after 49s
CD / quality (push) Failing after 1m1s
CD / publish (push) Skipped
Переключатель серий в шапке Frame меняет график uptime и смены статуса без отдельного списка плиток.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 01:44:16 +07:00
DenozordecandCursor 5d84c7bf6c fix(services): сохранять источники health-check и агрегацию в форме сервиса
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 9s
quality / changes (push) Successful in 12s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m14s
quality / api (push) Successful in 53s
CD / quality (push) Successful in 2m23s
CD / publish (push) Successful in 2m5s
GET отдавал sqlite-типы (0/1 и JSON-строка), Zod отклонял PATCH; форма сбрасывалась на refetch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 01:03:00 +07:00
Denozordec 78811bc9b1 feat(uptime-chart): enhance tooltip functionality and refactor delta display
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
CD / quality (push) Successful in 1m6s
CD / publish (push) Successful in 1m53s
- Added useEffect to set tooltip portal for improved rendering.
- Introduced UptimeDelta component to streamline delta display logic.
- Updated ChartTooltip to utilize ChartTooltipContent for better formatting of tooltip data.
- Refactored chart margin and overflow properties for improved layout consistency.
2026-08-20 00:49:27 +07:00
Denozordec a458465153 feat(certificates): enhance service certificate management and monitoring
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 10s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m8s
quality / api (push) Successful in 1m9s
CD / quality (push) Successful in 2m31s
CD / publish (push) Successful in 1m35s
- Added new endpoints for listing and checking service certificates, improving visibility into SSL status.
- Integrated certificate monitoring options into service binding updates, allowing for flexible SSL management.
- Updated the service detail grid to include SSL monitoring controls, enhancing user interaction with certificate settings.
- Refactored related components and schemas to support the new certificate features, ensuring consistency across the application.
- Improved test coverage for certificate functionalities, validating the new features and ensuring reliability.
2026-08-20 00:32:43 +07:00
Denozordec 69119a08a4 feat(health): enhance HealthTimeline and HealthSourceTiles components
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 10s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m1s
CD / quality (push) Successful in 1m15s
CD / publish (push) Successful in 2m11s
- Added optional props `emptyTitle` and `emptyDescription` to HealthTimeline for customizable empty state messages.
- Refactored HealthSourceTiles to export `HEALTH_PROVIDER_ITEMS` and introduced a new `HealthProviderStatusTiles` component for improved health monitoring.
- Updated UptimeChart to support period selection and filtering, enhancing data visualization capabilities.
- Improved ServiceDetailPage by integrating HealthProviderStatusTiles and ServiceHealthMonitor for better service health insights.
2026-08-20 00:09:55 +07:00
Denozordec ba03d2be9d feat(services): enhance service health logging and component structure
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 59s
quality / api (push) Successful in 44s
CD / quality (push) Successful in 2m0s
CD / publish (push) Successful in 1m37s
- Updated the service health log retrieval to include a limit parameter, improving performance and control over log data.
- Refactored the ServiceUnitCard component to export the LbModeTile function, enhancing reusability across the application.
- Simplified routing for service health and nodes pages by redirecting to the service overview, improving user navigation.
- Enhanced the service detail page with additional state management and mutation hooks for better service configuration handling.
- Removed unused components and streamlined the service routing and subdomains pages for improved clarity and maintainability.
2026-08-19 23:45:53 +07:00
Denozordec d8fc4ac949 feat(services): update ServiceUnitCard with background color for FramePanel
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 1m3s
CD / publish (push) Successful in 1m36s
- Added `bg-muted` class to FramePanel in ServiceUnitCard for improved visual consistency.
- Updated documentation to reflect the new background color setting for FramePanel, enhancing clarity on component usage.
2026-08-19 23:13:58 +07:00
Denozordec d063323402 feat(services): add load balancing mode and active IPs to service view
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m15s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 2m17s
CD / publish (push) Successful in 1m41s
- Introduced `lb_mode` to specify the load balancing strategy for services, supporting options like round robin, failover, and weighted.
- Added `active_ips` to track currently active IPs for each service, enhancing visibility into service health and configuration.
- Updated relevant components to display load balancing mode and active IPs, improving user experience and service management capabilities.
2026-08-19 23:00:21 +07:00
Denozordec 6bced71037 feat(ui): refactor ServiceIpList and ServiceUnitCard components for improved layout and functionality
quality / commitlint (push) Skipped
quality / changes (push) Successful in 10s
quality / api (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / web (push) Successful in 53s
CD / quality (push) Successful in 1m9s
CD / publish (push) Successful in 1m37s
- Replaced div elements with Item components in ServiceIpList for better alignment and structure.
- Introduced alignWithMenu prop to align IP Switch with the card menu.
- Updated ServiceUnitCard to utilize Item components, enhancing the visual consistency and responsiveness of the service card layout.
- Improved overall user experience by streamlining component structure and ensuring proper alignment of UI elements.
2026-08-19 22:37:54 +07:00
DenozordecandCursor 44d0eb0114 ci: учитывать несколько коммитов в одном пуше при detect paths
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 51s
quality / api (push) Successful in 45s
CD / quality (push) Successful in 1m47s
CD / publish (push) Successful in 1m37s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 22:09:14 +07:00
DenozordecandCursor 9f00dfcf84 chore(reui): обновить ReUI agent skill до актуальной версии
CD / update-wiki (push) Successful in 4s
quality / commitlint (push) Skipped
quality / changes (push) Failing after 7s
quality / web (push) Skipped
quality / api (push) Skipped
quality / docker-check (push) Skipped
CD / quality (push) Failing after 9s
CD / publish (push) Skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 20:04:19 +07:00
DenozordecandCursor 9b9dcc3b12 fix(ui): выровнять health-check плитки и тип TCP/HTTP под ReUI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 20:04:09 +07:00
Denozordec 6008cd763a refactor(health): clean up health check configuration and UI components
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 58s
CD / quality (push) Successful in 1m6s
CD / publish (push) Successful in 1m44s
- Removed unused imports and alerts related to Cloudflare, Globalping, and Local health checks from the HealthCheckConfigFields component.
- Simplified the logic for rendering health check sources and improved layout responsiveness by adding min-width classes.
- Introduced new Cloudflare and Globalping SVG icons for better visual representation in the HealthSourceTiles component.
- Refactored the TilePanel component to enhance its structure and ensure proper handling of minimum width for child elements.

This commit streamlines the health check configuration UI, improving maintainability and user experience.
2026-08-19 19:37:36 +07:00
DenozordecandCursor b9bea44dce feat(health): добавить Globalping и мультивыбор источников проб
CD / update-wiki (push) Successful in 8s
quality / commitlint (push) Skipped
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 54s
quality / api (push) Successful in 46s
CD / quality (push) Successful in 1m49s
CD / publish (push) Successful in 1m40s
Несколько источников проб сразу и правило агрегации на сервисе вместо XOR Local/Cloudflare.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 18:32:18 +07:00
DenozordecandCursor 4c4908558b feat(health): деплоить probe-Worker из CFDM и опрашивать цели с edge
quality / changes (push) Successful in 9s
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 1m4s
quality / api (push) Successful in 54s
CD / quality (push) Successful in 2m17s
CD / publish (push) Successful in 2m21s
Worker сам ходит на origin по Cron Trigger; CFDM кладёт цели в KV и забирает результаты без POST /probe.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 17:22:07 +07:00
Denozordec 2c92e78b24 feat(health-checks): enhance health check configuration and logging
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 8s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 51s
quality / api (push) Successful in 44s
CD / quality (push) Successful in 1m44s
CD / publish (push) Successful in 1m35s
- Added new health worker URL and token fields to the AppConfig interface, allowing for Cloudflare Worker integration.
- Updated health check routes to utilize the new worker configuration, enabling dynamic health checks via Cloudflare Workers.
- Introduced a health log endpoint for services, providing detailed logs of health probe results.
- Enhanced health check service logic to support both local and Cloudflare Worker providers, improving flexibility in health monitoring.
- Updated UI components to reflect changes in health check provider settings and display relevant health information.

This commit significantly improves the health check management capabilities, allowing for better integration with Cloudflare Workers and enhanced logging features.
2026-08-19 16:27:34 +07:00
106 changed files with 11276 additions and 1911 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+4 -4
View File
@@ -6,18 +6,18 @@ alwaysApply: false
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+8 -7
View File
@@ -38,14 +38,11 @@ jobs:
api: ${{ steps.detect.outputs.api }}
docker: ${{ steps.detect.outputs.docker }}
steps:
- if: ${{ inputs.is_pull_request }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
# Push may contain several commits; github.event.before is then
# more than one parent away. fetch-depth: 2 only has HEAD~1.
fetch-depth: 0
- if: ${{ inputs.is_pull_request == false }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 2
- id: detect
name: Detect changed paths per module
env:
@@ -72,12 +69,16 @@ jobs:
done
}
has_commit() {
git cat-file -e "${1}^{commit}" 2>/dev/null
}
if [ "$IS_PR" = "true" ]; then
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
else
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
before="$BEFORE_SHA"
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ] && has_commit "$before"; then
FILES="$(git diff --name-only "$before" "$after")"
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)"
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsup src/server.ts --format esm --dts",
"build": "tsup --config tsup.config.ts",
"start": "node dist/server.js",
"test": "vitest run"
},
+10
View File
@@ -35,8 +35,10 @@ import { auditRoutes } from "./routes/audit.js";
import * as certificateService from "./services/certificate-service.js";
import {
createHealthCheckTask,
healthEngineFallbacksFromConfig,
scheduleHealthCheckJob,
} from "./services/health-check-scheduler.js";
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
import { AsyncTask, CronJob } from "toad-scheduler";
export interface BuildAppOptions {
@@ -131,6 +133,14 @@ export async function buildApp(opts: BuildAppOptions = {}) {
app.decorate("reloadHealthCheckJob", () => {
scheduleHealthCheckJob(app, config, healthTask);
});
if (config.cloudflareApiToken) {
fireEnsureHealthWorker(
app.db,
app.cf,
healthEngineFallbacksFromConfig(config),
app.log,
);
}
}
return app;
+4
View File
@@ -17,6 +17,8 @@ export interface AppConfig {
healthLatencyWarnMs: number;
/** Min pause between probes to different physical targets (same IP is probed once). */
healthProbeGapMs: number;
healthWorkerUrl: string;
healthWorkerToken: string;
logLevel: string;
/** Portal SSO — when true, require portal JWT with apps includes cfdm */
authRequired: boolean;
@@ -61,6 +63,8 @@ export function loadConfig(): AppConfig {
healthLatencyWarnMs:
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
healthProbeGapMs: Number(process.env.HEALTH_PROBE_GAP_MS ?? "2000") || 2000,
healthWorkerUrl: (process.env.HEALTH_WORKER_URL ?? "").trim(),
healthWorkerToken: (process.env.HEALTH_WORKER_TOKEN ?? "").trim(),
logLevel: process.env.LOG_LEVEL ?? "info",
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
authIssuer:
+53
View File
@@ -7,6 +7,8 @@ import type {
} from "@cfdm/shared";
import { createDnsAdapter } from "./cloudflare/dns-service.js";
import { createHealthCheckAdapter, type CfHealthCheckPayload } from "./cloudflare/healthcheck-service.js";
import { createKvAdapter } from "./cloudflare/kv-service.js";
import { createWorkersAdapter } from "./cloudflare/workers-service.js";
import { createZoneAdapter } from "./cloudflare/zone-service.js";
export type { CfHealthCheckPayload };
@@ -15,11 +17,21 @@ export class CloudflareClient {
private readonly zones;
private readonly dns;
private readonly healthchecks;
private readonly kv;
private readonly workers;
private readonly token;
constructor(token: string) {
this.token = token.trim();
this.zones = createZoneAdapter(token);
this.dns = createDnsAdapter(token);
this.healthchecks = createHealthCheckAdapter(token);
this.kv = createKvAdapter(token);
this.workers = createWorkersAdapter(token);
}
get isConfigured(): boolean {
return this.token.length > 0;
}
listZones(): Promise<CfZone[]> {
@@ -81,4 +93,45 @@ export class CloudflareClient {
deleteHealthCheck(zoneId: string, id: string): Promise<void> {
return this.healthchecks.deleteHealthCheck(zoneId, id);
}
listAccounts() {
return this.workers.listAccounts();
}
listKvNamespaces(accountId: string) {
return this.kv.listNamespaces(accountId);
}
createKvNamespace(accountId: string, title: string) {
return this.kv.createNamespace(accountId, title);
}
kvGet(accountId: string, namespaceId: string, key: string) {
return this.kv.getValue(accountId, namespaceId, key);
}
kvPut(accountId: string, namespaceId: string, key: string, value: string) {
return this.kv.putValue(accountId, namespaceId, key, value);
}
putWorkerScript(opts: {
accountId: string;
scriptName: string;
source: string;
kvNamespaceId: string;
}) {
return this.workers.putScript(opts);
}
putWorkerSchedules(accountId: string, scriptName: string, crons: string[]) {
return this.workers.putSchedules(accountId, scriptName, crons);
}
enableWorkersDev(accountId: string, scriptName: string) {
return this.workers.enableWorkersDev(accountId, scriptName);
}
getWorkersSubdomain(accountId: string) {
return this.workers.getWorkersSubdomain(accountId);
}
}
+43
View File
@@ -17,6 +17,15 @@ export function mapCloudflareFailure(
): AppError {
const lower = message.toLowerCase();
if (status === 401 || status === 403 || lower.includes("authentication")) {
if (
operation.includes("workers") ||
operation.includes("kv_") ||
operation.includes("accounts")
) {
return AppError.cloudflareAuthFailed(
"Токену нужны права Account: Workers Scripts Write и Workers KV Storage Write. Zone DNS недостаточно.",
);
}
return AppError.cloudflareAuthFailed(
"Cloudflare отклонил токен. Проверьте CLOUDFLARE_API_TOKEN.",
);
@@ -67,6 +76,40 @@ export async function handleCfResponse<T>(
return body.result;
}
/** KV PUT / schedules often return `{ success: true }` without `result`. */
export async function handleCfSuccess(
response: Response,
operation: string,
): Promise<void> {
if (response.status === 429) {
const wait = parseRetryAfter(response.headers) ?? 5000;
throw AppError.rateLimited(
`Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,
);
}
const text = await response.text();
if (!text) {
if (!response.ok) {
throw mapCloudflareFailure(operation, response.status, String(response.status));
}
return;
}
let body: CfResponse<unknown>;
try {
body = JSON.parse(text) as CfResponse<unknown>;
} catch {
if (!response.ok) {
throw mapCloudflareFailure(operation, response.status, text.slice(0, 180));
}
return;
}
if (!body.success) {
const msg =
body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
throw mapCloudflareFailure(operation, response.status, msg);
}
}
export async function cfRequest<T>(
token: string,
path: string,
+100
View File
@@ -0,0 +1,100 @@
import { CF_API_BASE, handleCfResponse, handleCfSuccess, mapCloudflareFailure } from "./http.js";
export interface CfKvNamespace {
id: string;
title: string;
}
export function createKvAdapter(token: string) {
return {
async listNamespaces(accountId: string): Promise<CfKvNamespace[]> {
const all: CfKvNamespace[] = [];
let page = 1;
while (true) {
const url = new URL(
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
);
url.searchParams.set("per_page", "100");
url.searchParams.set("page", String(page));
const response = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("kv_list", response.status, String(response.status));
}
const batch = await handleCfResponse<CfKvNamespace[]>(response, "kv_list");
all.push(...batch);
if (batch.length < 100) break;
page += 1;
}
return all;
},
async createNamespace(accountId: string, title: string): Promise<CfKvNamespace> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ title }),
signal: AbortSignal.timeout(30_000),
},
);
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("kv_create", response.status, String(response.status));
}
return handleCfResponse<CfKvNamespace>(response, "kv_create");
},
async getValue(
accountId: string,
namespaceId: string,
key: string,
): Promise<string | null> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
if (response.status === 404) return null;
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("kv_get", response.status, String(response.status));
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw mapCloudflareFailure("kv_get", response.status, text.slice(0, 180));
}
return response.text();
},
async putValue(
accountId: string,
namespaceId: string,
key: string,
value: string,
): Promise<void> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "text/plain",
},
body: value,
signal: AbortSignal.timeout(30_000),
},
);
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("kv_put", response.status, String(response.status));
}
await handleCfSuccess(response, "kv_put");
},
};
}
@@ -0,0 +1,142 @@
import {
CF_API_BASE,
handleCfResponse,
handleCfSuccess,
mapCloudflareFailure,
} from "./http.js";
export interface CfAccount {
id: string;
name?: string;
}
export interface CfWorkersSubdomain {
subdomain?: string;
enabled?: boolean;
}
export function createWorkersAdapter(token: string) {
return {
async listAccounts(): Promise<CfAccount[]> {
const response = await fetch(`${CF_API_BASE}/accounts?per_page=50`, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
});
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("list_accounts", response.status, String(response.status));
}
return handleCfResponse<CfAccount[]>(response, "list_accounts");
},
async putScript(opts: {
accountId: string;
scriptName: string;
source: string;
kvNamespaceId: string;
filename?: string;
}): Promise<void> {
const filename = opts.filename ?? "index.mjs";
const metadata = {
main_module: filename,
compatibility_date: "2025-04-01",
bindings: [
{
type: "kv_namespace",
name: "HEALTH_KV",
namespace_id: opts.kvNamespaceId,
},
],
};
const form = new FormData();
form.append(
"metadata",
new Blob([JSON.stringify(metadata)], { type: "application/json" }),
);
form.append(
filename,
new Blob([opts.source], { type: "application/javascript+module" }),
filename,
);
const response = await fetch(
`${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,
{
method: "PUT",
headers: { Authorization: `Bearer ${token}` },
body: form,
signal: AbortSignal.timeout(60_000),
},
);
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("workers_put_script", response.status, String(response.status));
}
await handleCfSuccess(response, "workers_put_script");
},
async putSchedules(
accountId: string,
scriptName: string,
crons: string[],
): Promise<void> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/schedules`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(crons.map((cron) => ({ cron }))),
signal: AbortSignal.timeout(30_000),
},
);
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("workers_put_schedules", response.status, String(response.status));
}
await handleCfSuccess(response, "workers_put_schedules");
},
async enableWorkersDev(
accountId: string,
scriptName: string,
): Promise<void> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ enabled: true }),
signal: AbortSignal.timeout(30_000),
},
);
if (response.status === 409) return;
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("workers_subdomain", response.status, String(response.status));
}
if (!response.ok && response.status !== 200 && response.status !== 201) {
await handleCfSuccess(response, "workers_subdomain");
}
},
async getWorkersSubdomain(accountId: string): Promise<string | null> {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/workers/subdomain`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(30_000),
},
);
if (response.status === 404) return null;
if (response.status >= 500 || response.status === 429) {
throw mapCloudflareFailure("workers_get_subdomain", response.status, String(response.status));
}
const result = await handleCfResponse<CfWorkersSubdomain>(
response,
"workers_get_subdomain",
);
return result.subdomain?.trim() || null;
},
};
}
+277
View File
@@ -0,0 +1,277 @@
import type { HealthCheckTarget } from "@cfdm/shared";
import {
clampGlobalpingLimit,
parseGlobalpingLocations,
} from "@cfdm/shared";
export const GLOBALPING_API_ROOT = "https://api.globalping.io";
export const GLOBALPING_MIN_POLL_MS = 500;
export const GLOBALPING_UA = "CFDM-health/1.0";
export interface GlobalpingClientOptions {
token?: string | null;
locations?: string;
limit?: number;
pollIntervalMs?: number;
maxWaitMs?: number;
fetchImpl?: typeof fetch;
}
export interface GlobalpingProbeResult {
ok: boolean;
latencyMs: number;
error: string | null;
colo: string | null;
}
interface MeasurementCreateBody {
type: "ping" | "http";
target: string;
inProgressUpdates: false;
limit: number;
locations: Array<{ magic: string }>;
measurementOptions: Record<string, unknown>;
}
interface MeasurementProbe {
continent?: string;
country?: string;
city?: string;
network?: string;
}
interface MeasurementResultRow {
probe?: MeasurementProbe;
result?: {
status?: string;
statusCode?: number;
timings?: { total?: number };
stats?: { avg?: number; loss?: number };
};
}
interface MeasurementResponse {
id?: string;
status?: string;
results?: MeasurementResultRow[];
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function locationLabel(probe: MeasurementProbe | undefined): string | null {
if (!probe) return null;
const city = probe.city?.trim();
const country = probe.country?.trim();
if (city && country) return `${city}, ${country}`;
return city || country || null;
}
export function buildMeasurementBody(
target: HealthCheckTarget,
options: GlobalpingClientOptions,
): MeasurementCreateBody {
const limit = clampGlobalpingLimit(options.limit, 3);
const locations = parseGlobalpingLocations(options.locations).map((magic) => ({
magic,
}));
const port = target.port ?? (target.type === "http" ? 80 : 80);
const ip = String(target.ip || "").trim();
const hostname = (target.hostname || ip).trim();
if (target.type === "http") {
const path = target.path?.trim() || "/";
const protocol = port === 443 ? "HTTPS" : "HTTP";
return {
type: "http",
target: ip,
inProgressUpdates: false,
limit,
locations,
measurementOptions: {
protocol,
port,
request: {
method: "GET",
host: hostname,
path: path.startsWith("/") ? path : `/${path}`,
},
},
};
}
return {
type: "ping",
target: ip,
inProgressUpdates: false,
limit,
locations,
measurementOptions: {
protocol: "TCP",
port,
},
};
}
function rowOk(target: HealthCheckTarget, row: MeasurementResultRow): boolean {
const result = row.result;
if (!result) return false;
const status = String(result.status ?? "").toLowerCase();
if (status && status !== "finished") return false;
if (target.type === "http") {
const code = result.statusCode;
if (code == null) return false;
if (target.expected_status != null) return code === target.expected_status;
return code >= 200 && code < 400;
}
const loss = result.stats?.loss;
if (loss != null && loss >= 100) return false;
return status === "finished" || status === "";
}
function rowLatency(row: MeasurementResultRow): number {
const total = row.result?.timings?.total;
if (typeof total === "number" && Number.isFinite(total)) return Math.round(total);
const avg = row.result?.stats?.avg;
if (typeof avg === "number" && Number.isFinite(avg)) return Math.round(avg);
return 0;
}
export function summarizeMeasurement(
target: HealthCheckTarget,
doc: MeasurementResponse,
): GlobalpingProbeResult {
const rows = doc.results ?? [];
if (rows.length === 0) {
return {
ok: false,
latencyMs: 0,
error: "Globalping: пустой результат",
colo: null,
};
}
const oks = rows.map((row) => rowOk(target, row));
const okCount = oks.filter(Boolean).length;
const ok = okCount > rows.length / 2;
const latencies = rows.map(rowLatency);
const latencyMs = Math.round(
latencies.reduce((sum, n) => sum + n, 0) / latencies.length,
);
const colo =
locationLabel(rows.find((_, i) => oks[i])?.probe) ??
locationLabel(rows[0]?.probe);
if (ok) {
return { ok: true, latencyMs, error: null, colo };
}
const expected =
target.type === "http" && target.expected_status != null
? `ожидали HTTP ${target.expected_status}`
: target.type === "http"
? "ожидали HTTP 2xx/3xx"
: "TCP ping с packet loss < 100%";
return {
ok: false,
latencyMs,
error: `Globalping: ${okCount}/${rows.length} проб успешны (${expected})`,
colo,
};
}
async function parseJson(response: Response): Promise<MeasurementResponse> {
try {
return (await response.json()) as MeasurementResponse;
} catch {
return {};
}
}
export async function runGlobalpingMeasurement(
target: HealthCheckTarget,
options: GlobalpingClientOptions = {},
): Promise<GlobalpingProbeResult> {
const fetchImpl = options.fetchImpl ?? fetch;
const pollMs =
options.pollIntervalMs === undefined
? GLOBALPING_MIN_POLL_MS
: Math.max(0, options.pollIntervalMs);
const maxWaitMs = options.maxWaitMs ?? Math.max(target.timeout_ms ?? 3000, 3000) + 15_000;
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
"User-Agent": GLOBALPING_UA,
};
const token = options.token?.trim();
if (token) headers.Authorization = `Bearer ${token}`;
const created = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements`, {
method: "POST",
headers,
body: JSON.stringify(buildMeasurementBody(target, options)),
});
if (created.status === 429) {
return {
ok: false,
latencyMs: 0,
error: "Globalping: 429 rate limit",
colo: null,
};
}
if (created.status !== 202 && created.status !== 200) {
const body = await parseJson(created);
return {
ok: false,
latencyMs: 0,
error: `Globalping: HTTP ${created.status}${body.status ? ` (${body.status})` : ""}`,
colo: null,
};
}
const createdBody = await parseJson(created);
const id = createdBody.id?.trim();
if (!id) {
return {
ok: false,
latencyMs: 0,
error: "Globalping: нет id измерения",
colo: null,
};
}
const started = Date.now();
while (Date.now() - started < maxWaitMs) {
await sleep(pollMs);
const polled = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements/${id}`, {
method: "GET",
headers: {
Accept: "application/json",
"User-Agent": GLOBALPING_UA,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (polled.status === 429) {
return {
ok: false,
latencyMs: 0,
error: "Globalping: 429 rate limit",
colo: null,
};
}
if (!polled.ok) {
return {
ok: false,
latencyMs: 0,
error: `Globalping: HTTP ${polled.status} при опросе`,
colo: null,
};
}
const doc = await parseJson(polled);
if (String(doc.status ?? "").toLowerCase() === "in-progress") continue;
return summarizeMeasurement(target, doc);
}
return {
ok: false,
latencyMs: Date.now() - started,
error: "Globalping: timeout ожидания measurement",
colo: null,
};
}
+17 -5
View File
@@ -1,8 +1,13 @@
import type { FastifyInstance } from "fastify";
import { healthStatusQuerySchema } from "@cfdm/shared";
import { repos } from "@cfdm/db";
import { getAppSettings, repos } from "@cfdm/db";
import * as healthCheckService from "../services/health-check-service.js";
import * as serviceConfigService from "../services/service-config-service.js";
import {
healthEngineFallbacksFromConfig,
} from "../services/health-check-scheduler.js";
import { mailboxFromSettings } from "../services/health/health-worker-deploy.js";
import { cronStaleAfterMs } from "../services/health/mailbox.js";
export async function healthCheckRoutes(app: FastifyInstance) {
app.get("/health-status", async (request) => {
@@ -16,15 +21,22 @@ export async function healthCheckRoutes(app: FastifyInstance) {
app.post("/health-check/run", async (request) => {
const config = request.server.config;
const fallbacks = healthEngineFallbacksFromConfig(config);
const settings = getAppSettings(
request.server.db,
fallbacks,
);
const thresholds = {
degradedFailures: config.healthDegradedFailures,
downFailures: config.healthDownFailures,
latencyWarnMs: config.healthLatencyWarnMs,
successRecoveries: config.healthSuccessRecoveries,
degradedFailures: settings.healthDegradedFailures,
downFailures: settings.healthDownFailures,
latencyWarnMs: settings.healthLatencyWarnMs,
successRecoveries: settings.healthSuccessRecoveries,
};
const checked = await healthCheckService.runAllChecks(request.server.db, {
thresholds,
probeGapMs: config.healthProbeGapMs,
mailbox: mailboxFromSettings(request.server.db, request.server.cf, fallbacks),
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
onStatusChange: async (target, prev, next) => {
try {
const label =
+2 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { changeIpSchema } from "@cfdm/shared";
import { certMonitoringSchema, changeIpSchema } from "@cfdm/shared";
import * as bindingService from "../services/binding-service.js";
import * as changeIp from "../services/change-ip-service.js";
import { recordAudit } from "../lib/audit.js";
@@ -17,6 +17,7 @@ export async function serviceBindingRoutes(app: FastifyInstance) {
service_id: z.number().optional(),
hostname: z.string().optional(),
target_ip: z.string().optional(),
cert_monitoring: certMonitoringSchema.optional(),
});
app.get("/service-bindings", async (request) => {
+30
View File
@@ -12,6 +12,7 @@ import { repos } from "@cfdm/db";
import * as serviceConfig from "../services/service-config-service.js";
import * as nodeService from "../services/node-service.js";
import * as changeDomain from "../services/change-domain-service.js";
import * as certificateService from "../services/certificate-service.js";
import { recordAudit } from "../lib/audit.js";
export async function serviceRoutes(app: FastifyInstance) {
@@ -65,6 +66,35 @@ export async function serviceRoutes(app: FastifyInstance) {
return serviceConfig.getView(request.server.db, Number(id));
});
app.get("/services/:id/health-log", async (request) => {
const { id } = request.params as { id: string };
repos.getService(request.server.db, Number(id));
return {
items: repos.listHealthProbeLogForService(
request.server.db,
Number(id),
200,
),
};
});
app.get("/services/:id/certificates", async (request) => {
const { id } = request.params as { id: string };
return certificateService.listServiceCertificates(
request.server.db,
Number(id),
);
});
app.post("/services/:id/certificates/check", async (request) => {
const { id } = request.params as { id: string };
const checked = await certificateService.runServiceChecks(
request.server.db,
Number(id),
);
return { checked };
});
app.get("/services/:id/overview", async (request) => {
const { id } = request.params as { id: string };
return nodeService.getOverview(request.server.db, Number(id));
+21 -2
View File
@@ -10,6 +10,7 @@ import {
assertValidHealthCron,
healthEngineFallbacksFromConfig,
} from "../services/health-check-scheduler.js";
import { ensureHealthWorker } from "../services/health/health-worker-deploy.js";
export async function settingsRoutes(app: FastifyInstance) {
app.get("/settings", async (request) => {
@@ -40,11 +41,29 @@ export async function settingsRoutes(app: FastifyInstance) {
"ошибок до down не меньше, чем до degraded",
);
}
const next = updateAppSettings(request.server.db, body, fallbacks);
updateAppSettings(request.server.db, body, fallbacks);
if (body.healthCheckCron !== undefined) {
request.server.reloadHealthCheckJob?.();
const after = getAppSettings(request.server.db, fallbacks);
if (after.healthWorkerKvNamespaceId) {
try {
await ensureHealthWorker(
request.server.db,
request.server.cf,
fallbacks,
);
} catch {
// error stored in settings
}
}
}
return next;
return getAppSettings(request.server.db, fallbacks);
});
app.post("/settings/health/worker/ensure", async (request) => {
const fallbacks = healthEngineFallbacksFromConfig(request.server.config);
await ensureHealthWorker(request.server.db, request.server.cf, fallbacks);
return getAppSettings(request.server.db, fallbacks);
});
app.post("/settings/vps-tracker/test", async (request) => {
+15
View File
@@ -15,6 +15,7 @@ export interface UpdateBindingRequest {
service_id?: number;
hostname?: string;
target_ip?: string;
cert_monitoring?: string;
}
function normalizeHostname(hostname?: string): string {
@@ -92,6 +93,20 @@ export async function update(
req: UpdateBindingRequest,
): Promise<ServiceBindingView> {
const existing = repos.getBinding(db, id);
if (req.cert_monitoring !== undefined) {
repos.updateBindingLbConfig(db, id, {
cert_monitoring: req.cert_monitoring,
});
}
const hasIdentityPatch =
req.service_id !== undefined ||
req.hostname !== undefined ||
req.target_ip !== undefined;
if (!hasIdentityPatch) {
return repos.getBindingView(db, id);
}
const serviceId = req.service_id ?? existing.service_id;
if (req.service_id) repos.getService(db, req.service_id);
const hostname = req.hostname
+81 -101
View File
@@ -2,7 +2,7 @@ import { connect } from "node:net";
import { connect as tlsConnect } from "node:tls";
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { Certificate, Domain, Subdomain } from "@cfdm/shared";
import type { Certificate, ServiceCertificateRow, Subdomain } from "@cfdm/shared";
import {
CERT_ERROR,
CERT_MONITOR_AUTO,
@@ -11,13 +11,13 @@ import {
CERT_UNKNOWN,
certStatusFromExpiry,
fqdnToDisplay,
parseFqdn,
shouldMonitorService,
} from "@cfdm/shared";
export interface CertificateTarget {
domainId: number;
subdomainId: number | null;
serviceId: number;
hostname: string;
}
@@ -41,6 +41,34 @@ export function getCertificate(db: Db, id: number): Certificate {
return repos.getCertificate(db, id);
}
export function listServiceCertificates(
db: Db,
serviceId: number,
): ServiceCertificateRow[] {
repos.getService(db, serviceId);
const certsByHost = new Map(
repos.listCertificates(db).map((cert) => [cert.hostname, cert]),
);
return repos.listBindingsByService(db, serviceId).map((binding) => {
const hostname = fqdnToDisplay(binding.hostname, binding.zone_name);
const cert = certsByHost.get(hostname);
return {
binding_id: binding.id,
domain_id: binding.domain_id,
service_id: binding.service_id,
hostname,
cert_monitoring:
(binding.cert_monitoring as ServiceCertificateRow["cert_monitoring"]) ??
"auto",
id: cert?.id ?? null,
status: cert?.status ?? "unknown",
expires_at: cert?.expires_at ?? null,
last_checked_at: cert?.last_checked_at ?? null,
last_error: cert?.last_error ?? null,
};
});
}
export async function checkHostname(
hostname: string,
): Promise<{ expiresAt: Date | null; error: string | null }> {
@@ -78,6 +106,7 @@ export async function checkAndStore(
domainId: number,
subdomainId: number | null,
hostname: string,
serviceId: number | null = null,
): Promise<Certificate> {
const { expiresAt, error } = await checkHostname(hostname);
@@ -90,6 +119,7 @@ export async function checkAndStore(
null,
CERT_ERROR,
error,
serviceId,
);
}
@@ -105,6 +135,7 @@ export async function checkAndStore(
expiresAt.toISOString(),
certStatusFromExpiry(days),
null,
serviceId,
);
}
@@ -116,23 +147,10 @@ export async function checkAndStore(
null,
CERT_UNKNOWN,
"unknown expiry",
serviceId,
);
}
function resolveMonitoringMode(
domain: Domain,
subdomain: Subdomain | null,
fqdn: string,
): string {
if (subdomain) {
return subdomain.cert_monitoring;
}
if (fqdn === domain.zone_name) {
return domain.cert_monitoring;
}
return CERT_MONITOR_AUTO;
}
function bindingSubdomain(
db: Db,
domainId: number,
@@ -165,10 +183,9 @@ function hasSslHealthGate(
return false;
}
export function buildServiceCertificateFqdns(
db: Db,
): Map<string, CertificateTarget> {
const result = new Map<string, CertificateTarget>();
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
const targets: CertificateTarget[] = [];
const seen = new Set<string>();
for (const binding of repos.listAllBindings(db)) {
const service = repos.getService(db, binding.service_id);
@@ -176,98 +193,40 @@ export function buildServiceCertificateFqdns(
? repos.getServiceGroup(db, service.service_group_id)
: null;
if (!shouldMonitorService(service, group)) continue;
if (
!hasSslHealthGate(
{
health_check_enabled: binding.health_check_enabled,
health_check_verify_tls: binding.health_check_verify_tls,
},
group,
)
) {
continue;
}
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
if (subdomain && !subdomain.enabled) continue;
const mode = binding.cert_monitoring ?? CERT_MONITOR_AUTO;
if (mode === CERT_MONITOR_SKIPPED) continue;
if (mode === CERT_MONITOR_AUTO) {
if (
!hasSslHealthGate(
{
health_check_enabled: binding.health_check_enabled,
health_check_verify_tls: binding.health_check_verify_tls,
},
group,
)
) {
continue;
}
} else if (mode !== CERT_MONITOR_REQUIRED) {
continue;
}
const fqdn = fqdnToDisplay(binding.hostname, binding.zone_name);
result.set(fqdn, {
if (seen.has(fqdn)) continue;
seen.add(fqdn);
targets.push({
domainId: binding.domain_id,
subdomainId: subdomain?.id ?? null,
serviceId: binding.service_id,
hostname: fqdn,
});
}
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name);
for (const group of repos.listServiceGroups(db)) {
if (!group.enabled || !group.domain?.trim()) continue;
if (!group.health_check_enabled || !group.health_check_verify_tls) continue;
const parsed = parseFqdn(group.domain, knownZones);
if (!parsed) continue;
const domain = repos.findDomainByZoneName(db, parsed.zoneName);
if (!domain) continue;
const subdomain =
parsed.hostname === "@"
? null
: bindingSubdomain(db, domain.id, parsed.hostname);
if (subdomain && !subdomain.enabled) continue;
result.set(parsed.fqdn, {
domainId: domain.id,
subdomainId: subdomain?.id ?? null,
hostname: parsed.fqdn,
});
}
return result;
}
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
const serviceFqdns = buildServiceCertificateFqdns(db);
const targets = new Map<string, CertificateTarget>();
for (const domain of repos.listAllDomains(db)) {
if (domain.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
if (domain.cert_monitoring === CERT_MONITOR_REQUIRED) {
targets.set(domain.zone_name, {
domainId: domain.id,
subdomainId: null,
hostname: domain.zone_name,
});
}
}
for (const sub of repos.listAllSubdomains(db)) {
if (sub.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
if (sub.cert_monitoring === CERT_MONITOR_REQUIRED) {
targets.set(sub.fqdn, {
domainId: sub.domain_id,
subdomainId: sub.id,
hostname: sub.fqdn,
});
}
}
for (const [fqdn, meta] of serviceFqdns) {
const domain = repos.getDomain(db, meta.domainId);
const subdomain = meta.subdomainId
? repos.getSubdomain(db, meta.subdomainId)
: null;
const monitoring = resolveMonitoringMode(domain, subdomain, fqdn);
if (monitoring === CERT_MONITOR_SKIPPED) continue;
if (
monitoring === CERT_MONITOR_AUTO ||
monitoring === CERT_MONITOR_REQUIRED
) {
targets.set(fqdn, meta);
}
}
return [...targets.values()];
return targets;
}
export async function runAllChecks(db: Db): Promise<number> {
@@ -278,6 +237,7 @@ export async function runAllChecks(db: Db): Promise<number> {
target.domainId,
target.subdomainId,
target.hostname,
target.serviceId,
);
}
repos.deleteCertificatesNotIn(
@@ -287,6 +247,26 @@ export async function runAllChecks(db: Db): Promise<number> {
return targets.length;
}
export async function runServiceChecks(
db: Db,
serviceId: number,
): Promise<number> {
repos.getService(db, serviceId);
const targets = resolveCertificateTargets(db).filter(
(target) => target.serviceId === serviceId,
);
for (const target of targets) {
await checkAndStore(
db,
target.domainId,
target.subdomainId,
target.hostname,
target.serviceId,
);
}
return targets.length;
}
export function statusSummary(db: Db): Array<[string, number]> {
pruneStaleCertificates(db);
return repos.countCertificatesByStatus(db);
@@ -2,6 +2,8 @@ import type { FastifyInstance } from "fastify";
import { AsyncTask, CronJob } from "toad-scheduler";
import {
getAppSettings,
getAppSettingsSecrets,
updateAppSettings,
type HealthEngineFallbacks,
} from "@cfdm/db";
import { repos } from "@cfdm/db";
@@ -9,6 +11,10 @@ import type { AppConfig } from "../config.js";
import { AppError } from "../errors.js";
import * as healthCheckService from "./health-check-service.js";
import * as serviceConfigService from "./service-config-service.js";
import {
mailboxFromSettings,
} from "./health/health-worker-deploy.js";
import { cronStaleAfterMs } from "./health/mailbox.js";
declare module "fastify" {
interface FastifyInstance {
@@ -27,6 +33,8 @@ export function healthEngineFallbacksFromConfig(
healthDownFailures: config.healthDownFailures,
healthLatencyWarnMs: config.healthLatencyWarnMs,
healthSuccessRecoveries: config.healthSuccessRecoveries,
healthWorkerUrl: config.healthWorkerUrl,
healthWorkerTokenSet: Boolean(config.healthWorkerToken),
};
}
@@ -63,9 +71,18 @@ export function createHealthCheckTask(
latencyWarnMs: settings.healthLatencyWarnMs,
successRecoveries: settings.healthSuccessRecoveries,
};
const mailbox = mailboxFromSettings(app.db, app.cf, fallbacks);
const secrets = getAppSettingsSecrets(app.db);
const n = await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: config.healthProbeGapMs,
mailbox,
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
globalping: {
token: secrets.globalpingToken,
locations: secrets.globalpingLocations,
limit: secrets.globalpingLimit,
},
onStatusChange: async (target, prev, next) => {
try {
const label =
@@ -98,6 +115,13 @@ export function createHealthCheckTask(
}
},
});
if (mailbox) {
updateAppSettings(
app.db,
{ healthWorkerLastIngestAt: new Date().toISOString() },
fallbacks,
);
}
const monitors = await healthCheckService.runDomainMonitors(
app.db,
thresholds,
+223 -74
View File
@@ -3,9 +3,28 @@ import { resolve4, resolve6 } from "node:dns/promises";
import { Agent, buildConnector, fetch as undiciFetch } from "undici";
import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
import type { HealthCheckTarget, IpHealthState, HealthCheckProvider } from "@cfdm/shared";
import {
aggregateHealthOk,
parseHealthAggregate,
targetProviders,
} from "@cfdm/shared";
import { AppError } from "../errors.js";
import { nextHealthState } from "./health/state-machine.js";
import { LocalHealthCheckProvider } from "./health/local.js";
import { workerNotConfiguredResult } from "./health/worker.js";
import {
globalpingNotConfiguredResult,
probeWithGlobalping,
} from "./health/globalping.js";
import {
buildTargetsDoc,
indexResults,
isResultsStale,
originProbeKey,
type HealthMailbox,
} from "./health/mailbox.js";
import type { GlobalpingClientOptions } from "../lib/globalping-client.js";
export interface HealthCheckThresholds {
degradedFailures: number;
@@ -18,6 +37,7 @@ export interface ProbeResult {
ok: boolean;
latencyMs: number;
error: string | null;
colo?: string | null;
}
/** Bracket IPv6 for URL authority; leave IPv4/hostname as-is. */
@@ -261,6 +281,11 @@ export interface RunAllChecksOptions {
thresholds: HealthCheckThresholds;
/** Pause between unique physical probes (default 2000). Same IP is only probed once. */
probeGapMs?: number;
/** KV mailbox with Worker results. Missing → cloudflare targets fail, never Local fallback. */
mailbox?: HealthMailbox | null;
/** Results older than this are stale (default 10 min). */
staleAfterMs?: number;
globalping?: GlobalpingClientOptions | null;
onStatusChange?: (
target: HealthCheckTarget,
prevState: IpHealthState | null,
@@ -273,25 +298,115 @@ function sleep(ms: number): Promise<void> {
}
/**
* One network hit per key. Group+binding on the same IP share a single TCP/HTTP probe
* so anti-bot / rate-limit on the origin is not tripped by back-to-back checks.
* One network hit per origin+provider. Group+binding on the same IP share a probe.
*/
export function physicalProbeKey(target: HealthCheckTarget): string {
const port = target.port ?? (target.type === "http" ? 80 : 80);
const ip = String(target.ip || "").trim().toLowerCase();
if (target.type === "http") {
const path = (target.path?.trim() || "/") || "/";
const expected = target.expected_status ?? "";
return `http|${ip}|${port}|${path}|${expected}`;
export function physicalProbeKey(
target: HealthCheckTarget,
provider: HealthCheckProvider = target.provider,
): string {
return `${provider}|${originProbeKey(target)}`;
}
function logSourceResult(
db: Db,
target: HealthCheckTarget,
provider: HealthCheckProvider,
result: ProbeResult,
): void {
repos.insertHealthProbeLog(db, {
scope: target.scope,
refId: target.ref_id,
ip: target.ip,
provider,
status: result.ok ? "up" : "down",
ok: result.ok,
latencyMs: result.latencyMs,
colo: result.colo ?? null,
error: result.error,
});
}
function applyAggregatedStatus(
db: Db,
target: HealthCheckTarget,
sources: Array<{ provider: HealthCheckProvider; result: ProbeResult }>,
options: RunAllChecksOptions,
): void {
const policy = parseHealthAggregate(target.aggregate);
const oks = sources.map((s) => s.result.ok);
const aggregatedOk = aggregateHealthOk(oks, policy);
const latencies = sources.map((s) => s.result.latencyMs);
const latencyMs = latencies.length
? Math.round(latencies.reduce((sum, n) => sum + n, 0) / latencies.length)
: 0;
const colo =
sources.find((s) => s.result.colo)?.result.colo ??
sources[0]?.result.colo ??
null;
const error = aggregatedOk
? null
: sources
.map((s) => s.result.error)
.filter((msg): msg is string => Boolean(msg))
.join("; ") || "health aggregate down";
const statusProvider =
sources.length > 1 ? "aggregate" : (sources[0]?.provider ?? target.provider);
const prev = repos.getIpHealthStatusRow(
db,
target.scope,
target.ref_id,
target.ip,
);
const { state, failures, successes, node } = deriveState(
aggregatedOk,
latencyMs,
prev
? {
consecutive_failures: prev.consecutive_failures,
consecutive_successes: prev.consecutive_successes,
status: prev.status,
}
: null,
options.thresholds,
);
const prevState: IpHealthState | null = prev
? (prev.status as IpHealthState)
: null;
repos.upsertIpHealthStatus(
db,
target.scope,
target.ref_id,
target.ip,
state,
latencyMs,
failures,
error,
successes,
{ colo, provider: statusProvider },
);
const matchedNode = repos.findNodeByIp(db, target.ip);
if (matchedNode && matchedNode.enabled) {
repos.updateNode(db, matchedNode.id, {
health_status: node,
consecutive_failures: failures,
consecutive_successes: successes,
last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19),
last_failure_reason: error,
});
}
if (target.type === "tcp") return `tcp|${ip}|${port}`;
if (target.type === "ping") {
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
if (prevState !== state) {
options.onStatusChange?.(target, prevState, state);
}
if (target.type === "dns") {
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
}
return `${target.type}|${ip}|${port}`;
}
function staleWorkerResult(colo: string | null): ProbeResult {
return {
ok: false,
latencyMs: 0,
error: "Cloudflare Worker: результаты устарели или KV пуст",
colo,
};
}
export async function runAllChecks(
@@ -300,76 +415,107 @@ export async function runAllChecks(
): Promise<number> {
const targets = repos.listHealthCheckTargets(db);
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
const local = new LocalHealthCheckProvider();
const staleAfterMs = options.staleAfterMs ?? 10 * 60_000;
const byPhysical = new Map<string, HealthCheckTarget[]>();
const byOrigin = new Map<string, HealthCheckTarget[]>();
for (const target of targets) {
const key = physicalProbeKey(target);
const list = byPhysical.get(key);
const key = originProbeKey(target);
const list = byOrigin.get(key);
if (list) list.push(target);
else byPhysical.set(key, [target]);
else byOrigin.set(key, [target]);
}
let probeIndex = 0;
for (const group of byPhysical.values()) {
if (probeIndex > 0 && gapMs > 0) {
await sleep(gapMs);
const needsCloudflare = targets.some((t) =>
targetProviders(t).includes("cloudflare"),
);
let mailboxResults = new Map<string, { ok: boolean; latencyMs: number; error: string | null }>();
let mailboxColo: string | null = null;
let mailboxStale = true;
const mailbox = options.mailbox ?? null;
if (needsCloudflare) {
const resultsDoc = mailbox ? await mailbox.getResults() : null;
mailboxResults = indexResults(resultsDoc);
mailboxStale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);
mailboxColo = resultsDoc?.colo ?? null;
if (mailbox) {
try {
const next = buildTargetsDoc(targets);
const current = await mailbox.getTargets();
if (current?.fingerprint !== next.fingerprint) {
await mailbox.putTargets(next);
}
} catch {
// ingest still proceeds
}
}
probeIndex += 1;
}
// Prefer binding hostname for SNI when several scopes share one IP.
const probeCache = new Map<string, ProbeResult>();
let probeIndex = 0;
async function resolveProvider(
provider: HealthCheckProvider,
representative: HealthCheckTarget,
originKey: string,
): Promise<ProbeResult> {
const cacheKey = `${provider}|${originKey}`;
const cached = probeCache.get(cacheKey);
if (cached) return cached;
let result: ProbeResult;
if (provider === "local") {
if (probeIndex > 0 && gapMs > 0) await sleep(gapMs);
probeIndex += 1;
result = await local.probe(representative);
} else if (provider === "cloudflare") {
const item = mailboxResults.get(originKey);
if (!mailbox) result = workerNotConfiguredResult();
else if (mailboxStale || !item) result = staleWorkerResult(mailboxColo);
else {
result = {
ok: item.ok,
latencyMs: item.latencyMs,
error: item.error,
colo: mailboxColo,
};
}
} else {
if (!options.globalping?.token?.trim()) {
result = globalpingNotConfiguredResult();
} else {
if (probeIndex > 0 && gapMs > 0) await sleep(gapMs);
probeIndex += 1;
result = await probeWithGlobalping(representative, options.globalping);
}
}
probeCache.set(cacheKey, result);
return result;
}
for (const [originKey, group] of byOrigin) {
const representative =
group.find((t) => t.scope === "binding") ?? group[0]!;
const result = await probeTarget(representative);
const needed = new Set<HealthCheckProvider>();
for (const target of group) {
const prev = repos.getIpHealthStatusRow(
db,
target.scope,
target.ref_id,
target.ip,
);
const { state, failures, successes, node } = deriveState(
result.ok,
result.latencyMs,
prev
? {
consecutive_failures: prev.consecutive_failures,
consecutive_successes: prev.consecutive_successes,
status: prev.status,
}
: null,
options.thresholds,
);
const prevState: IpHealthState | null = prev
? (prev.status as IpHealthState)
: null;
repos.upsertIpHealthStatus(
db,
target.scope,
target.ref_id,
target.ip,
state,
result.latencyMs,
failures,
result.error,
successes,
);
const matchedNode = repos.findNodeByIp(db, target.ip);
if (matchedNode && matchedNode.enabled) {
repos.updateNode(db, matchedNode.id, {
health_status: node,
consecutive_failures: failures,
consecutive_successes: successes,
last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19),
last_failure_reason: result.error,
});
}
if (prevState !== state) {
options.onStatusChange?.(target, prevState, state);
for (const provider of targetProviders(target)) needed.add(provider);
}
for (const provider of needed) {
await resolveProvider(provider, representative, originKey);
}
for (const target of group) {
const providers = targetProviders(target);
const sources = providers.map((provider) => ({
provider,
result: probeCache.get(`${provider}|${originKey}`)!,
}));
for (const source of sources) {
logSourceResult(db, target, source.provider, source.result);
}
applyAggregatedStatus(db, target, sources, options);
}
}
// Orphan rows (old IPs / hostname keys) still feed MAX latency on group badge.
repos.pruneStaleIpHealthStatus(db, targets);
return targets.length;
}
@@ -392,6 +538,9 @@ export async function runDomainMonitors(
expected_status: monitor.expected_status,
timeout_ms: monitor.timeout_ms,
verify_tls: false,
provider: "local",
providers: ["local"],
aggregate: "majority",
};
let result: ProbeResult;
if (monitor.type === "http") {
@@ -0,0 +1,40 @@
import type { HealthCheckTarget } from "@cfdm/shared";
import {
runGlobalpingMeasurement,
type GlobalpingClientOptions,
} from "../../lib/globalping-client.js";
import type { ProbeResult } from "../health-check-service.js";
export function globalpingNotConfiguredResult(): ProbeResult {
return {
ok: false,
latencyMs: 0,
error: "Globalping: токен не задан",
colo: null,
};
}
export async function probeWithGlobalping(
target: HealthCheckTarget,
options: GlobalpingClientOptions,
): Promise<ProbeResult> {
if (!options.token?.trim()) {
return globalpingNotConfiguredResult();
}
try {
const result = await runGlobalpingMeasurement(target, options);
return {
ok: result.ok,
latencyMs: result.latencyMs,
error: result.error,
colo: result.colo,
};
} catch (err) {
return {
ok: false,
latencyMs: 0,
error: err instanceof Error ? err.message : "Globalping: ошибка запроса",
colo: null,
};
}
}
@@ -0,0 +1,21 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
export function loadHealthProbeWorkerSource(): string {
const dir = dirname(fileURLToPath(import.meta.url));
const candidates = [
join(dir, "health-probe-worker.mjs"),
join(process.cwd(), "dist/health-probe-worker.mjs"),
join(process.cwd(), "health-probe-worker.mjs"),
join(dir, "../../../../../workers/health-probe/src/index.mjs"),
join(process.cwd(), "../../workers/health-probe/src/index.mjs"),
join(process.cwd(), "workers/health-probe/src/index.mjs"),
];
for (const path of candidates) {
if (existsSync(path)) {
return readFileSync(path, "utf8");
}
}
throw new Error("не найден исходник Worker health-probe");
}
@@ -0,0 +1,185 @@
import { getAppSettings, repos, updateAppSettings, type HealthEngineFallbacks } from "@cfdm/db";
import type { Db } from "@cfdm/db";
import { HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, targetHasProvider } from "@cfdm/shared";
import type { CloudflareClient } from "../../lib/cf-client.js";
import { AppError } from "../../errors.js";
import { loadHealthProbeWorkerSource } from "./health-probe-script.js";
import {
buildTargetsDoc,
createCloudflareKvMailbox,
toCloudflareCron,
type HealthMailbox,
} from "./mailbox.js";
export const DEFAULT_HEALTH_FALLBACKS: HealthEngineFallbacks = {
healthCheckCron: "0 */2 * * * *",
healthDegradedFailures: 1,
healthDownFailures: 2,
healthLatencyWarnMs: 1000,
healthSuccessRecoveries: 2,
healthWorkerUrl: "",
healthWorkerTokenSet: false,
};
export async function resolveAccountId(
cf: CloudflareClient,
db: Db,
cached?: string | null,
): Promise<string> {
const trimmed = cached?.trim();
if (trimmed) return trimmed;
const domains = repos.listDomains(db);
for (const domain of domains) {
if (!domain.cf_zone_id) continue;
try {
const zone = await cf.getZone(domain.cf_zone_id);
const id = zone.account?.id?.trim();
if (id) return id;
} catch {
// try next zone / accounts list
}
}
const accounts = await cf.listAccounts();
const id = accounts[0]?.id?.trim();
if (!id) {
throw AppError.cloudflare(
"Не удалось определить Cloudflare account_id. Добавьте зону или расширьте права токена (Account Settings Read).",
);
}
return id;
}
export async function ensureKvNamespace(
cf: CloudflareClient,
accountId: string,
existingId?: string | null,
): Promise<string> {
if (existingId?.trim()) return existingId.trim();
const listed = await cf.listKvNamespaces(accountId);
const found = listed.find((ns) => ns.title === HEALTH_PROBE_KV_TITLE);
if (found?.id) return found.id;
const created = await cf.createKvNamespace(accountId, HEALTH_PROBE_KV_TITLE);
if (!created.id) {
throw AppError.cloudflare("Cloudflare не вернул id KV namespace");
}
return created.id;
}
export async function ensureHealthWorker(
db: Db,
cf: CloudflareClient,
fallbacks: HealthEngineFallbacks,
): Promise<{ url: string; kvNamespaceId: string; accountId: string }> {
const settings = getAppSettings(db, fallbacks);
try {
const accountId = await resolveAccountId(cf, db, settings.healthWorkerAccountId);
const kvNamespaceId = await ensureKvNamespace(
cf,
accountId,
settings.healthWorkerKvNamespaceId,
);
const source = loadHealthProbeWorkerSource();
await cf.putWorkerScript({
accountId,
scriptName: HEALTH_PROBE_SCRIPT_NAME,
source,
kvNamespaceId,
});
await cf.putWorkerSchedules(accountId, HEALTH_PROBE_SCRIPT_NAME, [
toCloudflareCron(settings.healthCheckCron),
]);
try {
await cf.enableWorkersDev(accountId, HEALTH_PROBE_SCRIPT_NAME);
} catch {
// workers.dev may already be on
}
const subdomain = await cf.getWorkersSubdomain(accountId);
const url = subdomain
? `https://${HEALTH_PROBE_SCRIPT_NAME}.${subdomain}.workers.dev`
: settings.healthWorkerUrl || `https://${HEALTH_PROBE_SCRIPT_NAME}.workers.dev`;
updateAppSettings(
db,
{
healthWorkerAccountId: accountId,
healthWorkerKvNamespaceId: kvNamespaceId,
healthWorkerUrl: url,
healthWorkerError: null,
healthWorkerDeployedAt: new Date().toISOString(),
},
fallbacks,
);
await syncCloudflareTargetsToKv(db, cf, fallbacks);
return { url, kvNamespaceId, accountId };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
updateAppSettings(db, { healthWorkerError: message }, fallbacks);
throw err;
}
}
export async function maybeEnsureHealthWorker(
db: Db,
cf: CloudflareClient,
fallbacks: HealthEngineFallbacks,
): Promise<void> {
const hasCloudflare = repos
.listHealthCheckTargets(db)
.some((target) => targetHasProvider(target, "cloudflare"));
if (!hasCloudflare) return;
const settings = getAppSettings(db, fallbacks);
if (settings.healthWorkerKvNamespaceId.trim() && !settings.healthWorkerError) {
await syncCloudflareTargetsToKv(db, cf, fallbacks);
return;
}
await ensureHealthWorker(db, cf, fallbacks);
}
export function mailboxFromSettings(
db: Db,
cf: CloudflareClient,
fallbacks: HealthEngineFallbacks,
): HealthMailbox | null {
const settings = getAppSettings(db, fallbacks);
const accountId = settings.healthWorkerAccountId.trim();
const ns = settings.healthWorkerKvNamespaceId.trim();
if (!accountId || !ns) return null;
return createCloudflareKvMailbox(cf, accountId, ns);
}
export async function syncCloudflareTargetsToKv(
db: Db,
cf: CloudflareClient,
fallbacks: HealthEngineFallbacks,
mailbox?: HealthMailbox | null,
): Promise<void> {
const box = mailbox ?? mailboxFromSettings(db, cf, fallbacks);
if (!box) return;
const next = buildTargetsDoc(repos.listHealthCheckTargets(db));
const current = await box.getTargets();
if (current?.fingerprint === next.fingerprint) return;
await box.putTargets(next);
}
export function fireEnsureHealthWorker(
db: Db,
cf: CloudflareClient,
fallbacks: HealthEngineFallbacks,
log?: { warn: (obj: unknown, msg: string) => void },
): void {
if (process.env.VITEST) return;
if (!cf.isConfigured) return;
const hasCloudflare = repos
.listHealthCheckTargets(db)
.some((target) => targetHasProvider(target, "cloudflare"));
if (!hasCloudflare) {
void syncCloudflareTargetsToKv(db, cf, fallbacks).catch((err) => {
log?.warn({ err }, "health worker KV sync failed");
});
return;
}
void maybeEnsureHealthWorker(db, cf, fallbacks).catch((err) => {
log?.warn({ err }, "health worker ensure failed");
});
}
+145
View File
@@ -0,0 +1,145 @@
import type {
HealthCheckTarget,
HealthProbeResultItem,
HealthProbeResultsDoc,
HealthProbeTargetItem,
HealthProbeTargetsDoc,
} from "@cfdm/shared";
import { HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, targetHasProvider } from "@cfdm/shared";
import type { CloudflareClient } from "../../lib/cf-client.js";
export interface HealthMailbox {
getTargets(): Promise<HealthProbeTargetsDoc | null>;
putTargets(doc: HealthProbeTargetsDoc): Promise<void>;
getResults(): Promise<HealthProbeResultsDoc | null>;
}
export function createCloudflareKvMailbox(
cf: CloudflareClient,
accountId: string,
namespaceId: string,
): HealthMailbox {
return {
async getTargets() {
return readJson<HealthProbeTargetsDoc>(cf, accountId, namespaceId, HEALTH_KV_TARGETS_KEY);
},
async putTargets(doc) {
await cf.kvPut(accountId, namespaceId, HEALTH_KV_TARGETS_KEY, JSON.stringify(doc));
},
async getResults() {
return readJson<HealthProbeResultsDoc>(cf, accountId, namespaceId, HEALTH_KV_RESULTS_KEY);
},
};
}
async function readJson<T>(
cf: CloudflareClient,
accountId: string,
namespaceId: string,
key: string,
): Promise<T | null> {
const raw = await cf.kvGet(accountId, namespaceId, key);
if (!raw) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export function originProbeKey(target: HealthCheckTarget): string {
const port = target.port ?? (target.type === "http" ? 80 : 80);
const ip = String(target.ip || "").trim().toLowerCase();
if (target.type === "http") {
const path = (target.path?.trim() || "/") || "/";
const expected = target.expected_status ?? "";
return `http|${ip}|${port}|${path}|${expected}`;
}
if (target.type === "tcp") return `tcp|${ip}|${port}`;
if (target.type === "ping") {
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
}
if (target.type === "dns") {
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
}
return `${target.type}|${ip}|${port}`;
}
export function cloudflareMailboxTargets(
targets: HealthCheckTarget[],
): HealthProbeTargetItem[] {
const unique = new Map<string, HealthProbeTargetItem>();
for (const target of targets) {
if (!targetHasProvider(target, "cloudflare")) continue;
if (target.type !== "tcp" && target.type !== "http") continue;
const key = originProbeKey(target);
if (unique.has(key)) continue;
unique.set(key, {
key,
ip: target.ip,
hostname: target.hostname || target.ip,
type: target.type,
port: target.port ?? (target.type === "http" ? 80 : 80),
path: target.path ?? "/",
expectedStatus: target.expected_status,
timeoutMs: target.timeout_ms ?? 3000,
verifyTls: Boolean(target.verify_tls),
});
}
return [...unique.values()].sort((a, b) => a.key.localeCompare(b.key));
}
export function fingerprintTargets(items: HealthProbeTargetItem[]): string {
return items
.map(
(item) =>
`${item.key}|${item.hostname}|${item.timeoutMs ?? ""}|${item.verifyTls ? "1" : "0"}`,
)
.join(";");
}
export function buildTargetsDoc(targets: HealthCheckTarget[]): HealthProbeTargetsDoc {
const items = cloudflareMailboxTargets(targets);
return {
fingerprint: fingerprintTargets(items),
updatedAt: new Date().toISOString(),
items,
};
}
export function indexResults(
doc: HealthProbeResultsDoc | null,
): Map<string, HealthProbeResultItem> {
const map = new Map<string, HealthProbeResultItem>();
if (!doc?.items) return map;
for (const item of doc.items) {
map.set(item.key, item);
}
return map;
}
export function isResultsStale(doc: HealthProbeResultsDoc | null, staleAfterMs: number): boolean {
if (!doc?.probedAt) return true;
const ts = Date.parse(doc.probedAt);
if (!Number.isFinite(ts)) return true;
return Date.now() - ts > staleAfterMs;
}
/** Drop seconds from toad 6-field cron for Cloudflare Workers (5-field). */
export function toCloudflareCron(expr: string): string {
const parts = expr.trim().split(/\s+/).filter(Boolean);
if (parts.length === 6) return parts.slice(1).join(" ");
if (parts.length === 5) return parts.join(" ");
throw new Error("некорректное cron-выражение");
}
export function cronStaleAfterMs(expr: string): number {
const cf = toCloudflareCron(expr);
const minute = cf.split(/\s+/)[0] ?? "*";
if (minute.startsWith("*/")) {
const n = Number(minute.slice(2));
if (Number.isFinite(n) && n > 0) return Math.max(n * 2, 5) * 60_000;
}
if (minute === "*") return 10 * 60_000;
return 10 * 60_000;
}
+13
View File
@@ -0,0 +1,13 @@
export function workerNotConfiguredResult(): {
ok: false;
latencyMs: number;
error: string;
colo: null;
} {
return {
ok: false,
latencyMs: 0,
error: "Cloudflare Worker не настроен (нет KV mailbox)",
colo: null,
};
}
+119 -5
View File
@@ -2,6 +2,8 @@ import type { Db } from "@cfdm/db";
import { repos } from "@cfdm/db";
import type {
DnsRecord,
HealthCheckAggregate,
HealthCheckProvider,
HealthCheckScope,
HealthCheckType,
IpHealthState,
@@ -16,6 +18,7 @@ import {
SYNC_PENDING_PUSH,
SYNC_SYNCED,
dnsRecordNamesMatch,
isIpLiteral,
normalizeDnsRecordName,
} from "@cfdm/shared";
import type { CloudflareClient } from "../lib/cf-client.js";
@@ -24,7 +27,9 @@ import { isValidIpv4 } from "../lib/validators.js";
import * as dnsService from "./dns-service.js";
import * as domainService from "./domain-service.js";
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
import {
isHealthy,
selectActiveIpsByMode,
withBindingLock,
type LbIpRow,
@@ -50,6 +55,9 @@ export interface ServiceDomainInput {
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
health_check_verify_tls?: boolean;
health_check_provider?: HealthCheckProvider;
health_check_providers?: HealthCheckProvider[];
health_check_aggregate?: HealthCheckAggregate;
}
export interface ToggleRequest {
@@ -70,6 +78,9 @@ export interface ServiceGroupBody {
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
health_check_verify_tls?: boolean;
health_check_provider?: HealthCheckProvider;
health_check_providers?: HealthCheckProvider[];
health_check_aggregate?: HealthCheckAggregate;
}
export interface UpdateServiceGroupBody {
@@ -86,6 +97,9 @@ export interface UpdateServiceGroupBody {
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
health_check_verify_tls?: boolean;
health_check_provider?: HealthCheckProvider;
health_check_providers?: HealthCheckProvider[];
health_check_aggregate?: HealthCheckAggregate;
}
export interface UpdateServiceConfigRequest {
@@ -291,10 +305,24 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
health_check_interval_sec: binding.health_check_interval_sec,
health_check_timeout_ms: binding.health_check_timeout_ms,
health_check_verify_tls: binding.health_check_verify_tls,
health_check_provider: binding.health_check_provider ?? "local",
health_check_providers: binding.health_check_providers ?? [
binding.health_check_provider ?? "local",
],
health_check_aggregate: binding.health_check_aggregate ?? "majority",
cert_monitoring: binding.cert_monitoring ?? "auto",
sync_status: aggregateSyncStatus(statuses),
};
});
const activeIps = new Set<string>();
for (const binding of bindings) {
const { config, rows } = getBindingLbState(db, binding.id);
for (const ip of selectActiveIpsByMode(config, rows)) {
activeIps.add(ip);
}
}
return {
id: service.id,
name: service.name,
@@ -313,9 +341,69 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
health_status: "unknown",
health_latency_ms: null,
ip_health: [],
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
active_ips: [...activeIps],
};
}
const HEALTH_RANK: Record<string, number> = {
down: 3,
degraded: 2,
unknown: 1,
up: 0,
};
function cnameLookupKeys(value: string, zoneName?: string | null): string[] {
const trimmed = value.trim();
if (!trimmed) return [];
const noDot = trimmed.replace(/\.+$/, "");
const lower = noDot.toLowerCase();
const keys = new Set([trimmed, noDot, lower]);
if (zoneName && !lower.includes(".")) {
keys.add(`${lower}.${zoneName.trim().toLowerCase().replace(/\.+$/, "")}`);
}
return [...keys];
}
type ServiceHealthRow = {
ip: string;
status: IpHealthState;
latency_ms: number | null;
last_checked_at: string | null;
last_error: string | null;
provider: ServiceView["ip_health"][number]["provider"];
colo: string | null;
};
/** Health rows keyed by CNAME hostname (legacy probes) applied to service IPs. */
function fallbackCnameHealth(
rows: ServiceHealthRow[],
view: ServiceView,
): ServiceHealthRow | undefined {
const cnameKeys = new Set<string>();
for (const domain of view.domains ?? []) {
const cname = domain.target_cname?.trim();
if (!cname) continue;
for (const key of cnameLookupKeys(cname, domain.zone_name)) {
cnameKeys.add(key);
}
}
const hostnameRows = rows.filter((row) => !isIpLiteral(row.ip));
if (hostnameRows.length === 0) return undefined;
const matched =
cnameKeys.size === 0
? hostnameRows
: hostnameRows.filter((row) =>
cnameLookupKeys(row.ip).some((key) => cnameKeys.has(key)),
);
const candidates = matched.length > 0 ? matched : hostnameRows;
return candidates.reduce((worst, row) =>
(HEALTH_RANK[row.status] ?? 0) > (HEALTH_RANK[worst.status] ?? 0)
? row
: worst,
);
}
function attachServiceHealth(
db: Db,
views: ServiceView[],
@@ -325,15 +413,24 @@ function attachServiceHealth(
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 rows = ipHealthByService.get(view.id) ?? [];
const byIp = new Map(rows.map((row) => [row.ip, row]));
const cnameFallback = fallbackCnameHealth(rows, view);
const aRecordIps = new Set(
(view.domains ?? []).flatMap((domain) =>
domain.target_cname?.trim() ? [] : (domain.target_ips ?? []),
),
);
const ip_health = (view.ips ?? []).map((ip) => {
const row = byIp.get(ip);
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
return {
ip,
status: row?.status ?? ("unknown" as const),
latency_ms: row?.latency_ms ?? null,
last_checked_at: row?.last_checked_at ?? null,
last_error: row?.last_error ?? null,
provider: row?.provider ?? "local",
colo: row?.colo ?? null,
};
});
return {
@@ -1154,7 +1251,10 @@ export async function updateConfig(
input.health_check_expected_status !== undefined ||
input.health_check_interval_sec !== undefined ||
input.health_check_timeout_ms !== undefined ||
input.health_check_verify_tls !== undefined
input.health_check_verify_tls !== undefined ||
input.health_check_provider !== undefined ||
input.health_check_providers !== undefined ||
input.health_check_aggregate !== undefined
) {
repos.updateBindingLbConfig(db, binding.id, {
lb_mode: input.lb_mode,
@@ -1166,6 +1266,9 @@ export async function updateConfig(
health_check_interval_sec: input.health_check_interval_sec,
health_check_timeout_ms: input.health_check_timeout_ms,
health_check_verify_tls: input.health_check_verify_tls,
health_check_provider: input.health_check_provider,
health_check_providers: input.health_check_providers,
health_check_aggregate: input.health_check_aggregate,
});
}
@@ -1240,6 +1343,8 @@ export async function updateConfig(
void syncServiceToVpsTracker(db, id, removedBindingIds);
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
return view!;
}
@@ -1251,7 +1356,7 @@ export async function createGroup(
): Promise<ServiceGroup> {
const groupType = body.type?.trim() || "custom";
const domain = await normalizeGroupDomain(db, cf, body.domain);
return repos.createServiceGroup(
const group = repos.createServiceGroup(
db,
body.name,
groupType,
@@ -1267,8 +1372,13 @@ export async function createGroup(
health_check_interval_sec: body.health_check_interval_sec,
health_check_timeout_ms: body.health_check_timeout_ms,
health_check_verify_tls: body.health_check_verify_tls,
health_check_provider: body.health_check_provider,
health_check_providers: body.health_check_providers,
health_check_aggregate: body.health_check_aggregate,
},
);
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
return group;
}
export async function updateGroup(
@@ -1303,6 +1413,9 @@ export async function updateGroup(
health_check_interval_sec: body.health_check_interval_sec,
health_check_timeout_ms: body.health_check_timeout_ms,
health_check_verify_tls: body.health_check_verify_tls,
health_check_provider: body.health_check_provider,
health_check_providers: body.health_check_providers,
health_check_aggregate: body.health_check_aggregate,
},
);
if (!domain && group.enabled) {
@@ -1310,6 +1423,7 @@ export async function updateGroup(
group = repos.getServiceGroup(db, id);
}
await syncEnabledServicesInGroup(db, cf, id);
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
return group;
}
+186 -20
View File
@@ -208,7 +208,7 @@ describe("certificates", () => {
await testApp.close();
});
it("required apex is monitored without bindings", async () => {
it("required binding is monitored without TLS health gate", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
@@ -221,10 +221,18 @@ describe("certificates", () => {
"required.example.com",
"cf-zone-req",
);
repos.updateDomain(testApp.db, domain.id, {
group_id: null,
status: "active",
const service = repos.createService(testApp.db, "Req", "req");
repos.setServiceEnabled(testApp.db, service.id, true);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"@",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED,
health_check_enabled: false,
});
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
@@ -247,7 +255,7 @@ describe("certificates", () => {
await testApp.close();
});
it("skipped apex removes stale certificate on check", async () => {
it("skipped binding removes stale certificate on check", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
@@ -260,6 +268,20 @@ describe("certificates", () => {
"skipped.example.com",
"cf-zone-skip",
);
const service = repos.createService(testApp.db, "Skip", "skip");
repos.setServiceEnabled(testApp.db, service.id, true);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"@",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_SKIPPED,
health_check_enabled: true,
health_check_verify_tls: true,
});
repos.upsertCertificateCheck(
testApp.db,
domain.id,
@@ -268,12 +290,8 @@ describe("certificates", () => {
null,
CERT_ERROR,
"stale",
service.id,
);
repos.updateDomain(testApp.db, domain.id, {
group_id: null,
status: "active",
cert_monitoring: CERT_MONITOR_SKIPPED,
});
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: null,
@@ -305,9 +323,16 @@ describe("certificates", () => {
"broken.example.com",
"cf-zone-broken",
);
repos.updateDomain(testApp.db, domain.id, {
group_id: null,
status: "active",
const service = repos.createService(testApp.db, "Broken", "broken");
repos.setServiceEnabled(testApp.db, service.id, true);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"@",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED,
});
@@ -424,7 +449,7 @@ describe("certificates", () => {
});
const certs = repos.listCertificates(testApp.db);
expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(true);
expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(false);
expect(certs.some((c) => c.hostname === "edge.ok.example.com")).toBe(true);
await testApp.close();
@@ -443,12 +468,7 @@ describe("certificates", () => {
"force.example.com",
"cf-zone-force",
);
repos.updateDomain(testApp.db, domain.id, {
group_id: null,
status: "active",
cert_monitoring: CERT_MONITOR_REQUIRED,
});
repos.createServiceGroup(
const group = repos.createServiceGroup(
testApp.db,
"Proxy",
"vpn",
@@ -459,6 +479,19 @@ describe("certificates", () => {
health_check_verify_tls: false,
},
);
const service = repos.createService(testApp.db, "Force", "force");
repos.setServiceEnabled(testApp.db, service.id, true);
repos.setServiceGroup(testApp.db, service.id, group.id);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"@",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED,
});
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
@@ -540,4 +573,137 @@ describe("certificates", () => {
await testApp.close();
});
it("GET /services/:id/certificates lists binding FQDNs", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(testApp);
const domain = repos.createDomain(
testApp.db,
null,
"svc.example.com",
"cf-zone-svc",
);
const service = repos.createService(testApp.db, "Api", "api");
repos.setServiceEnabled(testApp.db, service.id, true);
repos.insertBinding(testApp.db, domain.id, service.id, "www", null);
const res = await testApp.inject({
method: "GET",
url: `/api/v1/services/${service.id}/certificates`,
headers,
});
expect(res.statusCode).toBe(200);
const rows = res.json() as Array<{
hostname: string;
cert_monitoring: string;
status: string;
}>;
expect(rows).toHaveLength(1);
expect(rows[0]?.hostname).toBe("www.svc.example.com");
expect(rows[0]?.cert_monitoring).toBe("auto");
expect(rows[0]?.status).toBe("unknown");
await testApp.close();
});
it("PATCH /service-bindings/:id updates cert_monitoring", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(testApp);
const domain = repos.createDomain(
testApp.db,
null,
"patch.example.com",
"cf-zone-patch",
);
const service = repos.createService(testApp.db, "Patch", "patch");
repos.setServiceEnabled(testApp.db, service.id, true);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"api",
null,
);
const res = await testApp.inject({
method: "PATCH",
url: `/api/v1/service-bindings/${binding.id}`,
headers,
payload: { cert_monitoring: CERT_MONITOR_REQUIRED },
});
expect(res.statusCode).toBe(200);
expect((res.json() as { cert_monitoring: string }).cert_monitoring).toBe(
CERT_MONITOR_REQUIRED,
);
expect(repos.getBinding(testApp.db, binding.id).cert_monitoring).toBe(
CERT_MONITOR_REQUIRED,
);
await testApp.close();
});
it("POST /services/:id/certificates/check only checks that service", async () => {
const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(testApp);
const domain = repos.createDomain(
testApp.db,
null,
"check.example.com",
"cf-zone-check",
);
const service = repos.createService(testApp.db, "One", "one");
const other = repos.createService(testApp.db, "Two", "two");
repos.setServiceEnabled(testApp.db, service.id, true);
repos.setServiceEnabled(testApp.db, other.id, true);
const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"one",
null,
);
const otherBinding = repos.insertBinding(
testApp.db,
domain.id,
other.id,
"two",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED,
});
repos.updateBindingLbConfig(testApp.db, otherBinding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED,
});
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
error: null,
});
const res = await testApp.inject({
method: "POST",
url: `/api/v1/services/${service.id}/certificates/check`,
headers,
});
expect(res.statusCode).toBe(200);
expect((res.json() as { checked: number }).checked).toBe(1);
const certs = repos.listCertificates(testApp.db);
expect(certs.some((c) => c.hostname === "one.check.example.com")).toBe(true);
expect(certs.some((c) => c.hostname === "two.check.example.com")).toBe(false);
await testApp.close();
});
});
+95
View File
@@ -56,6 +56,7 @@ describe("health-check probeTarget", () => {
expected_status: null,
timeout_ms: 1000,
verify_tls: false,
provider: "local",
};
const result = await healthCheckService.probeTarget(target);
expect(result.ok).toBe(true);
@@ -75,6 +76,7 @@ describe("health-check probeTarget", () => {
expected_status: null,
timeout_ms: 500,
verify_tls: false,
provider: "local",
};
const result = await healthCheckService.probeTarget(target);
expect(result.ok).toBe(false);
@@ -107,6 +109,7 @@ describe("health-check probeTarget", () => {
expected_status: 200,
timeout_ms: 1000,
verify_tls: false,
provider: "local",
};
const bindingTarget: HealthCheckTarget = {
...groupTarget,
@@ -142,6 +145,7 @@ describe("health-check probeTarget", () => {
expected_status: null,
timeout_ms: 3000,
verify_tls: false,
provider: "local",
};
const binding: HealthCheckTarget = {
...group,
@@ -231,4 +235,95 @@ describe("health-check state derivation via runAllChecks", () => {
expect(targets).toHaveLength(1);
expect(targets[0]?.verify_tls).toBe(true);
});
it("unwraps CNAME target to origin A record IPs", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
repos.insertDnsRecord(
db,
domain.id,
"A",
"ihome",
"2.59.161.102",
1,
false,
null,
"synced",
"cf",
null,
);
const service = repos.createService(db, "RW Sub", "rw-sub");
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
repos.updateBindingLbConfig(db, binding.id, {
health_check_enabled: true,
health_check_type: "tcp",
health_check_port: 443,
});
const targets = repos.listHealthCheckTargets(db);
expect(targets).toHaveLength(1);
expect(targets[0]?.ip).toBe("2.59.161.102");
expect(targets[0]?.hostname).toBe("s.rkns.top");
});
it("unwraps CNAME target to service IP pool when origin DNS is empty", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "RW Sub", "rw-sub");
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
repos.updateBindingLbConfig(db, binding.id, {
health_check_enabled: true,
health_check_type: "tcp",
health_check_port: 443,
});
const targets = repos.listHealthCheckTargets(db);
expect(targets).toHaveLength(1);
expect(targets[0]?.ip).toBe("2.59.161.102");
expect(targets[0]?.hostname).toBe("s.rkns.top");
});
});
describe("CNAME health mapped onto service IPs", () => {
it("getView copies CNAME-keyed health onto the service IP row", async () => {
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
const { getView } = await import("../src/services/service-config-service.js");
const { db, sqlite } = createMemoryDb();
runMigrations(sqlite);
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
const service = repos.createService(db, "RW Sub", "rw-sub");
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
repos.upsertIpHealthStatus(
db,
"binding",
binding.id,
"ihome.rkns.top",
"up",
12,
0,
null,
);
const view = await getView(db, service.id);
expect(view.health_status).toBe("up");
expect(view.ip_health).toEqual([
expect.objectContaining({
ip: "2.59.161.102",
status: "up",
latency_ms: 12,
}),
]);
});
});
+273
View File
@@ -0,0 +1,273 @@
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { repos, type Db } from "@cfdm/db";
import * as healthCheckService from "../src/services/health-check-service.js";
import {
buildMeasurementBody,
summarizeMeasurement,
} from "../src/lib/globalping-client.js";
import { aggregateHealthOk } from "@cfdm/shared";
import type { HealthCheckTarget } from "@cfdm/shared";
const thresholds = {
degradedFailures: 1,
downFailures: 2,
latencyWarnMs: 1000,
successRecoveries: 2,
};
function tcpTarget(overrides?: Partial<HealthCheckTarget>): HealthCheckTarget {
return {
scope: "binding",
ref_id: 1,
ip: "203.0.113.10",
hostname: "panel.example.com",
type: "tcp",
port: 443,
path: null,
expected_status: null,
timeout_ms: 400,
verify_tls: false,
provider: "globalping",
providers: ["globalping"],
aggregate: "majority",
...overrides,
};
}
async function seedBinding(
db: Db,
opts: {
ip: string;
providers: Array<"local" | "cloudflare" | "globalping">;
aggregate?: "any" | "all" | "majority";
port?: number;
},
) {
const domain = repos.createDomain(db, null, "example.com", "zone-1");
const service = repos.createService(db, "Panel", "panel");
repos.setServiceEnabled(db, service.id, true);
repos.replaceServiceIps(db, service.id, [opts.ip]);
const binding = repos.insertBinding(db, domain.id, service.id, "panel", null);
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: opts.ip, weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, {
health_check_enabled: true,
health_check_type: "tcp",
health_check_port: opts.port ?? 1,
health_check_timeout_ms: 400,
health_check_providers: opts.providers,
health_check_aggregate: opts.aggregate ?? "majority",
});
return { service, binding, domain };
}
function mockFetch(handler: (url: string, init?: RequestInit) => Response): typeof fetch {
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
return handler(url, init);
}) as typeof fetch;
}
describe("aggregateHealthOk", () => {
it("any / all / majority", () => {
expect(aggregateHealthOk([true, false], "any")).toBe(false);
expect(aggregateHealthOk([true, false], "all")).toBe(true);
expect(aggregateHealthOk([true, false], "majority")).toBe(true);
expect(aggregateHealthOk([false, false], "majority")).toBe(false);
expect(aggregateHealthOk([true, false, false], "majority")).toBe(false);
expect(aggregateHealthOk([true, true, false], "majority")).toBe(true);
expect(aggregateHealthOk([true], "majority")).toBe(true);
});
});
describe("globalping mapping", () => {
it("maps CFDM TCP to ping+TCP and HTTP to http+host", () => {
const tcp = buildMeasurementBody(tcpTarget(), { limit: 3, locations: "World" });
expect(tcp.type).toBe("ping");
expect(tcp.measurementOptions.protocol).toBe("TCP");
expect(tcp.measurementOptions.port).toBe(443);
expect(tcp.inProgressUpdates).toBe(false);
const http = buildMeasurementBody(
tcpTarget({ type: "http", port: 443, path: "/health", expected_status: 200 }),
{ limit: 2, locations: "EU,US" },
);
expect(http.type).toBe("http");
expect(http.locations).toEqual([{ magic: "EU" }, { magic: "US" }]);
expect(http.measurementOptions.request).toMatchObject({
host: "panel.example.com",
path: "/health",
method: "GET",
});
});
it("summarizes HTTP majority and TCP packet loss", () => {
const httpOk = summarizeMeasurement(
tcpTarget({ type: "http", expected_status: 200 }),
{
status: "finished",
results: [
{ probe: { city: "Frankfurt", country: "DE" }, result: { status: "finished", statusCode: 200, timings: { total: 40 } } },
{ probe: { city: "London", country: "GB" }, result: { status: "finished", statusCode: 200, timings: { total: 50 } } },
{ probe: { city: "Paris", country: "FR" }, result: { status: "finished", statusCode: 500, timings: { total: 20 } } },
],
},
);
expect(httpOk.ok).toBe(true);
expect(httpOk.colo).toBe("Frankfurt, DE");
const tcpFail = summarizeMeasurement(tcpTarget(), {
status: "finished",
results: [
{ result: { status: "finished", stats: { avg: 12, loss: 100 } } },
{ result: { status: "finished", stats: { avg: 11, loss: 100 } } },
],
});
expect(tcpFail.ok).toBe(false);
});
});
describe("globalping engine", () => {
it("POST 202 + GET finished writes colo from probe city", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const { binding } = await seedBinding(app.db, {
ip: "203.0.113.40",
providers: ["globalping"],
port: 443,
});
const fetchImpl = mockFetch((url) => {
if (url.endsWith("/v1/measurements")) {
return new Response(JSON.stringify({ id: "meas-1" }), { status: 202 });
}
return new Response(
JSON.stringify({
id: "meas-1",
status: "finished",
results: [
{
probe: { city: "Amsterdam", country: "NL" },
result: { status: "finished", stats: { avg: 18, loss: 0 } },
},
{
probe: { city: "Frankfurt", country: "DE" },
result: { status: "finished", stats: { avg: 22, loss: 0 } },
},
],
}),
{ status: 200 },
);
});
await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: 0,
globalping: {
token: "gp_test",
locations: "World",
limit: 2,
pollIntervalMs: 0,
fetchImpl,
},
});
const row = repos.getIpHealthStatusRow(
app.db,
"binding",
binding.id,
"203.0.113.40",
);
expect(row?.status).toBe("up");
expect(row?.provider).toBe("globalping");
expect(row?.colo).toMatch(/Amsterdam/);
await app.close();
});
it("429 fails the source and does not fall back to local", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const { binding } = await seedBinding(app.db, {
ip: "127.0.0.1",
providers: ["globalping"],
port: 1,
});
const fetchImpl = mockFetch(() => new Response("rate limited", { status: 429 }));
await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: 0,
globalping: {
token: "gp_test",
locations: "World",
limit: 1,
pollIntervalMs: 0,
fetchImpl,
},
});
const row = repos.getIpHealthStatusRow(
app.db,
"binding",
binding.id,
"127.0.0.1",
);
expect(row?.last_error).toMatch(/429/i);
expect(row?.provider).toBe("globalping");
await app.close();
});
it("local+globalping all keeps IP up if Globalping is ok", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const { binding, service } = await seedBinding(app.db, {
ip: "127.0.0.1",
providers: ["local", "globalping"],
aggregate: "all",
port: 1,
});
const fetchImpl = mockFetch((url) => {
if (url.endsWith("/v1/measurements")) {
return new Response(JSON.stringify({ id: "meas-2" }), { status: 202 });
}
return new Response(
JSON.stringify({
status: "finished",
results: [
{ probe: { city: "Vienna", country: "AT" }, result: { status: "finished", stats: { avg: 9, loss: 0 } } },
],
}),
{ status: 200 },
);
});
await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: 0,
globalping: {
token: "gp_test",
locations: "World",
limit: 1,
pollIntervalMs: 0,
fetchImpl,
},
});
const row = repos.getIpHealthStatusRow(
app.db,
"binding",
binding.id,
"127.0.0.1",
);
expect(row?.status).toBe("up");
expect(row?.provider).toBe("aggregate");
const logs = repos.listHealthProbeLogForService(app.db, service.id);
expect(logs.map((row) => row.provider).sort()).toEqual([
"globalping",
"local",
]);
await app.close();
});
});
+160
View File
@@ -0,0 +1,160 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { toCloudflareCron } from "../src/services/health/mailbox.js";
import { HEALTH_PROBE_SCRIPT_NAME } from "@cfdm/shared";
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
const res = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: "admin", password: "admin" },
});
expect(res.statusCode).toBe(200);
const { token } = res.json() as { token: string };
return { authorization: `Bearer ${token}` };
}
function jsonOk(result: unknown, status = 200): Response {
return new Response(JSON.stringify({ success: true, result }), {
status,
headers: { "content-type": "application/json" },
});
}
describe("health worker deploy", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("maps 6-field toad cron to 5-field Cloudflare cron", () => {
expect(toCloudflareCron("0 */2 * * * *")).toBe("*/2 * * * *");
expect(toCloudflareCron("*/5 * * * *")).toBe("*/5 * * * *");
});
it("POST ensure creates KV+script; 403 is not local fallback", async () => {
const calls: string[] = [];
vi.stubGlobal(
"fetch",
async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === "string" || input instanceof URL
? String(input)
: input.url;
const method = (
init?.method ??
(typeof Request !== "undefined" && input instanceof Request
? input.method
: "GET")
).toUpperCase();
calls.push(`${method} ${url}`);
if (
(url.includes("/accounts?") || /\/accounts$/.test(url.split("?")[0] ?? "")) &&
!url.includes("/storage/") &&
!url.includes("/workers/")
) {
return jsonOk([{ id: "acc-1", name: "Test" }]);
}
if (url.includes("/storage/kv/namespaces") && method === "GET" && !url.includes("/values/")) {
return jsonOk([]);
}
if (url.includes("/storage/kv/namespaces") && method === "POST") {
return jsonOk({ id: "kv-1", title: "cfdm-health-probe" });
}
if (
url.includes(`/workers/scripts/${HEALTH_PROBE_SCRIPT_NAME}`) &&
method === "PUT" &&
!url.includes("/schedules")
) {
return jsonOk({ id: "script-1" });
}
if (url.includes("/schedules") && method === "PUT") {
return jsonOk([{ cron: "*/2 * * * *" }]);
}
if (url.includes("/subdomain") && method === "POST") {
return jsonOk({ enabled: true });
}
if (url.includes("/workers/subdomain") && method === "GET") {
return jsonOk({ subdomain: "example" });
}
if (url.includes("/values/") && method === "GET") {
return new Response("null", { status: 404 });
}
if (url.includes("/values/") && method === "PUT") {
return jsonOk(null);
}
return jsonOk({});
},
);
const app = await buildApp({
config: { ...loadConfig(), staticDir: null, cloudflareApiToken: "cf-token" },
memory: true,
});
const headers = await authHeaders(app);
const res = await app.inject({
method: "POST",
url: "/api/v1/settings/health/worker/ensure",
headers,
});
expect(res.statusCode).toBe(200);
const body = res.json() as {
healthWorkerStatus: string;
healthWorkerUrl: string;
healthWorkerKvNamespaceId: string;
healthWorkerError: string | null;
};
expect(body.healthWorkerStatus).toBe("ready");
expect(body.healthWorkerKvNamespaceId).toBe("kv-1");
expect(body.healthWorkerUrl).toContain("cfdm-health-probe.example.workers.dev");
expect(body.healthWorkerError).toBeNull();
expect(calls.some((c) => c.includes("/workers/scripts/"))).toBe(true);
await app.close();
}, 20_000);
it("POST ensure 403 stores error, does not probe as local", async () => {
vi.stubGlobal(
"fetch",
async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/storage/kv/namespaces")) {
return new Response(
JSON.stringify({
success: false,
errors: [{ code: 10000, message: "Authentication error" }],
}),
{ status: 403, headers: { "content-type": "application/json" } },
);
}
if (url.includes("/accounts")) {
return jsonOk([{ id: "acc-1" }]);
}
return jsonOk({});
},
);
const app = await buildApp({
config: { ...loadConfig(), staticDir: null, cloudflareApiToken: "zone-only" },
memory: true,
});
const headers = await authHeaders(app);
const res = await app.inject({
method: "POST",
url: "/api/v1/settings/health/worker/ensure",
headers,
});
expect(res.statusCode).toBe(401);
const again = await app.inject({
method: "GET",
url: "/api/v1/settings",
headers,
});
const body = again.json() as {
healthWorkerStatus: string;
healthWorkerError: string | null;
};
expect(body.healthWorkerStatus).toBe("error");
expect(body.healthWorkerError).toMatch(/Workers Scripts Write|токен/i);
await app.close();
}, 20_000);
});
+245
View File
@@ -0,0 +1,245 @@
import { describe, expect, it } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { repos, type Db } from "@cfdm/db";
import * as healthCheckService from "../src/services/health-check-service.js";
import type { HealthMailbox } from "../src/services/health/mailbox.js";
import { originProbeKey } from "../src/services/health/mailbox.js";
import type { HealthCheckTarget } from "@cfdm/shared";
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
const res = await app.inject({
method: "POST",
url: "/api/v1/auth/login",
payload: { username: "admin", password: "admin" },
});
expect(res.statusCode).toBe(200);
const { token } = res.json() as { token: string };
return { authorization: `Bearer ${token}` };
}
async function seedBinding(
db: Db,
opts: { provider: "local" | "cloudflare"; ip: string },
) {
const domain = repos.createDomain(db, null, "example.com", "zone-1");
const service = repos.createService(db, "Panel", "panel");
repos.setServiceEnabled(db, service.id, true);
repos.replaceServiceIps(db, service.id, [opts.ip]);
const binding = repos.insertBinding(db, domain.id, service.id, "panel", null);
repos.replaceBindingIpsWithMeta(db, binding.id, [
{ ip: opts.ip, weight: 1, priority: 1 },
]);
repos.updateBindingLbConfig(db, binding.id, {
health_check_enabled: true,
health_check_type: "tcp",
health_check_port: 1,
health_check_timeout_ms: 400,
health_check_provider: opts.provider,
});
return { service, binding, domain };
}
const thresholds = {
degradedFailures: 1,
downFailures: 2,
latencyWarnMs: 1000,
successRecoveries: 2,
};
function memoryMailbox(opts?: {
resultsOk?: boolean;
colo?: string;
probedAt?: string;
}): HealthMailbox {
let targets: unknown = null;
return {
async getTargets() {
return targets as never;
},
async putTargets(doc) {
targets = doc;
},
async getResults() {
if (!opts) return null;
const dummy: HealthCheckTarget = {
scope: "binding",
ref_id: 1,
ip: "203.0.113.10",
hostname: "panel.example.com",
type: "tcp",
port: 1,
path: null,
expected_status: null,
timeout_ms: 400,
verify_tls: false,
provider: "cloudflare",
};
return {
probedAt: opts.probedAt ?? new Date().toISOString(),
colo: opts.colo ?? "AMS",
items: [
{
key: originProbeKey(dummy),
ok: opts.resultsOk !== false,
latencyMs: 42,
error: opts.resultsOk === false ? "down" : null,
},
],
};
},
};
}
describe("health-check XOR worker mailbox", () => {
it("lists only local providers when no cloudflare bindings", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
await seedBinding(app.db, {
provider: "local",
ip: "10.0.0.1",
});
const targets = repos.listHealthCheckTargets(app.db);
expect(targets.length).toBeGreaterThan(0);
expect(targets.every((t) => t.provider === "local")).toBe(true);
expect(targets.some((t) => t.provider === "cloudflare")).toBe(false);
await app.close();
});
it("cloudflare without mailbox does not fall back to local", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const { binding } = await seedBinding(app.db, {
provider: "cloudflare",
ip: "127.0.0.1",
});
await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: 0,
mailbox: null,
});
const row = repos.getIpHealthStatusRow(
app.db,
"binding",
binding.id,
"127.0.0.1",
);
expect(row?.last_error).toMatch(/Worker не настроен/i);
expect(row?.provider).toBe("cloudflare");
await app.close();
});
it("KV results write colo and last_checked_at without HTTP /probe", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const { service, binding } = await seedBinding(app.db, {
provider: "cloudflare",
ip: "203.0.113.10",
});
const targets = repos.listHealthCheckTargets(app.db);
const cfTarget = targets.find((t) => t.ip === "203.0.113.10")!;
const mailbox: HealthMailbox = {
async getTargets() {
return null;
},
async putTargets() {
/* fingerprint sync */
},
async getResults() {
return {
probedAt: new Date().toISOString(),
colo: "AMS",
items: [
{
key: originProbeKey(cfTarget),
ok: true,
latencyMs: 42,
error: null,
},
],
};
},
};
await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: 0,
mailbox,
});
const row = repos.getIpHealthStatusRow(
app.db,
"binding",
binding.id,
"203.0.113.10",
);
expect(row?.status).toBe("up");
expect(row?.colo).toBe("AMS");
expect(row?.last_checked_at).toBeTruthy();
expect(row?.provider).toBe("cloudflare");
const res = await app.inject({
method: "GET",
url: `/api/v1/services/${service.id}`,
headers,
});
expect(res.statusCode).toBe(200);
const body = res.json() as {
ip_health: Array<{
ip: string;
colo: string | null;
last_checked_at: string | null;
provider: string;
}>;
};
const ipRow = body.ip_health.find((item) => item.ip === "203.0.113.10");
expect(ipRow?.colo).toBe("AMS");
expect(ipRow?.provider).toBe("cloudflare");
const logRes = await app.inject({
method: "GET",
url: `/api/v1/services/${service.id}/health-log`,
headers,
});
expect(logRes.statusCode).toBe(200);
const logBody = logRes.json() as { items: Array<{ colo: string | null }> };
expect(logBody.items[0]?.colo).toBe("AMS");
await app.close();
});
it("stale KV results are recorded, not local probe", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const { binding } = await seedBinding(app.db, {
provider: "cloudflare",
ip: "203.0.113.20",
});
await healthCheckService.runAllChecks(app.db, {
thresholds,
probeGapMs: 0,
mailbox: memoryMailbox({
resultsOk: true,
colo: "SIN",
probedAt: new Date(Date.now() - 60 * 60_000).toISOString(),
}),
staleAfterMs: 60_000,
});
const row = repos.getIpHealthStatusRow(
app.db,
"binding",
binding.id,
"203.0.113.20",
);
expect(row?.last_error).toMatch(/устарели|KV/i);
expect(row?.provider).toBe("cloudflare");
await app.close();
});
});
@@ -78,4 +78,53 @@ describe("service bindings prune", () => {
expect(bindings).toHaveLength(2);
expect(bindings.map((b) => b.hostname).sort()).toEqual(["api", "www"]);
});
it("updateConfig with extra FQDN per IP does not throw when group health-check is on", async () => {
const db = setupDb();
const cf = mockCf();
const health = {
health_check_enabled: true,
health_check_type: "tcp" as const,
health_check_port: 443,
health_check_providers: ["local", "cloudflare", "globalping"] as const,
health_check_aggregate: "majority" as const,
};
repos.createDomain(db, null, "example.com", "cf-zone-example");
const group = repos.createServiceGroup(
db,
"VPN",
"vpn",
null,
"vpn.example.com",
{ ...health },
);
const service = repos.createService(db, "GT", "gt");
repos.setServiceGroup(db, service.id, group.id);
repos.setServiceEnabled(db, service.id, true);
const view = await updateConfig(db, cf, service.id, {
ips: ["93.115.203.183", "130.49.213.153"],
domains: [
{
fqdn: "gt.example.com",
target_ips: ["93.115.203.183", "130.49.213.153"],
...health,
},
{
fqdn: "rutg.example.com",
target_ips: ["93.115.203.183"],
...health,
},
{
fqdn: "nsgt.example.com",
target_ips: ["130.49.213.153"],
...health,
},
],
});
expect(view.domains).toHaveLength(3);
expect(view.ips.sort()).toEqual(["130.49.213.153", "93.115.203.183"]);
});
});
+9 -1
View File
@@ -99,7 +99,15 @@ describe("service groups health enrichment", () => {
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 },
{
ip: "1.2.3.4",
status: "degraded",
latency_ms: 120,
last_checked_at: expect.any(String),
last_error: null,
provider: "local",
colo: null,
},
]);
// group worst = degraded (from service) over up (group scope)
expect(groupView!.health_status).toBe("degraded");
+90 -6
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { serviceGroupsResponseSchema } from "@cfdm/shared";
import { serviceGroupsResponseSchema, updateServiceConfigSchema } from "@cfdm/shared";
import { repos } from "@cfdm/db";
import type { CloudflareClient } from "../src/lib/cf-client.js";
import { buildApp } from "../src/app.js";
@@ -51,6 +51,26 @@ async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
}
describe("create service then list groups", () => {
it("accepts sqlite-shaped health fields on service config PATCH", () => {
const parsed = updateServiceConfigSchema.parse({
domains: [
{
fqdn: "gw.example.com",
target_ips: ["1.2.3.4"],
health_check_enabled: 1,
health_check_verify_tls: 0,
health_check_providers: '["local","cloudflare"]',
health_check_aggregate: "majority",
},
],
});
expect(parsed.domains?.[0]?.health_check_enabled).toBe(true);
expect(parsed.domains?.[0]?.health_check_verify_tls).toBe(false);
expect(parsed.domains?.[0]?.health_check_providers).toEqual([
"local",
"cloudflare",
]);
});
it("create + updateConfig then listGroupViews parses with shared Zod schema", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
@@ -123,11 +143,75 @@ describe("create service then list groups", () => {
expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe(
true,
);
expect(
httpParsed.data!.groups
.find((g) => g.id === group.id)
?.services.some((s) => s.id === created.id),
).toBe(true);
const listed = httpParsed.data!.groups
.find((g) => g.id === group.id)
?.services.find((s) => s.id === created.id);
expect(listed).toBeDefined();
expect(listed?.lb_mode).toBe("round_robin");
expect(listed?.active_ips).toEqual(["1.2.3.4"]);
await app.close();
});
it("PATCH /services/:id persists health providers and aggregate", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const cf = mockCf();
repos.createDomain(app.db, null, "example.com", "zone-1");
const createRes = await app.inject({
method: "POST",
url: "/api/v1/services",
headers,
payload: { name: "GW", slug: "gw" },
});
expect(createRes.statusCode).toBe(200);
const created = createRes.json() as { id: number };
await updateConfig(app.db, cf, created.id, {
ips: ["1.2.3.4"],
domains: [
{
fqdn: "gw.example.com",
target_ips: ["1.2.3.4"],
health_check_enabled: true,
health_check_type: "tcp",
health_check_interval_sec: 30,
health_check_timeout_ms: 3000,
health_check_providers: ["local", "cloudflare"],
health_check_aggregate: "majority",
},
],
});
const stored = repos.listBindingsByService(app.db, created.id)[0]!;
expect(stored.health_check_enabled).toBe(true);
expect(Array.isArray(stored.health_check_providers)).toBe(true);
expect(stored.health_check_providers).toEqual(["local", "cloudflare"]);
expect(stored.health_check_aggregate).toBe("majority");
const getRes = await app.inject({
method: "GET",
url: `/api/v1/services/${created.id}`,
headers,
});
expect(getRes.statusCode).toBe(200);
const view = getRes.json() as {
domains: Array<{
health_check_enabled: boolean;
health_check_providers: string[];
health_check_aggregate: string;
}>;
};
expect(view.domains[0]?.health_check_enabled).toBe(true);
expect(view.domains[0]?.health_check_providers).toEqual([
"local",
"cloudflare",
]);
expect(view.domains[0]?.health_check_aggregate).toBe("majority");
await app.close();
});
+78
View File
@@ -40,12 +40,14 @@ describe("settings health engine", () => {
healthDownFailures: number;
healthLatencyWarnMs: number;
healthSuccessRecoveries: number;
healthWorkerStatus?: string;
};
expect(body.healthCheckCron).toBe("*/30 * * * * *");
expect(body.healthDegradedFailures).toBe(3);
expect(body.healthDownFailures).toBe(4);
expect(body.healthLatencyWarnMs).toBe(1500);
expect(body.healthSuccessRecoveries).toBe(5);
expect(body.healthWorkerStatus).toBe("missing");
await app.close();
});
@@ -133,4 +135,80 @@ describe("settings health engine", () => {
expect(res.statusCode).toBe(400);
await app.close();
});
it("PATCH worker URL; token is not returned in GET", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const res = await app.inject({
method: "PATCH",
url: "/api/v1/settings",
headers,
payload: {
healthWorkerUrl: "https://cfdm-health-probe.example.workers.dev",
healthWorkerToken: "super-secret",
},
});
expect(res.statusCode).toBe(200);
const body = res.json() as {
healthWorkerUrl: string;
healthWorkerTokenSet: boolean;
healthWorkerToken?: string;
};
expect(body.healthWorkerUrl).toBe(
"https://cfdm-health-probe.example.workers.dev",
);
expect(body.healthWorkerTokenSet).toBe(true);
expect(body.healthWorkerToken).toBeUndefined();
await app.close();
});
it("GET exposes Globalping flags without the token; PATCH persists locations/limit", async () => {
const app = await buildApp({
config: { ...loadConfig(), staticDir: null },
memory: true,
});
const headers = await authHeaders(app);
const initial = await app.inject({
method: "GET",
url: "/api/v1/settings",
headers,
});
expect(initial.statusCode).toBe(200);
const before = initial.json() as {
globalpingTokenSet?: boolean;
globalpingLocations?: string;
globalpingLimit?: number;
globalpingToken?: string;
};
expect(before.globalpingTokenSet).toBe(false);
expect(before.globalpingLocations).toBe("World");
expect(before.globalpingLimit).toBe(3);
expect(before.globalpingToken).toBeUndefined();
const patched = await app.inject({
method: "PATCH",
url: "/api/v1/settings",
headers,
payload: {
globalpingToken: "gp_secret",
globalpingLocations: "EU,US",
globalpingLimit: 5,
},
});
expect(patched.statusCode).toBe(200);
const body = patched.json() as {
globalpingTokenSet: boolean;
globalpingLocations: string;
globalpingLimit: number;
globalpingToken?: string;
};
expect(body.globalpingTokenSet).toBe(true);
expect(body.globalpingLocations).toBe("EU,US");
expect(body.globalpingLimit).toBe(5);
expect(body.globalpingToken).toBeUndefined();
await app.close();
});
});
+17
View File
@@ -0,0 +1,17 @@
import { copyFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/server.ts"],
format: ["esm"],
dts: true,
async onSuccess() {
const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
copyFileSync(
join(root, "workers/health-probe/src/index.mjs"),
join(dirname(fileURLToPath(import.meta.url)), "dist/health-probe-worker.mjs"),
);
},
});
+1
View File
@@ -4,6 +4,7 @@ export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"],
testTimeout: 20_000,
typecheck: {
tsconfig: "./tsconfig.test.json",
},
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { GlobeIcon, SearchIcon } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { GlobeIcon, SearchIcon, ServerIcon } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
@@ -9,6 +10,7 @@ import { StatusBadge } from '@/components/status-badge'
import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils'
import type { Certificate } from '@/lib/schemas'
import { formatDate, formatRelative } from '@/lib/format'
import { Button } from '@cfdm/ui/components/button'
export const CERT_TABS = [
{ id: 'all', label: 'Все' },
@@ -41,6 +43,7 @@ export function certTabFilter(item: Certificate, tabId: string) {
export function createDefaultCertFilters() {
return [
createFilter('hostname', 'contains', ['']),
createFilter('service', 'contains', ['']),
createFilter('status', 'is', ['']),
]
}
@@ -56,6 +59,14 @@ export function useCertFilterFields() {
className: 'w-52',
placeholder: 'Поиск по хосту…',
},
{
key: 'service',
label: 'Сервис',
icon: <ServerIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'Поиск по сервису…',
},
{
key: 'status',
label: 'Статус',
@@ -75,6 +86,8 @@ export function certFilterFieldValue(item: Certificate, field: string) {
switch (field) {
case 'hostname':
return `${item.hostname} ${item.status}`.toLowerCase()
case 'service':
return (item.service_name ?? '').toLowerCase()
case 'status':
return item.status
default:
@@ -82,7 +95,7 @@ export function certFilterFieldValue(item: Certificate, field: string) {
}
}
function certRelativeBadge(status: string, expiresAt: string | null) {
export function certRelativeBadge(status: string, expiresAt: string | null) {
const relative = formatRelative(expiresAt)
if (!expiresAt) {
return <span className="text-muted-foreground tabular-nums"></span>
@@ -112,6 +125,39 @@ export function useCertificateColumns() {
<span className="truncate font-medium">{row.original.hostname}</span>
),
},
{
id: 'service',
accessorFn: (row) => row.service_name ?? '',
header: ({ column }) => (
<DataGridColumnHeader
column={column}
title="Сервис"
icon={<ServerIcon className="size-3.5" />}
/>
),
cell: ({ row }) => {
const serviceId = row.original.service_id
const name = row.original.service_name
if (serviceId == null || !name) {
return <span className="text-muted-foreground"></span>
}
return (
<Button
variant="link"
className="h-auto max-w-full truncate p-0 font-medium"
nativeButton={false}
render={
<Link
to="/services/$serviceId"
params={{ serviceId: String(serviceId) }}
/>
}
>
{name}
</Button>
)
},
},
{
id: 'status',
header: 'Статус',
@@ -54,6 +54,8 @@ interface HealthCheckBadgeProps {
latencyMs?: number | null
lastCheckedAt?: string | null
lastError?: string | null
colo?: string | null
provider?: 'local' | 'cloudflare' | string | null
title?: string
showLatency?: boolean
size?: 'xs' | 'sm'
@@ -65,6 +67,8 @@ export function HealthCheckBadge({
latencyMs,
lastCheckedAt,
lastError,
colo,
provider,
title,
showLatency = false,
size = 'sm',
@@ -79,6 +83,9 @@ export function HealthCheckBadge({
tooltipParts.push(`Статус: ${label}`)
if (latencyMs != null) tooltipParts.push(`Задержка: ${latencyMs} мс`)
if (lastCheckedAt) tooltipParts.push(`Проверка: ${formatDate(lastCheckedAt)}`)
if (colo) tooltipParts.push(`Colo: ${colo}`)
if (provider === 'cloudflare') tooltipParts.push('Провайдер: Cloudflare Worker')
if (provider === 'local') tooltipParts.push('Провайдер: Local')
if (lastError) tooltipParts.push(`Ошибка: ${lastError}`)
return (
@@ -17,16 +17,22 @@ import {
SelectValue,
} from '@cfdm/ui/components/select'
import { Switch } from '@cfdm/ui/components/switch'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Button } from '@cfdm/ui/components/button'
import { ButtonGroup } from '@cfdm/ui/components/button-group'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Link } from '@tanstack/react-router'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { CableIcon, GlobeIcon } from 'lucide-react'
import { cn } from '@cfdm/ui/lib/utils'
import {
HealthAggregateTiles,
HealthSourceTiles,
type HealthAggregate,
type HealthProvider,
} from '@/components/reui-kit/health-source-tiles'
import { parseHealthProviders } from '@cfdm/shared'
export type LbMode = 'round_robin' | 'failover' | 'weighted'
export type HealthCheckType = 'tcp' | 'http'
export type HealthProvider = 'local' | 'cloudflare'
export type { HealthProvider, HealthAggregate }
export interface HealthCheckConfig {
enabled: boolean
@@ -38,6 +44,8 @@ export interface HealthCheckConfig {
timeout_ms: number
verify_tls: boolean
provider: HealthProvider
providers: HealthProvider[]
aggregate: HealthAggregate
method?: string | null
retries?: number
consecutive_fails?: number
@@ -54,62 +62,6 @@ const defaultLbModeOptions = [
{ value: 'weighted', label: 'Weighted (веса)' },
]
const healthCheckTypes = [
{ value: 'tcp', label: 'TCP connect' },
{ value: 'http', label: 'HTTP' },
] as const
const cloudflareTypes = [
{ value: 'tcp', label: 'TCP' },
{ value: 'http', label: 'HTTP' },
] as const
export function HealthProviderToggle({
value,
onChange,
id,
}: {
value: HealthProvider
onChange: (next: HealthProvider) => void
id?: string
}) {
const provider = value || 'local'
return (
<ButtonGroup className="w-full" id={id}>
<Button
type="button"
size="sm"
className="flex-1"
variant={provider === 'local' ? 'secondary' : 'outline'}
aria-pressed={provider === 'local'}
onClick={() => onChange('local')}
>
Local
</Button>
<Button
type="button"
size="sm"
className="flex-1"
variant={provider === 'cloudflare' ? 'secondary' : 'outline'}
aria-pressed={provider === 'cloudflare'}
onClick={() => onChange('cloudflare')}
>
Cloudflare
</Button>
</ButtonGroup>
)
}
interface HealthCheckConfigFieldsProps {
value: LbAndHealthConfig
onChange: (next: LbAndHealthConfig) => void
lbModeLabel?: string
lbModeOptions?: { value: string; label: string }[]
idPrefix?: string
showLbMode?: boolean
className?: string
}
function CompactNumberField({
id,
value,
@@ -151,11 +103,24 @@ export function HealthCheckConfigFields({
idPrefix = 'health',
showLbMode = true,
className,
}: HealthCheckConfigFieldsProps) {
}: {
value: LbAndHealthConfig
onChange: (next: LbAndHealthConfig) => void
lbModeLabel?: string
lbModeOptions?: { value: string; label: string }[]
idPrefix?: string
showLbMode?: boolean
className?: string
}) {
function patch(next: Partial<LbAndHealthConfig>) {
onChange({ ...value, ...next })
}
const providers = parseHealthProviders(
value.providers,
value.provider ?? 'local',
)
const aggregate = value.aggregate ?? 'majority'
const isHttp = value.type === 'http'
const rowClass = 'gap-3 px-0 py-3'
@@ -190,42 +155,40 @@ export function HealthCheckConfigFields({
<SettingRow
title="Провайдер health-check"
description="Local TCP/HTTP или Cloudflare Health Checks API"
description="Кто пробирует цель. Можно выбрать несколько источников."
labelFor={`${idPrefix}-provider`}
compact
stacked
className={rowClass}
contentClassName="min-w-0"
>
<HealthProviderToggle
id={`${idPrefix}-provider`}
value={value.provider ?? 'local'}
onChange={(provider) =>
<HealthSourceTiles
value={providers}
onChange={(next) =>
patch({
provider,
enabled: provider === 'cloudflare' ? true : value.enabled,
providers: next,
provider: next[0] ?? 'local',
enabled: next.includes('cloudflare') ? true : value.enabled,
})
}
/>
</SettingRow>
{value.provider === 'cloudflare' ? (
<Alert>
<AlertTitle>Cloudflare Health Checks</AlertTitle>
<AlertDescription>
Поля соответствуют официальному API зоны. Если план не позволяет Health
Checks, API вернёт ошибку останется Local. Workers не используются.
</AlertDescription>
</Alert>
) : (
<Alert>
<AlertTitle>Local health-check</AlertTitle>
<AlertDescription>
Интервал и таймаут пробы ниже. Cron и пороги Slow/Down задаются в{' '}
<Link to="/settings/health" className="text-foreground underline">
Настройках Health-check
</Link>
, как параметры Cloudflare Health Checks в этой форме.
</AlertDescription>
</Alert>
)}
{providers.length > 1 ? (
<SettingRow
title="Агрегация"
description="Как свести результаты источников в один статус IP для failover"
compact
stacked
className={rowClass}
contentClassName="min-w-0"
>
<HealthAggregateTiles
value={aggregate}
onChange={(next) => patch({ aggregate: next })}
/>
</SettingRow>
) : null}
<SettingRow
title="Health-check"
@@ -254,25 +217,30 @@ export function HealthCheckConfigFields({
<div className="flex flex-col gap-3 pt-1 pb-1">
<div className="grid grid-cols-2 gap-3">
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
<Select
modal={false}
value={value.type}
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })}
>
<SelectTrigger id={`${idPrefix}-type`} className="w-full">
<SelectValue placeholder="Тип" />
</SelectTrigger>
<SelectContent>
{(value.provider === 'cloudflare'
? cloudflareTypes
: healthCheckTypes
).map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<ButtonGroup id={`${idPrefix}-type`} className="w-full min-w-0">
<Button
type="button"
size="sm"
variant={value.type === 'tcp' ? 'secondary' : 'outline'}
className="flex-1"
aria-pressed={value.type === 'tcp'}
onClick={() => patch({ type: 'tcp' })}
>
<CableIcon data-icon="inline-start" />
TCP
</Button>
<Button
type="button"
size="sm"
variant={value.type === 'http' ? 'secondary' : 'outline'}
className="flex-1"
aria-pressed={value.type === 'http'}
onClick={() => patch({ type: 'http' })}
>
<GlobeIcon data-icon="inline-start" />
HTTP
</Button>
</ButtonGroup>
</FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
@@ -334,83 +302,18 @@ export function HealthCheckConfigFields({
</>
) : null}
<div className="grid grid-cols-2 gap-3">
<FormFieldSimple label="Интервал, сек" htmlFor={`${idPrefix}-interval`}>
<CompactNumberField
id={`${idPrefix}-interval`}
value={value.interval_sec}
min={5}
max={3600}
placeholder="30"
onValueChange={(next) =>
patch({ interval_sec: next ?? 30 })
}
/>
</FormFieldSimple>
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
<CompactNumberField
id={`${idPrefix}-timeout`}
value={value.timeout_ms}
min={100}
max={30000}
placeholder="3000"
onValueChange={(next) =>
patch({ timeout_ms: next ?? 3000 })
}
/>
</FormFieldSimple>
</div>
{value.provider === 'cloudflare' ? (
<div className="grid grid-cols-2 gap-3">
<FormFieldSimple
label="Retries"
htmlFor={`${idPrefix}-retries`}
hint="Cloudflare retries"
>
<CompactNumberField
id={`${idPrefix}-retries`}
value={value.retries ?? 2}
min={0}
max={10}
placeholder="2"
onValueChange={(retries) => patch({ retries: retries ?? 2 })}
/>
</FormFieldSimple>
<FormFieldSimple
label="Successes"
htmlFor={`${idPrefix}-successes`}
hint="consecutive_successes"
>
<CompactNumberField
id={`${idPrefix}-successes`}
value={value.consecutive_successes ?? 2}
min={1}
max={20}
placeholder="2"
onValueChange={(consecutive_successes) =>
patch({ consecutive_successes: consecutive_successes ?? 2 })
}
/>
</FormFieldSimple>
</div>
) : null}
{value.provider === 'cloudflare' && isHttp ? (
<FormFieldSimple label="HTTP method" htmlFor={`${idPrefix}-method`}>
<Select
modal={false}
value={value.method ?? 'GET'}
onValueChange={(v) => patch({ method: v ?? 'GET' })}
>
<SelectTrigger id={`${idPrefix}-method`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="HEAD">HEAD</SelectItem>
</SelectContent>
</Select>
</FormFieldSimple>
) : null}
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
<CompactNumberField
id={`${idPrefix}-timeout`}
value={value.timeout_ms}
min={100}
max={30000}
placeholder="3000"
onValueChange={(next) =>
patch({ timeout_ms: next ?? 3000 })
}
/>
</FormFieldSimple>
</div>
) : null}
</FieldGroup>
@@ -23,19 +23,27 @@ export interface HealthTimelineEvent {
latency_ms?: number | null
error?: string | null
checked_at: string
colo?: string | null
provider?: string | null
}
interface HealthTimelineProps {
events: HealthTimelineEvent[]
emptyTitle?: string
emptyDescription?: string
}
export function HealthTimeline({ events }: HealthTimelineProps) {
export function HealthTimeline({
events,
emptyTitle = 'Нет событий',
emptyDescription = 'Результаты проверок появятся после первого прогона',
}: HealthTimelineProps) {
if (events.length === 0) {
return (
<EmptyState
icon={Link2Icon}
title="Нет событий"
description="Результаты проверок появятся после первого прогона"
title={emptyTitle}
description={emptyDescription}
centered={false}
/>
)
@@ -63,6 +71,8 @@ export function HealthTimeline({ events }: HealthTimelineProps) {
<HealthCheckBadge
status={event.status}
latencyMs={event.latency_ms}
colo={event.colo}
provider={event.provider}
size="xs"
showLatency
/>
+10 -70
View File
@@ -1,5 +1,5 @@
import { Fragment, useMemo } from 'react'
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
import { useMemo } from 'react'
import {
Breadcrumb,
BreadcrumbItem,
@@ -12,75 +12,12 @@ import { Separator } from '@cfdm/ui/components/separator'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
import { getBreadcrumbs } from '@/lib/breadcrumbs'
export interface RouteBreadcrumbLoaderData {
breadcrumb?: string
}
const routeTitles: Record<string, string> = {
'/': 'Панель управления',
'/domains': 'Домены',
'/groups': 'Группы доменов',
'/services': 'Сервисы',
'/certificates': 'Сертификаты',
'/settings/appearance': 'Внешний вид',
'/settings/health': 'Health-check',
'/settings/integrations': 'Интеграции',
}
function getBreadcrumbs(
pathname: string,
dynamicLabels: Record<string, string>,
) {
if (pathname === '/') {
return [{ label: 'Панель управления', href: '/' }]
}
if (pathname.match(/^\/groups\/\d+$/)) {
return [
{ label: 'Группы доменов', href: '/groups' },
{ label: dynamicLabels[pathname] ?? 'Группа', href: pathname },
]
}
if (pathname.match(/^\/domains\/\d+\/dns$/)) {
const domainId = pathname.split('/')[2]
const domainPath = `/domains/${domainId}`
return [
{ label: 'Домены', href: '/domains' },
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
{ label: 'DNS', href: pathname },
]
}
if (pathname.match(/^\/domains\/\d+$/)) {
return [
{ label: 'Домены', href: '/domains' },
{ label: dynamicLabels[pathname] ?? 'Обзор домена', href: pathname },
]
}
if (pathname.startsWith('/settings')) {
return [
{ label: 'Настройки', href: '/settings/appearance' },
...(pathname === '/settings/integrations'
? [{ label: 'Интеграции', href: pathname }]
: pathname === '/settings/health'
? [{ label: 'Health-check', href: pathname }]
: pathname === '/settings/appearance'
? [{ label: 'Внешний вид', href: pathname }]
: []),
]
}
const title = routeTitles[pathname]
if (title) {
return [{ label: title, href: pathname }]
}
return [{ label: 'Панель управления', href: '/' }]
}
function useDynamicBreadcrumbLabels() {
const matches = useMatches()
return useMemo(() => {
@@ -99,7 +36,10 @@ function useDynamicBreadcrumbLabels() {
export function SiteHeader() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const dynamicLabels = useDynamicBreadcrumbLabels()
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
const crumbs = useMemo(
() => getBreadcrumbs(pathname, dynamicLabels),
[pathname, dynamicLabels],
)
return (
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
@@ -110,10 +50,10 @@ export function SiteHeader() {
{crumbs.map((crumb, index) => {
const isLast = index === crumbs.length - 1
return (
<span key={crumb.href} className="contents">
{index > 0 && (
<Fragment key={`${index}-${crumb.href}`}>
{index > 0 ? (
<BreadcrumbSeparator className="hidden md:block" />
)}
) : null}
<BreadcrumbItem
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
>
@@ -125,7 +65,7 @@ export function SiteHeader() {
</BreadcrumbLink>
)}
</BreadcrumbItem>
</span>
</Fragment>
)
})}
</BreadcrumbList>
@@ -0,0 +1,392 @@
import type { KeyboardEvent, ReactNode } from 'react'
import { ServerIcon, LayersIcon, ShieldAlertIcon, ScaleIcon } from 'lucide-react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { Badge } from '@/components/reui/badge'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from '@cfdm/ui/components/item'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { cn } from '@cfdm/ui/lib/utils'
import {
ToggleGroup,
ToggleGroupItem,
} from '@cfdm/ui/components/toggle-group'
import { parseHealthProviders, type HealthCheckAggregate, type HealthCheckProvider } from '@cfdm/shared'
import type { HealthLogStatus } from '@/lib/health-log'
export type HealthProvider = HealthCheckProvider
export type HealthAggregate = HealthCheckAggregate
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
/** Official Cloudflare mark (Simple Icons). */
function CloudflareMark() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727"
/>
</svg>
)
}
/** Official Globalping mark (globalping.io favicon). */
function GlobalpingMark() {
return (
<svg viewBox="0 0 26 26" fill="none" aria-hidden="true">
<path
fill="currentColor"
d="M10.354 6.081a2.636 2.636 0 0 1 4.32.257 31.5 31.5 0 0 1 8.55-1.349A12.97 12.97 0 0 0 3.247 4.42a33.6 33.6 0 0 1 7.107 1.661m13.647.003-.764.02a30.6 30.6 0 0 0-8.235 1.3q.005.08 0 .16c0 .6-.222 1.178-.624 1.624a42 42 0 0 1 2.382 3.695q.478.835.884 1.645h.134c.348-.003.689.102.975.302q3.286-2.798 5.83-7.517l.052-.094m-11.16 2.616a2.65 2.65 0 0 1-2.997-.644c-3.276 1.658-6.702 4.284-9.022 8.45 1.579.6 3.232.982 4.914 1.135a1.566 1.566 0 0 1 2.811.026h.14a16 16 0 0 0 6.935-2.188 1.5 1.5 0 0 1-.097-.52 1.53 1.53 0 0 1 .475-1.105 39 39 0 0 0-3.16-5.154Z"
/>
<path
fill="currentColor"
d="M18.866 24.463c.28-2.216-.22-4.582-1.06-6.825h-.019a1.66 1.66 0 0 1-.812-.211 17 17 0 0 1-7.622 2.467h-.14a1.563 1.563 0 0 1-2.902-.026 19 19 0 0 1-4.94-1.086 13 13 0 0 0 17.479 5.801zm6.324-15.97a26.5 26.5 0 0 1-5.82 7.218 1.4 1.4 0 0 1 .049.374 1.55 1.55 0 0 1-.56 1.18c.907 2.417 1.3 4.644 1.174 6.659a12.98 12.98 0 0 0 5.158-15.431Zm-15.258-.28a2.35 2.35 0 0 1-.045-1.108 33 33 0 0 0-7.46-1.658 12.97 12.97 0 0 0-1.829 11.45c2.106-3.682 5.268-6.627 9.334-8.684"
/>
</svg>
)
}
export const HEALTH_PROVIDER_ITEMS: Array<{
id: HealthProvider
title: string
description: string
icon: ReactNode
iconClassName: string
}> = [
{
id: 'local',
title: 'Local',
description: 'TCP/HTTP с сервера API',
icon: <ServerIcon />,
iconClassName: 'text-info [&_svg]:text-current',
},
{
id: 'cloudflare',
title: 'Cloudflare',
description: 'Worker на edge, KV mailbox',
icon: <CloudflareMark />,
iconClassName: 'text-warning [&_svg]:text-current',
},
{
id: 'globalping',
title: 'Globalping',
description: 'Пробы из сети globalping.io',
icon: <GlobalpingMark />,
iconClassName: 'text-success [&_svg]:text-current',
},
]
const AGGREGATE_ITEMS: Array<{
id: HealthAggregate
title: string
description: string
icon: ReactNode
}> = [
{
id: 'any',
title: 'Any',
description: 'Down, если хотя бы один источник Down',
icon: <ShieldAlertIcon />,
},
{
id: 'all',
title: 'All',
description: 'Down, только если все выбранные Down',
icon: <LayersIcon />,
},
{
id: 'majority',
title: 'Majority',
description: 'Down по большинству (2 → оба, 3 → ≥2)',
icon: <ScaleIcon />,
},
]
function handleTileKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onActivate()
}
}
function ChoicePanel({
selected,
title,
description,
icon,
iconClassName,
role,
trailing,
onActivate,
}: {
selected: boolean
title: string
description: string
icon: ReactNode
iconClassName?: string
role: 'checkbox' | 'radio'
trailing?: ReactNode
onActivate: () => void
}) {
return (
<FramePanel
fit
role={role}
aria-checked={selected}
aria-pressed={selected}
tabIndex={0}
className={cn(
'min-w-0 cursor-pointer transition-colors',
'hover:bg-muted/40 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
selected && 'bg-muted/40',
)}
onClick={onActivate}
onKeyDown={(event) => handleTileKeyDown(onActivate, event)}
>
<Item size="sm" className="w-full min-w-0 border-0 p-0">
<ItemMedia>
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', iconClassName ?? DEFAULT_ICON_CLASS)}
>
{icon}
</IconTile>
</ItemMedia>
<ItemContent className="min-w-0 gap-0.5">
<ItemTitle className="w-full min-w-0">{title}</ItemTitle>
<ItemDescription>{description}</ItemDescription>
</ItemContent>
{trailing ? (
<ItemActions className="shrink-0">{trailing}</ItemActions>
) : null}
</Item>
</FramePanel>
)
}
function ChoiceFrame({ children }: { children: ReactNode }) {
return (
<Frame stacked spacing="sm" className="w-full min-w-0">
{children}
</Frame>
)
}
/**
* Мультивыбор источников проб (Local / Cloudflare / Globalping).
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
*/
export function HealthSourceTiles({
value,
onChange,
}: {
value: HealthProvider[]
onChange: (next: HealthProvider[]) => void
}) {
const selected = parseHealthProviders(value)
function toggle(id: HealthProvider) {
if (selected.includes(id)) {
if (selected.length === 1) return
onChange(selected.filter((item) => item !== id))
return
}
onChange([...selected, id])
}
return (
<ChoiceFrame>
{HEALTH_PROVIDER_ITEMS.map((item) => (
<ChoicePanel
key={item.id}
selected={selected.includes(item.id)}
title={item.title}
description={item.description}
icon={item.icon}
iconClassName={item.iconClassName}
role="checkbox"
trailing={
selected.includes(item.id) ? (
<Badge variant="outline" size="sm">
Выбрано
</Badge>
) : null
}
onActivate={() => toggle(item.id)}
/>
))}
</ChoiceFrame>
)
}
/**
* Правило агрегации (ровно одно): any / all / majority.
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/settings-5
*/
export function HealthAggregateTiles({
value,
onChange,
}: {
value: HealthAggregate
onChange: (next: HealthAggregate) => void
}) {
const selected = value || 'majority'
return (
<ChoiceFrame>
{AGGREGATE_ITEMS.map((item) => (
<ChoicePanel
key={item.id}
selected={selected === item.id}
title={item.title}
description={item.description}
icon={item.icon}
role="radio"
trailing={
selected === item.id ? (
<Badge variant="outline" size="sm">
Выбрано
</Badge>
) : null
}
onActivate={() => onChange(item.id)}
/>
))}
</ChoiceFrame>
)
}
function toggleProviders(
active: HealthProvider[],
id: HealthProvider,
): HealthProvider[] {
if (active.includes(id)) {
if (active.length === 1) return active
return active.filter((item) => item !== id)
}
return [...active, id]
}
/**
* Компактный мультивыбор типа пробы (toolbar в stacked Frame).
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/chart-17
* Docs: https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/badge
*/
export function HealthSourceFilterBar({
enabled,
selected,
statuses,
onChange,
}: {
enabled: HealthProvider[]
selected: HealthProvider[]
statuses: Partial<Record<HealthProvider, HealthLogStatus>>
onChange: (next: HealthProvider[]) => void
}) {
const visible = HEALTH_PROVIDER_ITEMS.filter((item) => enabled.includes(item.id))
if (visible.length === 0) return null
const active = selected.length > 0 ? selected : enabled
return (
<div className="@container min-w-0 w-full">
<ToggleGroup
multiple
variant="outline"
size="sm"
className="flex w-full min-w-0 flex-wrap justify-start"
value={active}
aria-label="Тип пробы"
onValueChange={(next) => {
const values = next.filter((value): value is HealthProvider =>
visible.some((item) => item.id === value),
)
if (values.length === 0) return
onChange(values)
}}
>
{visible.map((item) => (
<ToggleGroupItem
key={item.id}
value={item.id}
aria-label={item.title}
title={item.title}
className="max-w-full min-w-0 flex-none justify-start gap-1.5 @[16rem]:min-w-[8.5rem]"
>
<IconTile
variant="elevated"
size="xs"
className={item.iconClassName}
aria-hidden="true"
>
{item.icon}
</IconTile>
<span className="hidden min-w-0 truncate @[16rem]:inline">
{item.title}
</span>
<HealthCheckBadge
status={statuses[item.id] ?? 'unknown'}
provider={item.id}
size="xs"
/>
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
)
}
/**
* Read-only status tiles for enabled probe sources; click filters the monitor.
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
*/
export function HealthProviderStatusTiles({
enabled,
selected,
statuses,
onChange,
}: {
enabled: HealthProvider[]
selected: HealthProvider[]
statuses: Partial<Record<HealthProvider, HealthLogStatus>>
onChange: (next: HealthProvider[]) => void
}) {
const visible = HEALTH_PROVIDER_ITEMS.filter((item) => enabled.includes(item.id))
if (visible.length === 0) return null
const active = selected.length > 0 ? selected : enabled
return (
<ChoiceFrame>
{visible.map((item) => (
<ChoicePanel
key={item.id}
selected={active.includes(item.id)}
title={item.title}
description={item.description}
icon={item.icon}
iconClassName={item.iconClassName}
role="checkbox"
trailing={
<HealthCheckBadge
status={statuses[item.id] ?? 'unknown'}
provider={item.id}
size="xs"
/>
}
onActivate={() => onChange(toggleProviders(active, item.id))}
/>
))}
</ChoiceFrame>
)
}
+11
View File
@@ -1,3 +1,5 @@
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
export { ServiceHealthMonitor } from './service-health-monitor'
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
@@ -18,3 +20,12 @@ export { OpsDashboard } from './ops-dashboard'
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export {
HealthSourceTiles,
HealthAggregateTiles,
HealthProviderStatusTiles,
HealthSourceFilterBar,
type HealthProvider,
type HealthAggregate,
} from './health-source-tiles'
export { ServiceAddressBlock } from './service-address-block'
@@ -67,7 +67,7 @@ function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return (
<Badge variant="outline" size="sm">
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
{item.hint}
</Badge>
)
@@ -81,30 +81,39 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const valueVariant = item.variant ?? 'default'
return (
<div className="relative z-10 flex h-full items-start gap-3">
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
{item.icon ? (
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null}
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
{item.label}
</div>
{footer ? (
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
{footer}
</div>
) : null}
</div>
<div
className={cn(
'text-2xl leading-none font-bold tabular-nums',
'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
</div>
{footer ? (
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
) : null}
</div>
</div>
)
@@ -116,7 +125,7 @@ function panelClassName(item: KpiStatItem, className?: string) {
const selected = isSelected(item)
return cn(
'relative isolate flex h-full flex-col',
'relative isolate flex h-full min-w-0 flex-col',
clickable &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
selected && 'ring-primary/30 bg-muted/30 ring-1',
@@ -0,0 +1,437 @@
import { useState, type KeyboardEvent } from 'react'
import { PlusIcon, ServerIcon, Trash2Icon } from 'lucide-react'
import { EmptyState } from '@/components/empty-state'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
import { isValidIpv4 } from '@/components/tagged-input'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { parseFqdn } from '@/lib/parse-fqdn'
import {
addAddressNode,
emptyBindingDraft,
removeAddressNode,
withPoolIps,
type AddressBlockState,
type ServiceBindingDraft,
} from '@/lib/service-address'
import { Button } from '@cfdm/ui/components/button'
import { Field, FieldLabel } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from '@cfdm/ui/components/input-group'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@cfdm/ui/components/item'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
/**
* Единый блок адресов сервиса: общий FQDN + пул IP с опциональным доп. доменом.
* Preview: https://reui.io/preview/base/settings-3
* Preview: https://reui.io/preview/base/list-9
* Preview: https://reui.io/preview/base/form-7
* Docs: https://reui.io/docs/components/base/frame
* Docs: https://reui.io/docs/components/base/icon-tile
* Docs: https://reui.io/docs/components/base/badge
*/
export function ServiceAddressBlock({
value,
onChange,
zoneHints,
}: {
value: AddressBlockState
onChange: (next: AddressBlockState) => void
zoneHints: string[]
}) {
const [pendingIp, setPendingIp] = useState('')
const [ipInvalid, setIpInvalid] = useState(false)
const [otherOpen, setOtherOpen] = useState(value.otherBindings.length > 0)
const pool = value.nodes.map((node) => node.ip)
const parsedCommon = parseFqdn(value.commonFqdn, zoneHints)
const showOthers = otherOpen || value.otherBindings.length > 0
const pendingTrimmed = pendingIp.trim()
const pendingInvalid =
ipInvalid && pendingTrimmed.length > 0 && !isValidIpv4(pendingTrimmed)
function handleCommonFqdn(next: string) {
onChange({ ...value, commonFqdn: next })
}
function tryAddIp(raw: string) {
const trimmed = raw.trim()
if (!trimmed) {
setIpInvalid(false)
return
}
if (!isValidIpv4(trimmed) || pool.includes(trimmed)) {
setIpInvalid(true)
return
}
onChange(addAddressNode(value, trimmed))
setPendingIp('')
setIpInvalid(false)
}
function handlePendingKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
event.preventDefault()
tryAddIp(pendingIp)
}
}
function handleNodeFqdn(ip: string, extraFqdn: string) {
onChange({
...value,
nodes: value.nodes.map((node) =>
node.ip === ip ? { ...node, extraFqdn } : node,
),
})
}
function handleRemoveIp(ip: string) {
onChange(removeAddressNode(value, ip))
}
function handleAddOther() {
setOtherOpen(true)
onChange({
...value,
otherBindings: [...value.otherBindings, withPoolIps(emptyBindingDraft(), pool)],
})
}
function handleOtherChange(index: number, next: ServiceBindingDraft) {
onChange({
...value,
otherBindings: value.otherBindings.map((item, i) => (i === index ? next : item)),
})
}
function handleRemoveOther(index: number) {
const otherBindings = value.otherBindings.filter((_, i) => i !== index)
onChange({ ...value, otherBindings })
if (otherBindings.length === 0) setOtherOpen(false)
}
return (
<Frame stacked dense spacing="sm" className="w-full min-w-0">
<FramePanel fit className="flex flex-col gap-3">
<FrameHeader className="px-0 pt-0">
<FrameTitle>Адреса</FrameTitle>
<FrameDescription>
Общий FQDN на весь пул · у каждого IP свой доп. домен
</FrameDescription>
</FrameHeader>
<Field>
<FieldLabel htmlFor="service-common-fqdn">Общий домен (FQDN)</FieldLabel>
<InputGroup>
<InputGroupInput
id="service-common-fqdn"
className="font-mono"
value={value.commonFqdn}
placeholder={zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'}
onChange={(event) => handleCommonFqdn(event.target.value)}
/>
{parsedCommon ? (
<InputGroupAddon align="inline-end">
<Badge variant="outline" size="xs" className="font-mono">
{parsedCommon.zoneName}
</Badge>
</InputGroupAddon>
) : value.commonFqdn.trim() ? (
<InputGroupAddon align="inline-end">
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
</InputGroupAddon>
) : null}
</InputGroup>
</Field>
</FramePanel>
<FramePanel fit className="flex flex-col gap-3">
<FrameHeader className="px-0 pt-0">
<FrameTitle>IP-адреса</FrameTitle>
</FrameHeader>
{value.nodes.length === 0 ? (
<EmptyState
icon={ServerIcon}
title="Добавьте IP пула"
description="IPv4 сервиса. Для каждого адреса можно указать доп. FQDN."
stackedIcon={false}
centered={false}
/>
) : (
<ItemGroup className="gap-2">
{value.nodes.map((node) => {
const parsedExtra = parseFqdn(node.extraFqdn, zoneHints)
return (
<Item
key={node.ip}
variant="outline"
size="sm"
className="items-stretch"
>
<ItemMedia>
<IconTile
variant="elevated"
size="xs"
className="text-info"
aria-hidden="true"
>
<ServerIcon />
</IconTile>
</ItemMedia>
<ItemContent className="flex min-w-0 flex-col gap-2">
<div className="flex items-center gap-2">
<ItemTitle className="font-mono">{node.ip}</ItemTitle>
<ItemActions className="ml-auto shrink-0">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Удалить ${node.ip}`}
onClick={() => handleRemoveIp(node.ip)}
>
<Trash2Icon />
</Button>
</ItemActions>
</div>
<Field className="gap-1.5">
<FieldLabel
htmlFor={`service-ip-extra-${node.ip}`}
className="text-muted-foreground text-xs"
>
Доп. FQDN
</FieldLabel>
<InputGroup>
<InputGroupInput
id={`service-ip-extra-${node.ip}`}
className="font-mono"
value={node.extraFqdn}
placeholder={
zoneHints[0]
? `необязательно · spb.${zoneHints[0]}`
: 'необязательно · spb.example.com'
}
onChange={(event) =>
handleNodeFqdn(node.ip, event.target.value)
}
/>
{parsedExtra ? (
<InputGroupAddon align="inline-end">
<Badge variant="outline" size="xs" className="font-mono">
{parsedExtra.zoneName}
</Badge>
</InputGroupAddon>
) : node.extraFqdn.trim() ? (
<InputGroupAddon align="inline-end">
<Badge variant="warning-light" size="xs">
зона не найдена
</Badge>
</InputGroupAddon>
) : null}
</InputGroup>
</Field>
</ItemContent>
</Item>
)
})}
</ItemGroup>
)}
<InputGroup>
<InputGroupInput
id="service-pool-ip-add"
className="font-mono"
value={pendingIp}
placeholder="192.168.1.1"
aria-invalid={pendingInvalid || undefined}
onChange={(event) => {
setPendingIp(event.target.value)
setIpInvalid(false)
}}
onKeyDown={handlePendingKeyDown}
onBlur={() => tryAddIp(pendingIp)}
/>
<InputGroupAddon align="inline-end">
<InputGroupButton size="sm" onClick={() => tryAddIp(pendingIp)}>
Добавить
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
{showOthers ? null : (
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleAddOther}
>
<PlusIcon data-icon="inline-start" />
Другой FQDN
</Button>
</div>
)}
</FramePanel>
{showOthers ? (
<FramePanel fit className="flex flex-col gap-3">
<FrameHeader className="flex flex-row items-start justify-between gap-2 px-0 pt-0">
<div className="flex min-w-0 flex-col gap-1">
<FrameTitle>Другие FQDN</FrameTitle>
<FrameDescription>CNAME и A не 1:1 с IP пула</FrameDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={handleAddOther}>
<PlusIcon data-icon="inline-start" />
Добавить
</Button>
</FrameHeader>
{value.otherBindings.length === 0 ? (
<p className="text-muted-foreground text-sm">Нет дополнительных FQDN</p>
) : (
<ItemGroup className="gap-2">
{value.otherBindings.map((binding, index) => {
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
return (
<Item
key={`other-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={() => handleRemoveOther(index)}
>
<Trash2Icon />
</Button>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_7.5rem]">
<Input
id={`other-fqdn-${index}`}
className="font-mono"
value={binding.fqdn}
onChange={(event) =>
handleOtherChange(index, {
...binding,
fqdn: 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={(next) => {
const recordType = (next ?? 'A') as 'A' | 'CNAME'
handleOtherChange(index, {
...binding,
record_type: recordType,
target_ips: recordType === 'A' ? binding.target_ips : [],
target_cname:
recordType === 'CNAME' ? binding.target_cname : '',
})
}}
>
<SelectTrigger id={`other-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={`other-cname-${index}`}
value={binding.target_cname}
placeholder="mmsk.rkns.top"
onChange={(event) =>
handleOtherChange(index, {
...binding,
target_cname: event.target.value,
})
}
/>
) : (
<ServiceBindingIpInput
id={`other-ip-${index}`}
value={binding.target_ips}
pool={pool}
onChange={(targetIps) =>
handleOtherChange(index, {
...binding,
target_ips: targetIps,
target_ip_weights: Object.fromEntries(
targetIps.map((ip) => [
ip,
binding.target_ip_weights[ip] ?? 1,
]),
),
target_ip_priorities: Object.fromEntries(
targetIps.map((ip) => [
ip,
binding.target_ip_priorities[ip] ?? 1,
]),
),
})
}
/>
)}
</ItemContent>
</Item>
)
})}
</ItemGroup>
)}
</FramePanel>
) : null}
</Frame>
)
}
@@ -0,0 +1,120 @@
import { useMemo, useState } from 'react'
import { HealthTimeline } from '@/components/health/health-timeline'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { UptimeChart, UPTIME_PERIODS, type UptimePeriodKey } from '@/components/reui-kit/uptime-chart'
import { HealthSourceFilterBar } from '@/components/reui-kit/health-source-tiles'
import {
collapseStatusChanges,
filterByPeriod,
filterByProviders,
type HealthLogProbe,
type HealthLogStatus,
} from '@/lib/health-log'
import type { HealthCheckProvider } from '@cfdm/shared'
/**
* Единый блок мониторинга: компактный мультивыбор типа пробы (list-9 / ToggleGroup)
* + график (chart-17) + таймлайн смен статуса.
*
* Preview: https://reui.io/preview/base/list-9
* Preview: https://reui.io/preview/base/chart-17
* Preview: https://reui.io/preview/base/solution-ai-ops-1
* Docs: https://reui.io/docs/components/base/frame
* Docs: https://reui.io/docs/components/base/icon-tile
* Docs: https://reui.io/docs/components/base/timeline
* Docs: https://reui.io/docs/components/base/badge
*/
export function ServiceHealthMonitor({
items,
enabledProviders,
statuses,
isLoading = false,
}: {
items: HealthLogProbe[]
enabledProviders: readonly HealthCheckProvider[]
statuses: Partial<Record<HealthCheckProvider, HealthLogStatus>>
isLoading?: boolean
}) {
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
const [selected, setSelected] = useState<HealthCheckProvider[] | null>(null)
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
const enabled = useMemo(
() => [...enabledProviders],
[enabledProviders],
)
const activeProviders = useMemo((): HealthCheckProvider[] => {
const picked = (selected ?? enabled).filter((provider) =>
enabled.includes(provider),
)
return picked.length > 0 ? picked : enabled
}, [enabled, selected])
const periodItems = useMemo(
() => filterByPeriod(items, days),
[items, days],
)
const filtered = useMemo(
() => filterByProviders(periodItems, activeProviders),
[periodItems, activeProviders],
)
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
return (
<Frame stacked spacing="sm" className="min-w-0 w-full">
<FramePanel>
<HealthSourceFilterBar
enabled={enabled}
selected={activeProviders}
statuses={statuses}
onChange={setSelected}
/>
</FramePanel>
<UptimeChart
items={filtered}
isLoading={isLoading}
period={period}
onPeriodChange={setPeriod}
skipPeriodFilter
embedded
hideHeader
/>
<FramePanel className="flex flex-col gap-3">
<FrameHeader className="px-0 py-0">
<FrameTitle>Смены статуса</FrameTitle>
<FrameDescription>
Только переходы up / degraded / down · Cloudflare = Worker, не Health
Checks API
</FrameDescription>
</FrameHeader>
<HealthTimeline
events={changes.map((row) => ({
id: row.id,
hostname: row.ip,
type: row.provider,
status: row.status,
latency_ms: row.latency_ms,
error: row.error,
checked_at: row.checked_at,
colo: row.colo,
provider: row.provider,
}))}
emptyTitle="Нет смен статуса"
emptyDescription="События появятся при переходе up / degraded / down"
/>
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { toAlignedSeries, type UptimeProbe } from './uptime-chart'
function probe(
overrides: Partial<UptimeProbe> & Pick<UptimeProbe, 'id'>,
): UptimeProbe {
return {
status: 'up',
ok: true,
latency_ms: 10,
checked_at: '2026-01-01T00:00:00.000Z',
provider: 'local',
...overrides,
}
}
describe('toAlignedSeries', () => {
it('puts mixed-source probes in one 60s bucket instead of a sawtooth series', () => {
const { points, keys } = toAlignedSeries([
probe({
id: 1,
provider: 'local',
latency_ms: 4,
checked_at: '2026-01-01T00:00:10.000Z',
}),
probe({
id: 2,
provider: 'cloudflare',
latency_ms: 284,
checked_at: '2026-01-01T00:00:12.000Z',
}),
probe({
id: 3,
provider: 'globalping',
latency_ms: 38,
checked_at: '2026-01-01T00:00:40.000Z',
}),
])
expect(points).toHaveLength(1)
expect(points[0]?.local).toBe(4)
expect(points[0]?.cloudflare).toBe(284)
expect(points[0]?.globalping).toBe(38)
expect(keys).toEqual(['local', 'cloudflare', 'globalping'])
})
it('does not plot down probes as latency 0', () => {
const { points } = toAlignedSeries([
probe({
id: 1,
status: 'down',
ok: false,
latency_ms: 12,
provider: 'local',
}),
])
expect(points).toHaveLength(1)
expect(points[0]?.local).toBeNull()
expect(points[0]?.localOk).toBe(false)
expect(points[0]?.ok).toBe(false)
})
it('splits probes that fall into adjacent minutes', () => {
const { points } = toAlignedSeries([
probe({ id: 1, latency_ms: 10, checked_at: '2026-01-01T00:00:50.000Z' }),
probe({ id: 2, latency_ms: 20, checked_at: '2026-01-01T00:01:10.000Z' }),
])
expect(points).toHaveLength(2)
expect(points[0]?.local).toBe(10)
expect(points[1]?.local).toBe(20)
})
})
@@ -0,0 +1,479 @@
import { useId, useMemo, useState } from 'react'
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
import { Area, ComposedChart, Line, XAxis, YAxis } from 'recharts'
import { EmptyState } from '@/components/empty-state'
import { Badge } from '@/components/reui/badge'
import { Frame, FramePanel } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { filterByPeriod, probeTime } from '@/lib/health-log'
import { formatDate } from '@/lib/format'
import { Button } from '@cfdm/ui/components/button'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@cfdm/ui/components/chart'
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import type { HealthCheckProvider } from '@cfdm/shared'
/**
* Uptime monitoring card chart-17 DNA (Frame + value + AreaChart + period tabs).
* Preview: https://reui.io/preview/base/chart-17
* Frame: https://reui.io/docs/components/base/frame
* Chart: shadcn Chart + Recharts ComposedChart
*/
export interface UptimeProbe {
id: number
status: 'up' | 'down' | 'degraded' | 'unknown'
ok: boolean
latency_ms: number | null
checked_at: string
provider?: HealthCheckProvider
}
export type UptimePeriodKey = '5D' | '2W' | '1M'
export const UPTIME_PERIODS: { key: UptimePeriodKey; label: string; days: number }[] = [
{ key: '5D', label: '5D', days: 5 },
{ key: '2W', label: '2W', days: 14 },
{ key: '1M', label: '1M', days: 30 },
]
export const UPTIME_BUCKET_MS = 60_000
export const UPTIME_PROVIDER_KEYS = ['local', 'cloudflare', 'globalping'] as const
export type UptimeProviderKey = (typeof UPTIME_PROVIDER_KEYS)[number]
const chartConfig = {
local: {
label: 'Local',
color: 'var(--info)',
},
cloudflare: {
label: 'Cloudflare',
color: 'var(--warning)',
},
globalping: {
label: 'Globalping',
color: 'var(--success)',
},
} satisfies ChartConfig
export interface AlignedChartPoint {
period: string
at: string
ok: boolean
local?: number | null
cloudflare?: number | null
globalping?: number | null
localOk?: boolean
cloudflareOk?: boolean
globalpingOk?: boolean
}
function isProviderKey(value: string | undefined): value is UptimeProviderKey {
return value === 'local' || value === 'cloudflare' || value === 'globalping'
}
function bucketStart(time: number): number {
return Math.floor(time / UPTIME_BUCKET_MS) * UPTIME_BUCKET_MS
}
function providerOf(item: UptimeProbe): UptimeProviderKey {
return isProviderKey(item.provider) ? item.provider : 'local'
}
function probeOk(item: UptimeProbe): boolean {
return item.ok && item.status !== 'down'
}
/** Align mixed-source probes onto a 60s time axis so Local/CF/GP do not zigzag. */
export function toAlignedSeries(items: UptimeProbe[]): {
points: AlignedChartPoint[]
keys: UptimeProviderKey[]
} {
const buckets = new Map<number, AlignedChartPoint>()
const used = new Set<UptimeProviderKey>()
const sorted = [...items].sort(
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
)
for (const item of sorted) {
const key = providerOf(item)
used.add(key)
const start = bucketStart(probeTime(item.checked_at))
let row = buckets.get(start)
if (!row) {
row = {
period: formatDate(item.checked_at),
at: item.checked_at,
ok: true,
}
buckets.set(start, row)
}
const ok = probeOk(item)
row[`${key}Ok`] = ok
row[key] = ok ? item.latency_ms : null
}
const points = [...buckets.entries()]
.sort((a, b) => a[0] - b[0])
.map(([, row]) => {
const present = UPTIME_PROVIDER_KEYS.filter((key) => row[`${key}Ok`] !== undefined)
return {
...row,
ok: present.length === 0 ? row.ok : present.every((key) => row[`${key}Ok`] !== false),
}
})
const keys = UPTIME_PROVIDER_KEYS.filter((key) => used.has(key))
return { points, keys }
}
function uptimePercent(points: AlignedChartPoint[]): number | null {
if (points.length === 0) return null
const okCount = points.filter((point) => point.ok).length
return (okCount / points.length) * 100
}
function deltaPercent(points: AlignedChartPoint[]): number | null {
if (points.length < 4) return null
const mid = Math.floor(points.length / 2)
const prev = uptimePercent(points.slice(0, mid))
const next = uptimePercent(points.slice(mid))
if (prev == null || next == null) return null
return next - prev
}
function UptimeDelta({ delta }: { delta: number }) {
if (Math.abs(delta) < 0.05) {
return <span className="text-muted-foreground">без изменений за период</span>
}
if (delta > 0) {
return (
<>
<TrendingUpIcon className="text-success size-4" aria-hidden="true" />
<span className="text-success font-medium">+{delta.toFixed(1)} п.п.</span>
<span className="text-muted-foreground">с начала периода</span>
</>
)
}
return (
<>
<TrendingDownIcon className="text-destructive size-4" aria-hidden="true" />
<span className="text-destructive font-medium">{delta.toFixed(1)} п.п.</span>
<span className="text-muted-foreground">с начала периода</span>
</>
)
}
export function probeUptimePercent(items: UptimeProbe[]): number | null {
return uptimePercent(toAlignedSeries(items).points)
}
export function lastProbeLatency(items: UptimeProbe[]): number | null {
if (items.length === 0) return null
const latest = [...items].sort(
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at),
)[0]
return latest?.latency_ms ?? null
}
function formatUptime(value: number | null): string {
if (value == null) return '—'
return `${value.toFixed(value >= 99.95 ? 2 : 1)}%`
}
function formatPing(value: unknown, ok: boolean | undefined): string {
if (ok === false) return '—'
const ping = typeof value === 'number' ? value : Number(value)
return Number.isFinite(ping) ? `${ping} мс` : '—'
}
interface UptimeChartProps {
items: UptimeProbe[]
isLoading?: boolean
period?: UptimePeriodKey
onPeriodChange?: (period: UptimePeriodKey) => void
skipPeriodFilter?: boolean
embedded?: boolean
/** dashboard-4: chrome живёт в родительском FrameHeader (переключатель серий). */
hideHeader?: boolean
}
export function UptimeChart({
items,
isLoading = false,
period: periodProp,
onPeriodChange,
skipPeriodFilter = false,
embedded = false,
hideHeader = false,
}: UptimeChartProps) {
const gradientId = useId().replace(/:/g, '')
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
const [hovered, setHovered] = useState<AlignedChartPoint | null>(null)
const period = periodProp ?? internalPeriod
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
function handlePeriodChange(next: UptimePeriodKey) {
onPeriodChange?.(next)
if (periodProp == null) setInternalPeriod(next)
setHovered(null)
}
const { points, keys } = useMemo(
() => toAlignedSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
[items, days, skipPeriodFilter],
)
const uptime = uptimePercent(points)
const delta = deltaPercent(points)
const lastOk = points.at(-1)?.ok ?? true
const tileClass = lastOk ? 'text-success' : 'text-destructive'
const single = keys.length <= 1
const areaKey = keys[0] ?? 'local'
const hoverPings = hovered
? keys.map((key) => {
const ok = hovered[`${key}Ok`]
const label = chartConfig[key].label
return `${label} ${formatPing(hovered[key], ok)}`
})
: []
function syncHover(state: {
activeTooltipIndex?: unknown
activeIndex?: unknown
}) {
const index = Number(state.activeTooltipIndex ?? state.activeIndex)
if (!Number.isFinite(index)) return
setHovered(points[index] ?? null)
}
const panel = (
<FramePanel className="flex flex-col gap-6 overflow-visible">
{hideHeader ? null : (
<div className="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
<div className="flex items-center gap-2.5">
<IconTile
variant="elevated"
className={`size-10.5 ${tileClass}`}
aria-hidden="true"
>
<ActivityIcon />
</IconTile>
<div className="flex flex-col justify-center">
<h3 className="text-base font-semibold">Uptime</h3>
<p className="text-muted-foreground text-sm">
Пробы health-check за период
</p>
</div>
</div>
<TooltipProvider delay={150}>
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label="О графике uptime"
className="text-muted-foreground/70 -mr-1"
size="icon-sm"
type="button"
variant="ghost"
/>
}
>
<InfoIcon data-icon="inline-start" aria-hidden="true" />
</TooltipTrigger>
<TooltipContent side="top" sideOffset={8}>
<p>Доля успешных проб и задержка (мс) по журналу health-log.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{isLoading ? (
<div className="bg-muted h-40 w-full animate-pulse rounded-xl" />
) : points.length === 0 ? (
<EmptyState
icon={ActivityIcon}
title="Нет проб за период"
description="Результаты появятся после health-check"
stackedIcon={false}
centered={false}
/>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<div className="text-foreground text-3xl font-semibold tabular-nums">
{formatUptime(uptime)}
</div>
<div className="flex items-center gap-2 text-sm">
{delta == null ? (
<Badge variant="outline" size="sm">
{points.length} проб
</Badge>
) : (
<UptimeDelta delta={delta} />
)}
</div>
</div>
{hoverPings.length > 0 ? (
<p className="text-muted-foreground min-h-4 min-w-0 text-xs tabular-nums">
<span className="text-foreground font-medium">Пинг</span>
{' · '}
{formatDate(hovered?.at)}
{' · '}
{hoverPings.join(' · ')}
</p>
) : (
<p className="text-muted-foreground min-h-4 text-xs">
Наведите на точку графика
</p>
)}
<div className="h-40 w-full overflow-visible">
<ChartContainer
config={chartConfig}
className="[&_.recharts-tooltip-wrapper]:z-50 [&_.recharts-wrapper]:overflow-visible h-full w-full overflow-visible rounded-b-xl"
initialDimension={{ width: 320, height: 160 }}
>
<ComposedChart
data={points}
margin={{ top: 24, left: 8, right: 8, bottom: 8 }}
accessibilityLayer
onMouseMove={syncHover}
onMouseLeave={() => setHovered(null)}
onClick={syncHover}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor={`var(--color-${areaKey})`}
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor={`var(--color-${areaKey})`}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<XAxis dataKey="at" hide />
<YAxis hide domain={['auto', 'auto']} />
<ChartTooltip
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
filterNull={false}
shared
isAnimationActive={false}
allowEscapeViewBox={{ x: true, y: true }}
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
content={
<ChartTooltipContent
labelFormatter={(_label, payload) => {
const at = (payload?.[0]?.payload as AlignedChartPoint | undefined)?.at
return at ? formatDate(at) : String(_label ?? '')
}}
formatter={(value, name, item) => {
const key = String(name)
const row = item.payload as AlignedChartPoint | undefined
const ok =
key === 'local' || key === 'cloudflare' || key === 'globalping'
? row?.[`${key}Ok`]
: row?.ok
const label = chartConfig[key as UptimeProviderKey]?.label ?? key
return (
<div className="flex flex-1 items-center justify-between gap-4">
<span className="text-muted-foreground">
{ok === false ? `${label} · Down` : `Пинг · ${label}`}
</span>
<span className="text-foreground font-mono font-medium tabular-nums">
{formatPing(value, ok)}
</span>
</div>
)
}}
/>
}
/>
{single ? (
<Area
dataKey={areaKey}
name={areaKey}
type="monotone"
fill={`url(#${gradientId})`}
stroke={`var(--color-${areaKey})`}
strokeWidth={2}
connectNulls={false}
isAnimationActive={false}
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
activeDot={{
r: 6,
stroke: 'var(--background)',
strokeWidth: 2,
}}
/>
) : (
keys.map((key) => (
<Line
key={key}
dataKey={key}
name={key}
type="monotone"
stroke={`var(--color-${key})`}
strokeWidth={2}
connectNulls={false}
isAnimationActive={false}
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
activeDot={{
r: 6,
stroke: 'var(--background)',
strokeWidth: 2,
}}
/>
))
)}
</ComposedChart>
</ChartContainer>
</div>
</div>
)}
<Tabs
value={period}
onValueChange={(value) => handlePeriodChange(value as UptimePeriodKey)}
>
<TabsList className="w-full">
{UPTIME_PERIODS.map((entry) => (
<TabsTrigger key={entry.key} value={entry.key} className="flex-1">
{entry.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</FramePanel>
)
if (embedded) return panel
return (
<Frame spacing="sm" className="min-w-0 w-full">
{panel}
</Frame>
)
}
+59 -407
View File
@@ -1,13 +1,10 @@
import { useEffect, useMemo, useState } from 'react'
import { PlusIcon, Trash2Icon } from 'lucide-react'
import { Trash2Icon } from 'lucide-react'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
import { ServiceAddressBlock } from '@/components/reui-kit/service-address-block'
import {
HealthCheckConfigFields,
type LbAndHealthConfig,
type LbMode,
type HealthCheckType,
} from '@/components/health-check-config-fields'
import type {
CreateServiceWithConfigInput,
@@ -16,8 +13,16 @@ import type {
ServiceView,
UpdateServiceConfigInput,
} from '@/lib/schemas'
import { bindingToFqdn, parseFqdn } from '@/lib/parse-fqdn'
import { Badge } from '@/components/reui/badge'
import {
DEFAULT_BINDING_HEALTH,
emptyAddressBlock,
hydrateAddressBlock,
toBindingDrafts,
toDomainsPayload,
type AddressBlockState,
type BindingHealthConfig,
type ServiceBindingDraft,
} from '@/lib/service-address'
import { toast } from 'sonner'
import {
Sheet,
@@ -27,14 +32,8 @@ import {
SheetHeader,
SheetTitle,
} from '@cfdm/ui/components/sheet'
import { Button } from '@cfdm/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import {
Item,
ItemContent,
ItemGroup,
} from '@cfdm/ui/components/item'
import { LoadingButton } from '@/components/loading-button'
import {
Select,
@@ -44,40 +43,7 @@ import {
SelectValue,
} from '@cfdm/ui/components/select'
interface BindingHealthConfig {
enabled: boolean
type: HealthCheckType
port: number | null
path: string | null
expected_status: number | null
interval_sec: number
timeout_ms: number
verify_tls: boolean
provider: 'local' | 'cloudflare'
}
export interface ServiceBindingDraft {
fqdn: string
record_type: 'A' | 'CNAME'
target_ips: string[]
target_cname: string
lb_mode: LbMode
health: BindingHealthConfig
target_ip_weights: Record<string, number>
target_ip_priorities: Record<string, number>
}
const defaultHealth: BindingHealthConfig = {
enabled: false,
type: 'tcp',
port: null,
path: null,
expected_status: null,
interval_sec: 30,
timeout_ms: 3000,
verify_tls: false,
provider: 'local',
}
export type { ServiceBindingDraft }
interface ServiceEditSheetProps {
mode: 'create' | 'edit'
@@ -94,93 +60,19 @@ interface ServiceEditSheetProps {
onDelete?: (id: number) => void
}
function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
return (service.domains ?? []).map((binding) => ({
fqdn: bindingToFqdn(binding),
record_type: binding.record_type ?? (binding.target_cname ? 'CNAME' : 'A'),
target_ips: binding.target_ips ?? [],
target_cname: binding.target_cname ?? '',
lb_mode: binding.lb_mode,
health: {
enabled: binding.health_check_enabled,
type: binding.health_check_type === 'http' ? 'http' : 'tcp',
port: binding.health_check_port,
path: binding.health_check_path,
expected_status: binding.health_check_expected_status,
interval_sec: binding.health_check_interval_sec,
timeout_ms: binding.health_check_timeout_ms,
verify_tls: binding.health_check_verify_tls ?? false,
provider: 'local',
},
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
}))
}
function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
return bindings
.filter((binding) => {
if (!binding.fqdn.trim()) return false
if (binding.record_type === 'CNAME') return Boolean(binding.target_cname.trim())
return binding.target_ips.length > 0
})
.map((binding) =>
binding.record_type === 'CNAME'
? {
fqdn: binding.fqdn.trim(),
target_cname: binding.target_cname.trim(),
lb_mode: binding.lb_mode,
health_check_enabled: binding.health.enabled,
health_check_type: binding.health.type,
health_check_port: binding.health.port,
health_check_path: binding.health.path,
health_check_expected_status: binding.health.expected_status,
health_check_interval_sec: binding.health.interval_sec,
health_check_timeout_ms: binding.health.timeout_ms,
health_check_verify_tls: binding.health.verify_tls,
}
: {
fqdn: binding.fqdn.trim(),
target_ips: binding.target_ips,
target_ip_weights: binding.target_ip_weights,
target_ip_priorities: binding.target_ip_priorities,
lb_mode: binding.lb_mode,
health_check_enabled: binding.health.enabled,
health_check_type: binding.health.type,
health_check_port: binding.health.port,
health_check_path: binding.health.path,
health_check_expected_status: binding.health.expected_status,
health_check_interval_sec: binding.health.interval_sec,
health_check_timeout_ms: binding.health.timeout_ms,
health_check_verify_tls: binding.health.verify_tls,
},
)
}
function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
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]),
),
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,
providers: next.providers,
aggregate: next.aggregate,
}
}
@@ -201,9 +93,11 @@ export function ServiceEditSheet({
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [serviceGroupId, setServiceGroupId] = useState('none')
const [ips, setIps] = useState<string[]>([])
const [commonFqdn, setCommonFqdn] = useState('')
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
const [address, setAddress] = useState<AddressBlockState>(() => emptyAddressBlock())
const [health, setHealth] = useState<BindingHealthConfig>(() => ({
...DEFAULT_BINDING_HEALTH,
}))
const [lbMode, setLbMode] = useState<LbAndHealthConfig['lb_mode']>('round_robin')
const [lbWeight, setLbWeight] = useState(1)
const [lbPriority, setLbPriority] = useState(1)
@@ -215,6 +109,8 @@ export function ServiceEditSheet({
[groups],
)
// Reset only when the sheet opens or the service id changes.
// Health polling replaces `service` by identity and would wipe unsaved settings.
useEffect(() => {
if (!open) return
if (mode === 'edit' && service) {
@@ -223,10 +119,10 @@ export function ServiceEditSheet({
setServiceGroupId(
service.service_group_id != null ? String(service.service_group_id) : 'none',
)
setIps(service.ips ?? [])
const drafts = toBindingDrafts(service)
setBindings(drafts)
setCommonFqdn(drafts[0]?.fqdn ?? '')
setAddress(hydrateAddressBlock(drafts, service.ips ?? []))
setHealth(drafts[0]?.health ?? { ...DEFAULT_BINDING_HEALTH })
setLbMode(drafts[0]?.lb_mode ?? service.lb_mode ?? 'round_robin')
setLbWeight(service.lb_weight ?? 1)
setLbPriority(service.lb_priority ?? 1)
return
@@ -237,147 +133,40 @@ export function ServiceEditSheet({
setServiceGroupId(
defaultGroupId != null ? String(defaultGroupId) : 'none',
)
setIps([])
setCommonFqdn('')
setBindings([])
setAddress(emptyAddressBlock())
setHealth({ ...DEFAULT_BINDING_HEALTH })
setLbMode('round_robin')
setLbWeight(1)
setLbPriority(1)
}
}, [open, mode, service, defaultGroupId])
}, [open, mode, service?.id, defaultGroupId])
const zoneHints = useMemo(
() => knownDomains.map((domain) => domain.zone_name),
[knownDomains],
)
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 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))
}
function handleFqdnChange(index: number, fqdn: string) {
setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
)
}
function handleRecordTypeChange(index: number, recordType: 'A' | 'CNAME') {
setBindings((current) =>
current.map((item, i) =>
i === index
? {
...item,
record_type: recordType,
target_ips: recordType === 'A' ? item.target_ips : [],
target_cname: recordType === 'CNAME' ? item.target_cname : '',
}
: item,
),
)
}
function handleCnameChange(index: number, value: string) {
setBindings((current) =>
current.map((item, i) => (i === index ? { ...item, target_cname: value } : item)),
)
}
function handleIpsChange(index: number, targetIps: string[]) {
setBindings((current) =>
current.map((item, i) =>
i === index
? {
...item,
target_ips: targetIps,
target_ip_weights: Object.fromEntries(
targetIps.map((ip) => [ip, item.target_ip_weights[ip] ?? 1]),
),
target_ip_priorities: Object.fromEntries(
targetIps.map((ip) => [ip, item.target_ip_priorities[ip] ?? 1]),
),
}
: item,
),
)
}
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 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,
)
})
setLbMode(next.lb_mode)
setHealth(healthFromConfig(next))
}
const primaryHealthValue: LbAndHealthConfig = {
lb_mode: bindings[0]?.lb_mode ?? 'round_robin',
...(bindings[0]?.health ?? defaultHealth),
lb_mode: lbMode,
...health,
}
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 syncedBindings = syncCommonDomain(bindings)
const domains = buildDomainsPayload(syncedBindings)
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
const ips = address.nodes.map((node) => node.ip)
const domains = toDomainsPayload(address, {
lb_mode: lbMode,
health,
})
const normalizedFqdns = domains.map((item) => item.fqdn.trim().toLowerCase())
const hasDuplicateFqdn =
new Set(normalizedFqdns).size !== normalizedFqdns.length
if (hasDuplicateFqdn) {
@@ -421,6 +210,7 @@ export function ServiceEditSheet({
const canSubmit = isCreate
? name.trim().length > 0 && slug.trim().length > 0
: Boolean(service)
const addressResetKey = `${mode}-${service?.id ?? 'new'}-${open ? 'open' : 'closed'}`
return (
<Sheet open={open} onOpenChange={onOpenChange}>
@@ -428,8 +218,8 @@ export function ServiceEditSheet({
<SheetHeader className="shrink-0 border-b pb-4">
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
<SheetDescription>
Общий домен и IP задаются у сервиса. Дополнительные FQDN ниже, зона
определяется автоматически.
Общий домен и пул IP в одном блоке. У каждого адреса можно указать
свой доп. FQDN.
</SheetDescription>
</SheetHeader>
@@ -476,33 +266,16 @@ export function ServiceEditSheet({
</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)}
/>
</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>
<ServiceAddressBlock
key={addressResetKey}
value={address}
onChange={setAddress}
zoneHints={zoneHints}
/>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Health check</h3>
<HealthCheckConfigFields
@@ -511,127 +284,6 @@ export function ServiceEditSheet({
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">
@@ -0,0 +1,793 @@
import { useMemo, useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { ColumnDef } from '@tanstack/react-table'
import {
GlobeIcon,
NetworkIcon,
PlusIcon,
SearchIcon,
ServerIcon,
ShieldCheckIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { IconTile } from '@/components/reui/icon-tile'
import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
import { ResourcePage } from '@/components/reui-kit'
import { certRelativeBadge } from '@/components/columns/certificates-columns'
import { certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatDate } from '@/lib/format'
import type { ServiceCertificateRow, ServiceView } from '@/lib/schemas'
import type { CertMonitoring } from '@cfdm/shared'
import {
certKeys,
checkServiceCertificates,
patchBindingCertMonitoring,
serviceCertificatesQueryOptions,
} from '@/queries'
import { Button } from '@cfdm/ui/components/button'
import { Switch } from '@cfdm/ui/components/switch'
import {
ToggleGroup,
ToggleGroupItem,
} from '@cfdm/ui/components/toggle-group'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
type HealthStatus = 'up' | 'down' | 'degraded' | 'unknown'
interface ServiceIpRow {
id: string
ip: string
status: HealthStatus
enabled: boolean
active: boolean
weight: number
priority: number
latency_ms: number | null
last_checked_at: string | null
last_error: string | null
colo: string | null
provider: string | null
}
export interface ServiceFqdnRow {
id: string
fqdn: string
zone_name: string
target_ips: string[]
binding_id: number
domain_id: number
}
interface ServiceNodeRow {
id: string
nodeId: number
address: string
protocol: string
port: number | null
health_status: HealthStatus
weight: number
priority: number
}
const TABS = [
{ id: 'ip', label: 'IP' },
{ id: 'fqdn', label: 'FQDN' },
{ id: 'nodes', label: 'Ноды' },
{ id: 'ssl', label: 'SSL' },
] as const
const HEALTH_OPTIONS = [
{ value: 'up', label: 'OK' },
{ value: 'degraded', label: 'Slow' },
{ value: 'down', label: 'Down' },
{ value: 'unknown', label: '—' },
]
function mapNodeHealth(status: string): HealthStatus {
if (status === 'healthy' || status === 'up') return 'up'
if (status === 'unhealthy' || status === 'down') return 'down'
if (status === 'degraded') return 'degraded'
return 'unknown'
}
function buildIpRows(service: ServiceView): ServiceIpRow[] {
const healthByIp = new Map(service.ip_health.map((row) => [row.ip, row]))
const weights = Object.assign(
{},
...service.domains.map((domain) => domain.target_ip_weights ?? {}),
) as Record<string, number>
const priorities = Object.assign(
{},
...service.domains.map((domain) => domain.target_ip_priorities ?? {}),
) as Record<string, number>
const activeSet = new Set(service.active_ips)
return service.ips.map((ip) => {
const health = healthByIp.get(ip)
return {
id: ip,
ip,
status: health?.status ?? 'unknown',
enabled: service.ip_enabled[ip] !== false,
active: activeSet.has(ip),
weight: weights[ip] ?? 1,
priority: priorities[ip] ?? 1,
latency_ms: health?.latency_ms ?? null,
last_checked_at: health?.last_checked_at ?? null,
last_error: health?.last_error ?? null,
colo: health?.colo ?? null,
provider: health?.provider ?? null,
}
})
}
function buildFqdnRows(service: ServiceView): ServiceFqdnRow[] {
return service.domains.map((domain) => ({
id: String(domain.binding_id),
fqdn: domain.fqdn,
zone_name: domain.zone_name,
target_ips: domain.target_ips ?? [],
binding_id: domain.binding_id,
domain_id: domain.domain_id,
}))
}
function buildNodeRows(
nodes: Array<{
id: number
address: string
protocol: string
port: number | null
health_status: string
weight: number
priority: number
}>,
): ServiceNodeRow[] {
return nodes.map((node) => ({
id: String(node.id),
nodeId: node.id,
address: node.address,
protocol: node.protocol,
port: node.port,
health_status: mapNodeHealth(node.health_status),
weight: node.weight,
priority: node.priority,
}))
}
function NameCell({
icon,
label,
iconClassName,
}: {
icon: ReactNode
label: string
iconClassName?: string
}) {
return (
<div className="flex min-w-0 items-center gap-2">
<IconTile
variant="elevated"
size="xs"
className={iconClassName ?? 'text-muted-foreground'}
aria-hidden="true"
>
{icon}
</IconTile>
<span className="truncate font-mono text-sm">{label}</span>
</div>
)
}
interface ServiceDetailGridProps {
service: ServiceView
nodes: Array<{
id: number
address: string
protocol: string
port: number | null
health_status: string
weight: number
priority: number
}>
togglingIp: string | null
onToggleIp: (ip: string, enabled: boolean) => void
onChangeIp: (row: ServiceFqdnRow) => void
onChangeDomain: () => void
onAddNode: () => void
onDeleteNode: (nodeId: number) => void
isLoading?: boolean
}
export function ServiceDetailGrid({
service,
nodes,
togglingIp,
onToggleIp,
onChangeIp,
onChangeDomain,
onAddNode,
onDeleteNode,
isLoading = false,
}: ServiceDetailGridProps) {
const [tab, setTab] = useState<(typeof TABS)[number]['id']>('ip')
const [ipFilters, setIpFilters] = useState<Filter[]>(() => [
createFilter('ip', 'contains', ['']),
createFilter('status', 'is', ['']),
])
const [fqdnFilters, setFqdnFilters] = useState<Filter[]>(() => [
createFilter('fqdn', 'contains', ['']),
])
const [nodeFilters, setNodeFilters] = useState<Filter[]>(() => [
createFilter('address', 'contains', ['']),
createFilter('health_status', 'is', ['']),
])
const [sslFilters, setSslFilters] = useState<Filter[]>(() => [
createFilter('hostname', 'contains', ['']),
])
const queryClient = useQueryClient()
const certQuery = useQuery(serviceCertificatesQueryOptions(service.id))
const sslRows = certQuery.data ?? []
const patchCertMonitoring = useMutation({
mutationFn: ({
bindingId,
mode,
}: {
bindingId: number
mode: CertMonitoring
}) => patchBindingCertMonitoring(bindingId, mode),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: certKeys.byService(service.id) }),
queryClient.invalidateQueries({ queryKey: certKeys.all }),
queryClient.invalidateQueries({ queryKey: certKeys.summary }),
])
toast.success('Режим проверки SSL обновлён')
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось обновить режим SSL',
)
},
})
const checkSsl = useMutation({
mutationFn: () => checkServiceCertificates(service.id),
onSuccess: async (result) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: certKeys.byService(service.id) }),
queryClient.invalidateQueries({ queryKey: certKeys.all }),
queryClient.invalidateQueries({ queryKey: certKeys.summary }),
])
toast.success(
result.checked > 0
? `Проверено FQDN: ${result.checked}`
: 'Нет FQDN для проверки SSL',
)
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось проверить SSL',
)
},
})
const ipRows = useMemo(() => buildIpRows(service), [service])
const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
const nodeRows = useMemo(() => buildNodeRows(nodes), [nodes])
const markActive = service.lb_mode === 'failover' || service.lb_mode === 'weighted'
const tabs = TABS.map((entry) => ({
...entry,
count:
entry.id === 'ip'
? ipRows.length
: entry.id === 'fqdn'
? fqdnRows.length
: entry.id === 'ssl'
? sslRows.length
: nodeRows.length,
}))
const ipFilterFields = useMemo<FilterFieldConfig[]>(
() => [
{
key: 'ip',
label: 'IP',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'Поиск по IP…',
},
{
key: 'status',
label: 'Статус',
type: 'select',
searchable: true,
className: 'w-[168px]',
options: HEALTH_OPTIONS,
},
],
[],
)
const fqdnFilterFields = useMemo<FilterFieldConfig[]>(
() => [
{
key: 'fqdn',
label: 'FQDN',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'Поиск по FQDN…',
},
],
[],
)
const nodeFilterFields = useMemo<FilterFieldConfig[]>(
() => [
{
key: 'address',
label: 'Адрес',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'Поиск по адресу…',
},
{
key: 'health_status',
label: 'Статус',
type: 'select',
searchable: true,
className: 'w-[168px]',
options: HEALTH_OPTIONS,
},
],
[],
)
const sslFilterFields = useMemo<FilterFieldConfig[]>(
() => [
{
key: 'hostname',
label: 'FQDN',
icon: <SearchIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'Поиск по FQDN…',
},
],
[],
)
const ipColumns = useMemo<ColumnDef<ServiceIpRow>[]>(
() => [
{
id: 'ip',
accessorKey: 'ip',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="IP" />
),
cell: ({ row }) => (
<NameCell
icon={<NetworkIcon />}
label={row.original.ip}
iconClassName="text-info"
/>
),
},
{
id: 'status',
accessorKey: 'status',
header: 'Health',
cell: ({ row }) => (
<HealthCheckBadge
status={row.original.status}
latencyMs={row.original.latency_ms}
lastCheckedAt={row.original.last_checked_at}
lastError={row.original.last_error}
colo={row.original.colo}
provider={row.original.provider}
size="xs"
/>
),
},
{
id: 'active',
header: 'Пул',
cell: ({ row }) =>
markActive && row.original.active ? (
<StatusBadge status="active" />
) : (
<span className="text-muted-foreground"></span>
),
},
{
id: 'weight',
accessorKey: 'weight',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Вес" />
),
cell: ({ row }) => (
<span className="tabular-nums">
{service.lb_mode === 'weighted' ? `w${row.original.weight}` : row.original.weight}
</span>
),
},
{
id: 'priority',
accessorKey: 'priority',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Приоритет" />
),
cell: ({ row }) => (
<span className="tabular-nums">{row.original.priority}</span>
),
},
{
id: 'enabled',
header: 'Вкл',
cell: ({ row }) => (
<Switch
size="sm"
checked={row.original.enabled}
disabled={togglingIp === row.original.ip}
onCheckedChange={(checked) =>
onToggleIp(row.original.ip, Boolean(checked))
}
aria-label={
row.original.enabled
? `Выключить IP ${row.original.ip}`
: `Включить IP ${row.original.ip}`
}
/>
),
},
],
[markActive, onToggleIp, service.lb_mode, togglingIp],
)
const fqdnColumns = useMemo<ColumnDef<ServiceFqdnRow>[]>(
() => [
{
id: 'fqdn',
accessorKey: 'fqdn',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="FQDN" />
),
cell: ({ row }) => (
<NameCell
icon={<GlobeIcon />}
label={row.original.fqdn}
iconClassName="text-foreground"
/>
),
},
{
id: 'zone',
accessorKey: 'zone_name',
header: 'Зона',
},
{
id: 'ips',
header: 'Target IP',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="text-muted-foreground font-mono text-xs">
{row.original.target_ips.join(', ') || '—'}
</span>
<Badge variant="outline" size="xs">
{row.original.target_ips.length} IP
</Badge>
</div>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Button
size="sm"
variant="outline"
onClick={() => onChangeIp(row.original)}
>
Сменить IP
</Button>
),
},
],
[onChangeIp],
)
const nodeColumns = useMemo<ColumnDef<ServiceNodeRow>[]>(
() => [
{
id: 'address',
accessorKey: 'address',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Адрес" />
),
cell: ({ row }) => (
<NameCell
icon={<ServerIcon />}
label={row.original.address}
iconClassName="text-foreground"
/>
),
},
{
id: 'health',
accessorKey: 'health_status',
header: 'Health',
cell: ({ row }) => (
<HealthCheckBadge status={row.original.health_status} size="xs" />
),
},
{
id: 'meta',
header: 'Вес / приоритет',
cell: ({ row }) => (
<span className="text-muted-foreground text-xs tabular-nums">
{row.original.protocol}
{row.original.port ? `:${row.original.port}` : ''} · w
{row.original.weight} · p{row.original.priority}
</span>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<Button
size="sm"
variant="outline"
onClick={() => onDeleteNode(row.original.nodeId)}
>
Удалить
</Button>
),
},
],
[onDeleteNode],
)
const sslColumns = useMemo<ColumnDef<ServiceCertificateRow>[]>(
() => [
{
id: 'hostname',
accessorKey: 'hostname',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="FQDN" />
),
cell: ({ row }) => (
<NameCell
icon={<ShieldCheckIcon />}
label={row.original.hostname}
iconClassName="text-foreground"
/>
),
},
{
id: 'status',
header: 'Статус',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'expires_at',
accessorKey: 'expires_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Истекает" />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{formatDate(row.original.expires_at)}
</span>
),
},
{
id: 'relative',
header: 'Срок',
cell: ({ row }) =>
certRelativeBadge(row.original.status, row.original.expires_at),
},
{
id: 'mode',
header: 'Режим',
cell: ({ row }) => (
<ToggleGroup
variant="outline"
size="sm"
value={[row.original.cert_monitoring]}
onValueChange={(next) => {
const value = Array.isArray(next) ? next[0] : next
if (
typeof value !== 'string' ||
value === row.original.cert_monitoring
) {
return
}
patchCertMonitoring.mutate({
bindingId: row.original.binding_id,
mode: value as CertMonitoring,
})
}}
>
{certMonitoringOptions.map((option) => (
<ToggleGroupItem key={option.value} value={option.value}>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
),
},
{
id: 'last_checked_at',
accessorKey: 'last_checked_at',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Проверка" />
),
cell: ({ row }) => (
<span className="text-muted-foreground tabular-nums">
{formatDate(row.original.last_checked_at)}
</span>
),
},
],
[patchCertMonitoring],
)
const sharedTabs = {
tabs,
activeTab: tab,
onTabChange: (id: string) => setTab(id as typeof tab),
}
if (tab === 'fqdn') {
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
{...sharedTabs}
filterFields={fqdnFilterFields}
filters={fqdnFilters}
onFiltersChange={setFqdnFilters}
onClearFilters={() => setFqdnFilters([createFilter('fqdn', 'contains', [''])])}
getFilterFieldValue={(item, field) =>
field === 'fqdn' ? `${item.fqdn} ${item.zone_name}` : ''
}
columns={fqdnColumns}
data={fqdnRows}
getRowId={(row) => row.id}
isLoading={isLoading}
primaryAction={
<Button
variant="outline"
size="sm"
onClick={onChangeDomain}
disabled={fqdnRows.length === 0}
>
Сменить домен
</Button>
}
/>
)
}
if (tab === 'nodes') {
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
{...sharedTabs}
filterFields={nodeFilterFields}
filters={nodeFilters}
onFiltersChange={setNodeFilters}
onClearFilters={() =>
setNodeFilters([
createFilter('address', 'contains', ['']),
createFilter('health_status', 'is', ['']),
])
}
getFilterFieldValue={(item, field) => {
if (field === 'address') return item.address
if (field === 'health_status') return item.health_status
return ''
}}
columns={nodeColumns}
data={nodeRows}
getRowId={(row) => row.id}
isLoading={isLoading}
primaryAction={
<Button size="sm" onClick={onAddNode}>
<PlusIcon className="size-4" aria-hidden />
Добавить ноду
</Button>
}
/>
)
}
if (tab === 'ssl') {
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN, ноды и SSL этого сервиса"
{...sharedTabs}
filterFields={sslFilterFields}
filters={sslFilters}
onFiltersChange={setSslFilters}
onClearFilters={() =>
setSslFilters([createFilter('hostname', 'contains', [''])])
}
getFilterFieldValue={(item, field) =>
field === 'hostname' ? item.hostname : ''
}
columns={sslColumns}
data={sslRows}
getRowId={(row) => String(row.binding_id)}
isLoading={isLoading || certQuery.isLoading}
primaryAction={
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Проверить SSL"
disabled={checkSsl.isPending || sslRows.length === 0}
onClick={() => checkSsl.mutate()}
/>
}
>
<ShieldCheckIcon className="size-4.5" aria-hidden />
</TooltipTrigger>
<TooltipContent>Проверить</TooltipContent>
</Tooltip>
}
emptyState={{
title: 'Нет FQDN',
description: 'Привяжите домен к сервису, чтобы мониторить SSL.',
}}
/>
)
}
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
{...sharedTabs}
filterFields={ipFilterFields}
filters={ipFilters}
onFiltersChange={setIpFilters}
onClearFilters={() =>
setIpFilters([
createFilter('ip', 'contains', ['']),
createFilter('status', 'is', ['']),
])
}
getFilterFieldValue={(item, field) => {
if (field === 'ip') return item.ip
if (field === 'status') return item.status
return ''
}}
columns={ipColumns}
data={ipRows}
getRowId={(row) => row.id}
isLoading={isLoading}
/>
)
}
@@ -2,12 +2,20 @@ import { CheckIcon, CopyIcon } from 'lucide-react'
import { toast } from 'sonner'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-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 {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemMedia,
} from '@cfdm/ui/components/item'
import { Switch } from '@cfdm/ui/components/switch'
import {
Tooltip,
@@ -17,6 +25,9 @@ import {
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
/** Matches `Button size="icon-sm"` so Switch columns align with the card menu. */
const MENU_SLOT_CLASS = 'size-7 shrink-0'
export function CopyFqdnButton({
value,
className,
@@ -126,6 +137,11 @@ interface ServiceIpListProps {
togglingIp?: string | null
ipToggleDisabled?: boolean
onToggleIp?: (ip: string, enabled: boolean) => void
/** Invisible icon-sm slot so IP Switch lines up with the card overflow menu. */
alignWithMenu?: boolean
lbMode?: ServiceView['lb_mode']
activeIps?: string[]
ipWeights?: Record<string, number>
className?: string
emptyLabel?: string
copyable?: boolean
@@ -139,6 +155,10 @@ export function ServiceIpList({
togglingIp = null,
ipToggleDisabled = false,
onToggleIp,
alignWithMenu = false,
lbMode,
activeIps = [],
ipWeights = {},
className,
emptyLabel = 'Нет IP',
copyable = false,
@@ -155,45 +175,83 @@ export function ServiceIpList({
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
const extraCount = ips.length - visible.length
const showMenuSlot = Boolean(onToggleIp && alignWithMenu)
const markActive = lbMode === 'failover' || lbMode === 'weighted'
const activeSet = new Set(activeIps)
return (
<div className={cn('flex min-w-0 flex-col gap-1', className)}>
<ItemGroup className={cn('gap-1', className)}>
{visible.map((ip) => {
const health = healthByIp.get(ip)
const enabled = ipEnabled[ip] !== false
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(
'min-w-0 font-mono text-xs',
enabled ? 'text-muted-foreground' : 'text-muted-foreground/60',
textClassName,
)}
>
{ip}
</TruncatedText>
{copyable ? <CopyFqdnButton value={ip} /> : null}
{onToggleIp ? (
<Switch
size="sm"
className="ml-auto shrink-0"
checked={enabled}
disabled={ipToggleDisabled || togglingIp === ip}
onClick={(event) => {
event.stopPropagation()
}}
onCheckedChange={(checked) => onToggleIp(ip, Boolean(checked))}
aria-label={
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
}
<Item
key={ip}
size="sm"
className="w-full min-w-0 flex-nowrap border-0 p-0"
>
<ItemMedia>
<HealthCheckBadge
status={health?.status ?? 'unknown'}
latencyMs={health?.latency_ms}
lastCheckedAt={health?.last_checked_at}
lastError={health?.last_error}
colo={health?.colo}
provider={health?.provider}
size="xs"
/>
</ItemMedia>
<ItemContent className="min-w-0 gap-0">
<div className="flex min-w-0 items-center gap-1.5">
<TruncatedText
className={cn(
'min-w-0 font-mono text-xs',
enabled
? 'text-muted-foreground'
: 'text-muted-foreground/60',
textClassName,
)}
>
{ip}
</TruncatedText>
{copyable ? <CopyFqdnButton value={ip} /> : null}
{markActive && activeSet.has(ip) ? (
<StatusBadge status="active" className="shrink-0" />
) : null}
{lbMode === 'weighted' ? (
<Badge
variant="outline"
size="xs"
className="shrink-0 tabular-nums"
>
w{ipWeights[ip] ?? 1}
</Badge>
) : null}
</div>
</ItemContent>
{onToggleIp ? (
<ItemActions className="ml-auto shrink-0 gap-1">
<Switch
size="sm"
className="shrink-0"
checked={enabled}
disabled={ipToggleDisabled || togglingIp === ip}
onClick={(event) => {
event.stopPropagation()
}}
onCheckedChange={(checked) =>
onToggleIp(ip, Boolean(checked))
}
aria-label={
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
}
/>
{showMenuSlot ? (
<span className={MENU_SLOT_CLASS} aria-hidden="true" />
) : null}
</ItemActions>
) : null}
</div>
</Item>
)
})}
{extraCount > 0 ? (
@@ -220,6 +278,6 @@ export function ServiceIpList({
</Tooltip>
</TooltipProvider>
) : null}
</div>
</ItemGroup>
)
}
@@ -1,11 +1,17 @@
import { Link } from '@tanstack/react-router'
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
import {
GitForkIcon,
MoreHorizontalIcon,
Repeat2Icon,
ScaleIcon,
ServerIcon,
type LucideIcon,
} from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
@@ -23,6 +29,12 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import {
Item,
ItemActions,
ItemContent,
ItemMedia,
} from '@cfdm/ui/components/item'
import { Switch } from '@cfdm/ui/components/switch'
import {
Tooltip,
@@ -30,6 +42,63 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
/**
* Compact service card settings-8 DNA (Badge + copy + Switch + menu).
* Preview: https://reui.io/preview/base/settings-8
* Frame: https://reui.io/docs/components/base/frame
* IconTile: https://reui.io/docs/components/base/icon-tile
* Header fill: FramePanel `bg-muted` (overrides `--frame-panel-bg`; see frame.tsx).
*/
type LbMode = ServiceView['lb_mode']
const LB_MODE_META: Record<
LbMode,
{ icon: LucideIcon; className: string; label: string }
> = {
round_robin: {
icon: Repeat2Icon,
className: 'text-info',
label: 'Round Robin',
},
failover: {
icon: GitForkIcon,
className: 'text-warning',
label: 'Failover (приоритет)',
},
weighted: {
icon: ScaleIcon,
className: 'text-info',
label: 'Weighted (веса)',
},
}
export function LbModeTile({ mode }: { mode: LbMode }) {
const meta = LB_MODE_META[mode]
const Icon = meta.icon
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<IconTile
variant="elevated"
size="xs"
className={cn('shrink-0', meta.className)}
aria-label={meta.label}
/>
}
>
<Icon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{meta.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
interface ServiceUnitCardProps {
service: ServiceView
@@ -55,27 +124,32 @@ export function ServiceUnitCard({
const extraCount = Math.max(0, fqdns.length - 1)
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>
<Frame stacked spacing="sm" className="h-full min-w-0">
<FramePanel fit className="bg-muted">
<Item size="sm" className="w-full min-w-0 flex-nowrap border-0 p-0">
<ItemMedia>
<IconTile
variant="elevated"
size="sm"
className="text-foreground"
aria-hidden="true"
>
<ServerIcon />
</IconTile>
</ItemMedia>
<ItemContent className="min-w-0 gap-px">
<div className="flex min-w-0 items-center gap-1.5">
<FrameTitle className="min-w-0 truncate text-base font-semibold">
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
className="hover:underline"
>
{service.name}
</Link>
</FrameTitle>
<LbModeTile mode={service.lb_mode} />
</div>
<div className="flex min-w-0 items-center gap-1">
<FrameDescription className="min-w-0 truncate font-mono text-xs">
{primaryDomain}
@@ -108,66 +182,70 @@ export function ServiceUnitCard({
<CopyFqdnButton value={fqdns.join('\n')} />
) : null}
</div>
</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}`}
/>
</ItemContent>
<ItemActions className="ml-auto shrink-0 gap-1">
<Switch
size="sm"
checked={service.enabled}
disabled={togglingId === service.id}
onCheckedChange={(checked) =>
onToggleService(service.id, Boolean(checked))
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
aria-label={
service.enabled ? 'Выключить сервис' : 'Включить сервис'
}
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия ${service.name}`}
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditService(service)}>
Изменить
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteService(service)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</FrameHeader>
<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>
</ItemActions>
</Item>
</FramePanel>
<FramePanel className="flex min-w-0 flex-col gap-1 pt-0 shadow-none!">
<FramePanel className="flex min-w-0 flex-col">
<ServiceIpList
copyable
alignWithMenu
ips={service.ips ?? []}
ipHealth={service.ip_health ?? []}
ipEnabled={service.ip_enabled ?? {}}
ipToggleDisabled={togglingId === service.id}
togglingIp={togglingIp}
lbMode={service.lb_mode}
activeIps={service.active_ips}
ipWeights={service.domains[0]?.target_ip_weights}
onToggleIp={(ip, enabled) =>
onToggleServiceIp(service.id, ip, enabled)
}
+1 -1
View File
@@ -73,7 +73,7 @@ export function SettingRow({
>
<div
className={cn(
'flex w-full justify-start',
'flex w-full min-w-0 justify-start',
stacked ? 'justify-start' : '@md/field-group:justify-end',
)}
>
@@ -3,10 +3,8 @@ import { useEffect, useMemo } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { CertMonitoring } from '@cfdm/shared'
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
import type { SubdomainServiceLink } from '@/hooks/use-domain-page'
import { certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatServiceGroupLabel } from '@/lib/service-utils'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
@@ -30,7 +28,6 @@ import {
const subdomainEditSchema = z.object({
name: z.string().min(1, 'Укажите имя'),
serviceId: z.string(),
certMonitoring: z.enum(['auto', 'required', 'skipped']),
})
export type SubdomainEditValues = z.infer<typeof subdomainEditSchema>
@@ -67,7 +64,6 @@ export function SubdomainEditSheet({
defaultValues: {
name: '',
serviceId: 'none',
certMonitoring: 'auto',
},
})
@@ -85,42 +81,27 @@ export function SubdomainEditSheet({
[services, serviceGroupById],
)
const certMonitoringItems = useMemo(
() =>
certMonitoringOptions.map((option) => ({
label: option.label,
value: option.value,
})),
[],
)
useEffect(() => {
if (!open) return
if (mode === 'edit' && subdomain) {
form.reset({
name: subdomain.name,
serviceId: currentServiceId || 'none',
certMonitoring: subdomain.cert_monitoring,
})
return
}
form.reset({
name: '',
serviceId: 'none',
certMonitoring: 'auto',
})
}, [open, mode, subdomain, currentServiceId, form])
const certMonitoring = form.watch('certMonitoring')
const certHint =
certMonitoringOptions.find((o) => o.value === certMonitoring)?.description
const hasMultipleServices = mode === 'edit' && serviceLinks.length > 1
function handleSubmit(values: SubdomainEditValues) {
onSubmit({
name: values.name.trim(),
serviceId: values.serviceId,
certMonitoring: values.certMonitoring as CertMonitoring,
})
}
@@ -218,39 +199,6 @@ export function SubdomainEditSheet({
)}
/>
</FormFieldSimple>
<FormFieldSimple
label="Мониторинг SSL"
htmlFor="subdomain_cert_monitoring"
hint={certHint}
>
<Controller
control={form.control}
name="certMonitoring"
render={({ field }) => (
<Select
items={certMonitoringItems}
value={field.value}
onValueChange={(value) =>
field.onChange((value ?? 'auto') as CertMonitoring)
}
>
<SelectTrigger
id="subdomain_cert_monitoring"
className="w-full"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FormFieldSimple>
</>
) : null}
</FieldGroup>
-17
View File
@@ -13,7 +13,6 @@ import {
serviceGroupsQueryOptions,
servicesQueryOptions,
subdomainsListQueryOptions,
updateDomain,
updateSubdomain,
} from '@/queries'
import type { CertMonitoring } from '@cfdm/shared'
@@ -172,21 +171,6 @@ export function useDomainPage(domainId: number) {
},
})
const updateDomainCertMonitoringMutation = useMutation({
mutationFn: (certMonitoring: CertMonitoring) =>
updateDomain(domainId, { cert_monitoring: certMonitoring }),
onSuccess: () => {
invalidate()
void queryClient.invalidateQueries({ queryKey: ['domains'] })
toast.success('Режим мониторинга SSL обновлён')
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : 'Не удалось обновить мониторинг SSL',
)
},
})
const deleteSubdomainMutation = useMutation({
mutationFn: (id: number) => deleteSubdomain(id),
onSuccess: () => {
@@ -249,7 +233,6 @@ export function useDomainPage(domainId: number) {
syncMutation,
createSubdomainMutation,
updateSubdomainMutation,
updateDomainCertMonitoringMutation,
deleteSubdomainMutation,
linkServiceMutation,
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { dedupeBreadcrumbs, getBreadcrumbs } from './breadcrumbs'
describe('getBreadcrumbs', () => {
it('keeps a single Настройки parent plus the active section', () => {
expect(getBreadcrumbs('/settings/appearance')).toEqual([
{ label: 'Настройки', href: '/settings' },
{ label: 'Внешний вид', href: '/settings/appearance' },
])
expect(getBreadcrumbs('/settings/health')).toEqual([
{ label: 'Настройки', href: '/settings' },
{ label: 'Health-check', href: '/settings/health' },
])
expect(getBreadcrumbs('/settings/integrations')).toEqual([
{ label: 'Настройки', href: '/settings' },
{ label: 'Интеграции', href: '/settings/integrations' },
])
})
it('does not reuse the section href for the parent crumb', () => {
const crumbs = getBreadcrumbs('/settings/appearance')
const hrefs = crumbs.map((crumb) => crumb.href)
expect(new Set(hrefs).size).toBe(hrefs.length)
})
})
describe('dedupeBreadcrumbs', () => {
it('collapses stacked identical labels from repeated navigations', () => {
expect(
dedupeBreadcrumbs([
{ label: 'Настройки', href: '/settings' },
{ label: 'Настройки', href: '/settings' },
{ label: 'Настройки', href: '/settings/appearance' },
{ label: 'Внешний вид', href: '/settings/appearance' },
]),
).toEqual([
{ label: 'Настройки', href: '/settings' },
{ label: 'Внешний вид', href: '/settings/appearance' },
])
})
})
+86
View File
@@ -0,0 +1,86 @@
export interface BreadcrumbCrumb {
label: string
href: string
}
const routeTitles: Record<string, string> = {
'/': 'Панель управления',
'/domains': 'Домены',
'/groups': 'Группы доменов',
'/services': 'Сервисы',
'/certificates': 'Сертификаты',
}
const SETTINGS_SECTIONS: Record<string, string> = {
'/settings/appearance': 'Внешний вид',
'/settings/health': 'Health-check',
'/settings/integrations': 'Интеграции',
}
/** Drop consecutive repeats so «Настройки» does not stack after tab switches. */
export function dedupeBreadcrumbs(crumbs: BreadcrumbCrumb[]): BreadcrumbCrumb[] {
const out: BreadcrumbCrumb[] = []
for (const crumb of crumbs) {
const prev = out.at(-1)
if (prev && prev.label === crumb.label) continue
out.push(crumb)
}
return out
}
export function getBreadcrumbs(
pathname: string,
dynamicLabels: Record<string, string> = {},
): BreadcrumbCrumb[] {
const path = pathname.replace(/\/+$/, '') || '/'
if (path === '/') {
return [{ label: 'Панель управления', href: '/' }]
}
if (path.match(/^\/services\/\d+$/)) {
return [
{ label: 'Сервисы', href: '/services' },
{ label: dynamicLabels[path] ?? 'Сервис', href: path },
]
}
if (path.match(/^\/groups\/\d+$/)) {
return [
{ label: 'Группы доменов', href: '/groups' },
{ label: dynamicLabels[path] ?? 'Группа', href: path },
]
}
if (path.match(/^\/domains\/\d+\/dns$/)) {
const domainId = path.split('/')[2]
const domainPath = `/domains/${domainId}`
return [
{ label: 'Домены', href: '/domains' },
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
{ label: 'DNS', href: path },
]
}
if (path.match(/^\/domains\/\d+$/)) {
return [
{ label: 'Домены', href: '/domains' },
{ label: dynamicLabels[path] ?? 'Обзор домена', href: path },
]
}
if (path === '/settings' || path.startsWith('/settings/')) {
const section = SETTINGS_SECTIONS[path]
return dedupeBreadcrumbs([
{ label: 'Настройки', href: '/settings' },
...(section ? [{ label: section, href: path }] : []),
])
}
const title = routeTitles[path]
if (title) {
return [{ label: title, href: path }]
}
return [{ label: 'Панель управления', href: '/' }]
}
+3 -3
View File
@@ -8,17 +8,17 @@ export const certMonitoringOptions: Array<{
{
value: 'auto',
label: 'Авто',
description: 'Проверять, если хост обслуживается активным сервисом',
description: 'Проверять, если health-check сервиса с verify TLS',
},
{
value: 'required',
label: 'Обязательно',
description: 'Всегда проверять SSL, даже без привязок',
description: 'Всегда проверять SSL для этого FQDN',
},
{
value: 'skipped',
label: 'Не проверять',
description: 'Исключить из мониторинга сертификатов',
description: 'Исключить FQDN из мониторинга сертификатов',
},
]
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import {
collapseStatusChanges,
enabledHealthProviders,
providerHealthStatuses,
worstHealthStatus,
type HealthLogProbe,
} from '@/lib/health-log'
function probe(
overrides: Partial<HealthLogProbe> & Pick<HealthLogProbe, 'id' | 'status' | 'checked_at'>,
): HealthLogProbe {
return {
ip: '1.1.1.1',
provider: 'local',
ok: overrides.status === 'up',
latency_ms: 12,
colo: null,
error: null,
...overrides,
}
}
describe('collapseStatusChanges', () => {
it('keeps only status transitions per ip+provider', () => {
const items = [
probe({ id: 1, status: 'up', checked_at: '2026-01-01T00:00:00Z' }),
probe({ id: 2, status: 'up', checked_at: '2026-01-01T00:01:00Z' }),
probe({ id: 3, status: 'down', checked_at: '2026-01-01T00:02:00Z' }),
probe({ id: 4, status: 'down', checked_at: '2026-01-01T00:03:00Z' }),
probe({ id: 5, status: 'up', checked_at: '2026-01-01T00:04:00Z' }),
]
const changes = collapseStatusChanges(items)
expect(changes.map((item) => item.id)).toEqual([5, 3, 1])
})
it('tracks series independently by provider', () => {
const items = [
probe({ id: 1, provider: 'local', status: 'up', checked_at: '2026-01-01T00:00:00Z' }),
probe({
id: 2,
provider: 'cloudflare',
status: 'up',
checked_at: '2026-01-01T00:00:00Z',
}),
probe({ id: 3, provider: 'local', status: 'up', checked_at: '2026-01-01T00:01:00Z' }),
probe({
id: 4,
provider: 'cloudflare',
status: 'down',
checked_at: '2026-01-01T00:01:00Z',
}),
]
const changes = collapseStatusChanges(items)
expect(changes.map((item) => item.id).sort()).toEqual([1, 2, 4])
})
})
describe('enabledHealthProviders', () => {
it('unions bindings in registry order', () => {
expect(
enabledHealthProviders([
{ health_check_providers: ['globalping'] },
{ health_check_providers: ['local', 'cloudflare'] },
]),
).toEqual(['local', 'cloudflare', 'globalping'])
})
it('falls back to local', () => {
expect(enabledHealthProviders([])).toEqual(['local'])
})
})
describe('providerHealthStatuses', () => {
it('uses worst latest-per-ip status', () => {
const items = [
probe({ id: 1, ip: '1.1.1.1', status: 'up', checked_at: '2026-01-01T00:02:00Z' }),
probe({ id: 2, ip: '2.2.2.2', status: 'down', checked_at: '2026-01-01T00:01:00Z' }),
probe({
id: 3,
ip: '2.2.2.2',
status: 'up',
checked_at: '2026-01-01T00:00:00Z',
}),
]
expect(providerHealthStatuses(items, ['local']).local).toBe('down')
})
})
describe('worstHealthStatus', () => {
it('ranks down over degraded over up', () => {
expect(worstHealthStatus(['up', 'degraded'])).toBe('degraded')
expect(worstHealthStatus(['degraded', 'down'])).toBe('down')
expect(worstHealthStatus([])).toBe('unknown')
})
})
+127
View File
@@ -0,0 +1,127 @@
import type { HealthCheckProvider } from '@cfdm/shared'
import { HEALTH_CHECK_PROVIDERS, uniqueHealthProviders } from '@cfdm/shared'
import { sqliteUtcToIso } from '@/lib/format'
import type { IpHealthStatus } from '@/lib/schemas'
export type HealthLogStatus = IpHealthStatus['status']
export interface HealthLogProbe {
id: number
ip: string
provider: HealthCheckProvider
status: HealthLogStatus
ok: boolean
latency_ms: number | null
colo: string | null
error: string | null
checked_at: string
}
const STATUS_RANK: Record<HealthLogStatus, number> = {
unknown: 0,
up: 1,
degraded: 2,
down: 3,
}
export function probeTime(checkedAt: string): number {
const iso = sqliteUtcToIso(checkedAt) ?? checkedAt
const time = new Date(iso).getTime()
return Number.isNaN(time) ? 0 : time
}
export function filterByPeriod<T extends { checked_at: string }>(
items: T[],
days: number,
): T[] {
const cutoff = Date.now() - days * 86_400_000
return items.filter((item) => probeTime(item.checked_at) >= cutoff)
}
export function filterByProviders<T extends { provider: string }>(
items: T[],
providers: readonly HealthCheckProvider[],
): T[] {
if (providers.length === 0) return items
const allowed = new Set(providers)
return items.filter((item) => allowed.has(item.provider as HealthCheckProvider))
}
/**
* Keep the first probe of each ip+provider series and every later probe
* whose status differs from the previous one. Newest first.
*/
export function collapseStatusChanges<T extends HealthLogProbe>(items: T[]): T[] {
const byKey = new Map<string, T[]>()
for (const item of items) {
const key = `${item.ip}\0${item.provider}`
const list = byKey.get(key)
if (list) list.push(item)
else byKey.set(key, [item])
}
const changes: T[] = []
for (const list of byKey.values()) {
list.sort(
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
)
let previous: HealthLogStatus | undefined
for (const item of list) {
if (item.status !== previous) {
changes.push(item)
previous = item.status
}
}
}
changes.sort(
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id,
)
return changes
}
export function enabledHealthProviders(
domains: Array<{ health_check_providers?: readonly HealthCheckProvider[] | null }>,
): HealthCheckProvider[] {
const collected = uniqueHealthProviders(
domains.flatMap((domain) => domain.health_check_providers ?? []),
)
if (collected.length === 0) return ['local']
return HEALTH_CHECK_PROVIDERS.filter((provider) => collected.includes(provider))
}
export function worstHealthStatus(statuses: readonly HealthLogStatus[]): HealthLogStatus {
if (statuses.length === 0) return 'unknown'
return statuses.reduce((worst, status) =>
STATUS_RANK[status] > STATUS_RANK[worst] ? status : worst,
)
}
/** Latest probe per IP for a provider, then worst among those IPs. */
export function providerHealthStatuses(
items: readonly HealthLogProbe[],
providers: readonly HealthCheckProvider[],
): Record<HealthCheckProvider, HealthLogStatus> {
const latestByIp = new Map<string, HealthLogProbe>()
const sorted = [...items].sort(
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id,
)
for (const item of sorted) {
const key = `${item.provider}\0${item.ip}`
if (!latestByIp.has(key)) latestByIp.set(key, item)
}
const result = Object.fromEntries(
HEALTH_CHECK_PROVIDERS.map((provider) => [provider, 'unknown' as HealthLogStatus]),
) as Record<HealthCheckProvider, HealthLogStatus>
for (const provider of providers) {
const statuses = [...latestByIp.values()]
.filter((item) => item.provider === provider)
.map((item) => item.status)
result[provider] = worstHealthStatus(statuses)
}
return result
}
+52 -2
View File
@@ -36,6 +36,9 @@ export const serviceGroupSchema = z.object({
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
created_at: z.string(),
updated_at: z.string(),
})
@@ -76,6 +79,10 @@ export const serviceDomainBindingSchema = z
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
sync_status: z.string().nullable().default(null),
})
.transform((binding) => ({
@@ -98,6 +105,24 @@ export const serviceIpHealthSchema = z.object({
ip: z.string(),
status: z.enum(['up', 'down', 'degraded', 'unknown']),
latency_ms: z.number().nullable(),
last_checked_at: z.string().nullable().optional(),
last_error: z.string().nullable().optional(),
provider: z.enum(['local', 'cloudflare', 'globalping', 'aggregate']).optional(),
colo: z.string().nullable().optional(),
})
export const healthProbeLogSchema = z.object({
id: z.number(),
scope: z.string(),
ref_id: z.number(),
ip: z.string(),
provider: z.enum(['local', 'cloudflare', 'globalping']),
status: z.enum(['up', 'down', 'degraded', 'unknown']),
ok: z.coerce.boolean(),
latency_ms: z.number().nullable(),
colo: z.string().nullable(),
error: z.string().nullable(),
checked_at: z.string(),
})
export const serviceViewSchema = serviceSchema.extend({
@@ -109,6 +134,8 @@ export const serviceViewSchema = serviceSchema.extend({
health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]),
ip_enabled: z.record(z.string(), z.boolean()).default({}),
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
active_ips: z.array(z.string()).default([]),
})
export const serviceGroupViewSchema = serviceGroupSchema.extend({
@@ -168,6 +195,10 @@ export const serviceBindingSchema = z
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
sync_status: z.string().nullable().default(null),
created_at: z.string(),
updated_at: z.string(),
@@ -205,6 +236,8 @@ export const certificateSchema = z.object({
id: z.number(),
domain_id: z.number(),
subdomain_id: z.number().nullable(),
service_id: z.number().nullable().optional().default(null),
service_name: z.string().nullable().optional().default(null),
hostname: z.string(),
expires_at: z.string().nullable(),
last_checked_at: z.string().nullable(),
@@ -214,6 +247,19 @@ export const certificateSchema = z.object({
updated_at: z.string(),
})
export const serviceCertificateRowSchema = z.object({
binding_id: z.number(),
domain_id: z.number(),
service_id: z.number(),
hostname: z.string(),
cert_monitoring: z.enum(['auto', 'required', 'skipped']),
id: z.number().nullable(),
status: z.string(),
expires_at: z.string().nullable(),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable(),
})
export type Group = z.infer<typeof groupSchema>
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
export type Service = z.infer<typeof serviceSchema>
@@ -227,6 +273,7 @@ export type DomainListItem = z.infer<typeof domainListItemSchema>
export type ServiceBinding = z.infer<typeof serviceBindingSchema>
export type DnsRecord = z.infer<typeof dnsRecordSchema>
export type Certificate = z.infer<typeof certificateSchema>
export type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>
export const createGroupSchema = z.object({
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
@@ -244,14 +291,17 @@ const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted'])
const healthCheckTypeSchema = z.enum(['tcp', 'http', 'ping', 'dns'])
const healthCheckConfigFields = {
health_check_enabled: z.boolean().optional(),
health_check_enabled: z.coerce.boolean().optional(),
health_check_type: healthCheckTypeSchema.optional(),
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
health_check_path: z.string().nullable().optional(),
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
health_check_verify_tls: z.boolean().optional(),
health_check_verify_tls: z.coerce.boolean().optional(),
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).optional(),
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).optional(),
health_check_aggregate: z.enum(['any', 'all', 'majority']).optional(),
}
const serviceDomainInputSchema = z
+183
View File
@@ -0,0 +1,183 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_BINDING_HEALTH,
addAddressNode,
emptyAddressBlock,
emptyBindingDraft,
hydrateAddressBlock,
removeAddressNode,
toAddressBindings,
toDomainsPayload,
type ServiceBindingDraft,
} from '@/lib/service-address'
const primaryMeta = {
lb_mode: 'round_robin' as const,
health: { ...DEFAULT_BINDING_HEALTH, enabled: true },
}
function aRecord(
fqdn: string,
target_ips: string[],
overrides: Partial<ServiceBindingDraft> = {},
): ServiceBindingDraft {
return {
...emptyBindingDraft(fqdn),
record_type: 'A',
target_ips,
target_ip_weights: Object.fromEntries(target_ips.map((ip) => [ip, 1])),
target_ip_priorities: Object.fromEntries(target_ips.map((ip) => [ip, 1])),
...overrides,
}
}
describe('hydrateAddressBlock', () => {
it('схлопывает extra A с одним IP пула в extraFqdn узла (MSK Macloud)', () => {
const drafts = [
aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']),
aRecord('msk.rutg.rkns.top', ['93.115.203.183']),
]
const state = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
expect(state.commonFqdn).toBe('rutg.rkns.top')
expect(state.nodes).toEqual([
{ ip: '93.115.203.183', extraFqdn: 'msk.rutg.rkns.top' },
{ ip: '185.244.181.61', extraFqdn: '' },
])
expect(state.otherBindings).toEqual([])
})
it('не схлопывает CNAME и A на несколько IP', () => {
const cname: ServiceBindingDraft = {
...emptyBindingDraft('alias.rkns.top'),
record_type: 'CNAME',
target_cname: 'rutg.rkns.top',
}
const drafts = [
aRecord('rutg.rkns.top', ['1.1.1.1', '2.2.2.2']),
aRecord('both.rkns.top', ['1.1.1.1', '2.2.2.2']),
cname,
]
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
expect(state.nodes.every((node) => node.extraFqdn === '')).toBe(true)
expect(state.otherBindings.map((item) => item.fqdn)).toEqual([
'both.rkns.top',
'alias.rkns.top',
])
})
it('кладёт extra A с IP вне пула в otherBindings', () => {
const drafts = [
aRecord('gw.example.com', ['10.0.0.1']),
aRecord('edge.example.com', ['8.8.8.8']),
]
const state = hydrateAddressBlock(drafts, ['10.0.0.1'])
expect(state.nodes).toEqual([{ ip: '10.0.0.1', extraFqdn: '' }])
expect(state.otherBindings).toHaveLength(1)
expect(state.otherBindings[0]?.fqdn).toBe('edge.example.com')
})
})
describe('toDomainsPayload', () => {
it('собирает primary на весь пул и extra binding на один IP', () => {
const drafts = [
aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']),
aRecord('msk.rutg.rkns.top', ['93.115.203.183']),
]
const state = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
const payload = toDomainsPayload(state, primaryMeta)
expect(payload).toEqual([
expect.objectContaining({
fqdn: 'rutg.rkns.top',
target_ips: ['93.115.203.183', '185.244.181.61'],
health_check_enabled: true,
}),
expect.objectContaining({
fqdn: 'msk.rutg.rkns.top',
target_ips: ['93.115.203.183'],
health_check_enabled: true,
}),
])
})
it('круг hydrate → payload → hydrate сохраняет extra FQDN', () => {
const drafts = [
aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']),
aRecord('msk.rutg.rkns.top', ['93.115.203.183']),
]
const first = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
const rebound = toAddressBindings(first, primaryMeta)
const second = hydrateAddressBlock(rebound, rebound[0]?.target_ips ?? [])
expect(second.commonFqdn).toBe(first.commonFqdn)
expect(second.nodes).toEqual(first.nodes)
expect(second.otherBindings).toEqual([])
})
})
describe('removeAddressNode', () => {
it('удаляет extra FQDN узла и IP из other A-bindings', () => {
const state = hydrateAddressBlock(
[
aRecord('gw.example.com', ['10.0.0.1', '10.0.0.2']),
aRecord('msk.example.com', ['10.0.0.1']),
aRecord('pair.example.com', ['10.0.0.1', '10.0.0.2']),
],
['10.0.0.1', '10.0.0.2'],
)
const next = removeAddressNode(state, '10.0.0.1')
expect(next.nodes).toEqual([{ ip: '10.0.0.2', extraFqdn: '' }])
expect(next.otherBindings).toHaveLength(1)
expect(next.otherBindings[0]?.target_ips).toEqual(['10.0.0.2'])
})
})
describe('addAddressNode', () => {
it('не добавляет дубликат IP', () => {
const withIp = addAddressNode(
{ ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdn: '' }] },
'1.1.1.1',
)
expect(withIp.nodes).toHaveLength(1)
})
})
describe('CNAME / otherBindings', () => {
it('сохраняет CNAME в otherBindings при круге hydrate → payload', () => {
const cname: ServiceBindingDraft = {
...emptyBindingDraft('alias.rkns.top'),
record_type: 'CNAME',
target_cname: 'rutg.rkns.top',
}
const drafts = [
aRecord('rutg.rkns.top', ['1.1.1.1']),
aRecord('msk.rkns.top', ['1.1.1.1']),
cname,
]
const state = hydrateAddressBlock(drafts, ['1.1.1.1'])
expect(state.nodes[0]?.extraFqdn).toBe('msk.rkns.top')
expect(state.otherBindings).toHaveLength(1)
const payload = toDomainsPayload(state, primaryMeta)
expect(payload.map((item) => item.fqdn)).toEqual([
'rutg.rkns.top',
'msk.rkns.top',
'alias.rkns.top',
])
expect(payload[2]).toEqual(
expect.objectContaining({
fqdn: 'alias.rkns.top',
target_cname: 'rutg.rkns.top',
}),
)
})
})
+340
View File
@@ -0,0 +1,340 @@
import { parseHealthProviders } from '@cfdm/shared'
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
import { bindingToFqdn } from '@/lib/parse-fqdn'
import type { ServiceView } from '@/lib/schemas'
export type AddressLbMode = 'round_robin' | 'failover' | 'weighted'
export type AddressHealthCheckType = 'tcp' | 'http'
export interface BindingHealthConfig {
enabled: boolean
type: AddressHealthCheckType
port: number | null
path: string | null
expected_status: number | null
interval_sec: number
timeout_ms: number
verify_tls: boolean
provider: HealthCheckProvider
providers: HealthCheckProvider[]
aggregate: HealthCheckAggregate
}
export interface ServiceBindingDraft {
fqdn: string
record_type: 'A' | 'CNAME'
target_ips: string[]
target_cname: string
lb_mode: AddressLbMode
health: BindingHealthConfig
target_ip_weights: Record<string, number>
target_ip_priorities: Record<string, number>
}
export interface AddressNode {
ip: string
extraFqdn: string
}
export interface AddressBlockState {
commonFqdn: string
nodes: AddressNode[]
otherBindings: ServiceBindingDraft[]
target_ip_weights: Record<string, number>
target_ip_priorities: Record<string, number>
}
export interface AddressPrimaryMeta {
lb_mode: AddressLbMode
health: BindingHealthConfig
}
export const DEFAULT_BINDING_HEALTH: BindingHealthConfig = {
enabled: false,
type: 'tcp',
port: null,
path: null,
expected_status: null,
interval_sec: 30,
timeout_ms: 3000,
verify_tls: false,
provider: 'local',
providers: ['local'],
aggregate: 'majority',
}
export function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
return {
fqdn,
record_type: 'A',
target_ips: [],
target_cname: '',
lb_mode: 'round_robin',
health: { ...DEFAULT_BINDING_HEALTH },
target_ip_weights: {},
target_ip_priorities: {},
}
}
export function emptyAddressBlock(): AddressBlockState {
return {
commonFqdn: '',
nodes: [],
otherBindings: [],
target_ip_weights: {},
target_ip_priorities: {},
}
}
export 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]),
),
}
}
function uniqueIps(...lists: string[][]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const list of lists) {
for (const ip of list) {
const trimmed = ip.trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
out.push(trimmed)
}
}
return out
}
function omitKey(record: Record<string, number>, key: string): Record<string, number> {
const next = { ...record }
delete next[key]
return next
}
export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
return (service.domains ?? []).map((binding) => ({
fqdn: bindingToFqdn(binding),
record_type: binding.record_type ?? (binding.target_cname ? 'CNAME' : 'A'),
target_ips: binding.target_ips ?? [],
target_cname: binding.target_cname ?? '',
lb_mode: binding.lb_mode,
health: {
enabled: Boolean(binding.health_check_enabled),
type: binding.health_check_type === 'http' ? 'http' : 'tcp',
port: binding.health_check_port,
path: binding.health_check_path,
expected_status: binding.health_check_expected_status,
interval_sec: binding.health_check_interval_sec,
timeout_ms: binding.health_check_timeout_ms,
verify_tls: Boolean(binding.health_check_verify_tls),
provider: binding.health_check_provider ?? 'local',
providers: parseHealthProviders(
binding.health_check_providers,
binding.health_check_provider ?? 'local',
),
aggregate: binding.health_check_aggregate ?? 'majority',
},
target_ip_weights: binding.target_ip_weights ?? {},
target_ip_priorities: binding.target_ip_priorities ?? {},
}))
}
function canCollapseToNode(
extra: ServiceBindingDraft,
pool: Set<string>,
claimed: Set<string>,
): string | null {
if (extra.record_type !== 'A') return null
if (extra.target_ips.length !== 1) return null
const ip = extra.target_ips[0]?.trim() ?? ''
if (!ip || !pool.has(ip) || claimed.has(ip)) return null
if (!extra.fqdn.trim()) return null
return ip
}
export function hydrateAddressBlock(
drafts: ServiceBindingDraft[],
pool: string[] = [],
): AddressBlockState {
const primary = drafts[0]
const ips = uniqueIps(pool, primary?.target_ips ?? [])
const poolSet = new Set(ips)
const claimed = new Set<string>()
const extraByIp = new Map<string, string>()
const otherBindings: ServiceBindingDraft[] = []
for (const extra of drafts.slice(1)) {
const ip = canCollapseToNode(extra, poolSet, claimed)
if (ip) {
claimed.add(ip)
extraByIp.set(ip, extra.fqdn)
continue
}
otherBindings.push(extra)
}
return {
commonFqdn: primary?.fqdn ?? '',
nodes: ips.map((ip) => ({
ip,
extraFqdn: extraByIp.get(ip) ?? '',
})),
otherBindings,
target_ip_weights: { ...(primary?.target_ip_weights ?? {}) },
target_ip_priorities: { ...(primary?.target_ip_priorities ?? {}) },
}
}
export function pruneIpFromBindings(
bindings: ServiceBindingDraft[],
ip: string,
): ServiceBindingDraft[] {
return bindings.flatMap((binding) => {
if (binding.record_type !== 'A') return [binding]
if (!binding.target_ips.includes(ip)) return [binding]
const target_ips = binding.target_ips.filter((item) => item !== ip)
if (target_ips.length === 0) return []
return [
{
...binding,
target_ips,
target_ip_weights: omitKey(binding.target_ip_weights, ip),
target_ip_priorities: omitKey(binding.target_ip_priorities, ip),
},
]
})
}
export function removeAddressNode(state: AddressBlockState, ip: string): AddressBlockState {
return {
...state,
nodes: state.nodes.filter((node) => node.ip !== ip),
otherBindings: pruneIpFromBindings(state.otherBindings, ip),
target_ip_weights: omitKey(state.target_ip_weights, ip),
target_ip_priorities: omitKey(state.target_ip_priorities, ip),
}
}
export function addAddressNode(state: AddressBlockState, ip: string): AddressBlockState {
const trimmed = ip.trim()
if (!trimmed || state.nodes.some((node) => node.ip === trimmed)) {
return state
}
return {
...state,
nodes: [...state.nodes, { ip: trimmed, extraFqdn: '' }],
target_ip_weights: { ...state.target_ip_weights, [trimmed]: 1 },
target_ip_priorities: { ...state.target_ip_priorities, [trimmed]: 1 },
}
}
export function toAddressBindings(
state: AddressBlockState,
primary: AddressPrimaryMeta,
): ServiceBindingDraft[] {
const ips = state.nodes.map((node) => node.ip)
const weights = Object.fromEntries(
ips.map((ip) => [ip, state.target_ip_weights[ip] ?? 1]),
)
const priorities = Object.fromEntries(
ips.map((ip) => [ip, state.target_ip_priorities[ip] ?? 1]),
)
const drafts: ServiceBindingDraft[] = []
const hasPrimary = Boolean(state.commonFqdn.trim()) || ips.length > 0
if (hasPrimary) {
drafts.push({
fqdn: state.commonFqdn,
record_type: 'A',
target_ips: ips,
target_cname: '',
lb_mode: primary.lb_mode,
health: { ...primary.health },
target_ip_weights: weights,
target_ip_priorities: priorities,
})
}
for (const node of state.nodes) {
const extraFqdn = node.extraFqdn.trim()
if (!extraFqdn) continue
drafts.push({
fqdn: extraFqdn,
record_type: 'A',
target_ips: [node.ip],
target_cname: '',
lb_mode: primary.lb_mode,
health: { ...primary.health },
target_ip_weights: { [node.ip]: weights[node.ip] ?? 1 },
target_ip_priorities: { [node.ip]: priorities[node.ip] ?? 1 },
})
}
drafts.push(...state.otherBindings)
return drafts
}
export function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
return bindings
.filter((binding) => {
if (!binding.fqdn.trim()) return false
if (binding.record_type === 'CNAME') return Boolean(binding.target_cname.trim())
return binding.target_ips.length > 0
})
.map((binding) =>
binding.record_type === 'CNAME'
? {
fqdn: binding.fqdn.trim(),
target_cname: binding.target_cname.trim(),
lb_mode: binding.lb_mode,
health_check_enabled: binding.health.enabled,
health_check_type: binding.health.type,
health_check_port: binding.health.port,
health_check_path: binding.health.path,
health_check_expected_status: binding.health.expected_status,
health_check_interval_sec: binding.health.interval_sec,
health_check_timeout_ms: binding.health.timeout_ms,
health_check_verify_tls: binding.health.verify_tls,
health_check_provider: binding.health.provider,
health_check_providers: binding.health.providers,
health_check_aggregate: binding.health.aggregate,
}
: {
fqdn: binding.fqdn.trim(),
target_ips: binding.target_ips,
target_ip_weights: binding.target_ip_weights,
target_ip_priorities: binding.target_ip_priorities,
lb_mode: binding.lb_mode,
health_check_enabled: binding.health.enabled,
health_check_type: binding.health.type,
health_check_port: binding.health.port,
health_check_path: binding.health.path,
health_check_expected_status: binding.health.expected_status,
health_check_interval_sec: binding.health.interval_sec,
health_check_timeout_ms: binding.health.timeout_ms,
health_check_verify_tls: binding.health.verify_tls,
health_check_provider: binding.health.provider,
health_check_providers: binding.health.providers,
health_check_aggregate: binding.health.aggregate,
},
)
}
export function toDomainsPayload(
state: AddressBlockState,
primary: AddressPrimaryMeta,
) {
return buildDomainsPayload(toAddressBindings(state, primary))
}
+30 -1
View File
@@ -1,11 +1,14 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { certificateSchema } from '@/lib/schemas'
import { certificateSchema, serviceCertificateRowSchema } from '@/lib/schemas'
import type { CertMonitoring } from '@cfdm/shared'
import { z } from 'zod'
export const certKeys = {
all: ['certificates'] as const,
summary: ['certificates', 'summary'] as const,
byService: (serviceId: number) =>
[...certKeys.all, 'service', serviceId] as const,
}
export const certificatesQueryOptions = () =>
@@ -23,3 +26,29 @@ export const certSummaryQueryOptions = () =>
queryKey: certKeys.summary,
queryFn: () => api.get<[string, number][]>('/api/v1/certificates/summary'),
})
export const serviceCertificatesQueryOptions = (serviceId: number) =>
queryOptions({
queryKey: certKeys.byService(serviceId),
queryFn: async () => {
const data = await api.get<unknown[]>(
`/api/v1/services/${serviceId}/certificates`,
)
return z.array(serviceCertificateRowSchema).parse(data)
},
})
export async function patchBindingCertMonitoring(
bindingId: number,
certMonitoring: CertMonitoring,
) {
return api.patch(`/api/v1/service-bindings/${bindingId}`, {
cert_monitoring: certMonitoring,
})
}
export async function checkServiceCertificates(serviceId: number) {
return api.post<{ checked: number }>(
`/api/v1/services/${serviceId}/certificates/check`,
)
}
+21
View File
@@ -1,6 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import {
healthProbeLogSchema,
serviceBindingSchema,
serviceGroupsResponseSchema,
serviceViewSchema,
@@ -87,8 +88,28 @@ export async function deleteServiceBinding(id: number) {
export const serviceDetailKeys = {
overview: (id: number) => [...serviceKeys.all, id, 'overview'] as const,
nodes: (id: number) => [...serviceKeys.all, id, 'nodes'] as const,
healthLog: (id: number) => [...serviceKeys.all, id, 'health-log'] as const,
view: (id: number) => [...serviceKeys.all, id, 'view'] as const,
}
export const serviceViewQueryOptions = (id: number) =>
queryOptions({
queryKey: serviceDetailKeys.view(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/services/${id}`)
return serviceViewSchema.parse(data)
},
})
export const serviceHealthLogQueryOptions = (id: number) =>
queryOptions({
queryKey: serviceDetailKeys.healthLog(id),
queryFn: async () => {
const data = await api.get<unknown>(`/api/v1/services/${id}/health-log`)
return z.object({ items: z.array(healthProbeLogSchema) }).parse(data)
},
})
export const serviceOverviewQueryOptions = (id: number) =>
queryOptions({
queryKey: serviceDetailKeys.overview(id),
+2 -2
View File
@@ -74,7 +74,7 @@ function CertificatesPage() {
<PageShell>
<PageHeader
title="Сертификаты"
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
actions={primaryAction}
/>
<CertKpiStats
@@ -86,7 +86,7 @@ function CertificatesPage() {
/>
<ResourcePage
title="Сертификаты"
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
hideHeader
tabs={CERT_TABS.map((tab) => ({ ...tab }))}
activeTab={activeTab}
@@ -6,11 +6,9 @@ import {
GlobeIcon,
Link2Icon,
ServerIcon,
ShieldCheckIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters'
import type { CertMonitoring } from '@cfdm/shared'
import {
domainDetailQueryOptions,
domainServiceBindingsQueryOptions,
@@ -41,19 +39,11 @@ import {
import { DomainBindingsPanel } from '@/components/domain-bindings-panel'
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
import { StatusBadge } from '@/components/status-badge'
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatDate } from '@/lib/format'
import { Badge } from '@/components/reui/badge'
import { LoadingButton } from '@/components/loading-button'
import { TableSkeleton } from '@/components/skeletons'
import { Button } from '@cfdm/ui/components/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@cfdm/ui/components/select'
import { TabsContent } from '@cfdm/ui/components/tabs'
export const Route = createFileRoute('/_auth/domains/$domainId/')({
@@ -108,7 +98,6 @@ function DomainOverviewPage() {
syncMutation,
createSubdomainMutation,
updateSubdomainMutation,
updateDomainCertMonitoringMutation,
deleteSubdomainMutation,
linkServiceMutation,
} = useDomainPage(id)
@@ -167,20 +156,15 @@ function DomainOverviewPage() {
if (!editTarget) return
const nameChanged = values.name !== editTarget.subdomain.name
const certMonitoringChanged =
values.certMonitoring !== editTarget.subdomain.cert_monitoring
const currentServiceId = resolveServiceId(editTarget)
const serviceChanged = values.serviceId !== currentServiceId
const targetServiceId =
values.serviceId === 'none' ? null : Number(values.serviceId)
if (nameChanged || certMonitoringChanged) {
if (nameChanged) {
await updateSubdomainMutation.mutateAsync({
id: editTarget.subdomain.id,
...(nameChanged ? { name: values.name } : {}),
...(certMonitoringChanged
? { cert_monitoring: values.certMonitoring }
: {}),
name: values.name,
})
}
@@ -209,11 +193,6 @@ function DomainOverviewPage() {
updateSubdomainMutation.isPending ||
linkServiceMutation.isPending
const certMonitoringItems = certMonitoringOptions.map((option) => ({
label: option.label,
value: option.value,
}))
const metricCards = useMemo(() => {
if (!domain) return []
return [
@@ -344,40 +323,6 @@ function DomainOverviewPage() {
>
<TabsContent value="overview" className="flex flex-col gap-4">
<DetailPanel.Metrics cards={metricCards} />
<DetailPanel.Section
title="Мониторинг SSL"
description="Настройка проверки сертификата для apex-зоны"
>
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
<span className="text-muted-foreground flex items-center gap-2 text-sm">
<ShieldCheckIcon className="size-4" aria-hidden="true" />
Мониторинг SSL (apex):
</span>
<Select
items={certMonitoringItems}
value={domain.cert_monitoring}
onValueChange={(value) =>
updateDomainCertMonitoringMutation.mutate(
(value ?? 'auto') as CertMonitoring,
)
}
disabled={updateDomainCertMonitoringMutation.isPending}
>
<SelectTrigger className="w-full sm:w-56">
<SelectValue>
{certMonitoringLabel(domain.cert_monitoring)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{certMonitoringOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</DetailPanel.Section>
</TabsContent>
<TabsContent value="availability" className="flex flex-col gap-4">
@@ -1,143 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { DetailPanel } from '@/components/reui-kit'
import { EmptyState } from '@/components/empty-state'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { HealthProviderToggle } from '@/components/health-check-config-fields'
import {
createOriginHealthCheck,
listOriginHealthChecks,
serviceOverviewQueryOptions,
} from '@/queries'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
component: ServiceHealthPage,
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
})
export function ServiceHealthPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const queryClient = useQueryClient()
const overview = useQuery(serviceOverviewQueryOptions(id))
const checksQuery = useQuery({
queryKey: ['health-checks'],
queryFn: listOriginHealthChecks,
})
const [open, setOpen] = useState(false)
const form = useForm<{
name: string
provider: 'local' | 'cloudflare'
protocol: string
}>({
defaultValues: { name: '', provider: 'local', protocol: 'tcp' },
})
const provider = form.watch('provider')
const checks = (checksQuery.data ?? []) as Array<{
id: number
name: string
provider: string
protocol: string
}>
const createMut = useMutation({
mutationFn: (values: { name: string; provider: 'local' | 'cloudflare'; protocol: string }) =>
createOriginHealthCheck({
name: values.name,
provider: values.provider,
protocol: values.protocol,
}),
onSuccess: async () => {
toast.success('Health check сохранён')
await queryClient.invalidateQueries({ queryKey: ['health-checks'] })
setOpen(false)
},
onError: (e: unknown) =>
toast.error(
e instanceof Error
? e.message
: 'Cloudflare Health Checks недоступны для этой зоны',
),
})
void overview
return (
<DetailPanel>
<DetailPanel.Header
title="Health checks"
description="Local TCP/HTTP или официальный Cloudflare Health Checks API."
actions={
<Button size="sm" onClick={() => setOpen(true)}>
Добавить проверку
</Button>
}
/>
{checks.length === 0 ? (
<EmptyState
title="Нет проверок"
description="Локальные пробы уже работают на привязках. Cloudflare Health Checks — опционально."
/>
) : (
<div className="flex flex-col gap-2">
{checks.map((check) => (
<div
key={check.id}
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
>
<div className="flex flex-col gap-1">
<span className="font-medium">{check.name}</span>
<span className="text-muted-foreground text-xs">
{check.provider} · {check.protocol}
</span>
</div>
</div>
))}
</div>
)}
<FormSheet
open={open}
onOpenChange={setOpen}
title="Health check"
description="Поля Cloudflare соответствуют официальному API (address, type, interval, timeout, retries)."
form={form}
onSubmit={(values) => createMut.mutate(values)}
footer={
<LoadingButton type="submit" isLoading={createMut.isPending}>
Сохранить
</LoadingButton>
}
>
<FormFieldSimple label="Имя" htmlFor="hc-name">
<Input id="hc-name" {...form.register('name')} />
</FormFieldSimple>
<FormFieldSimple label="Провайдер" htmlFor="hc-provider">
<HealthProviderToggle
id="hc-provider"
value={provider}
onChange={(next) => form.setValue('provider', next)}
/>
</FormFieldSimple>
{provider === 'cloudflare' ? (
<Alert>
<AlertTitle>Cloudflare Health Checks</AlertTitle>
<AlertDescription>
Если зона не поддерживает Health Checks, вернётся ошибка плана останется
Local. Workers не используются.
</AlertDescription>
</Alert>
) : null}
<FormFieldSimple label="Протокол" htmlFor="hc-protocol">
<Input id="hc-protocol" {...form.register('protocol')} placeholder="tcp" />
</FormFieldSimple>
</FormSheet>
</DetailPanel>
)
}
@@ -1,94 +1,431 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
import { DetailPanel } from '@/components/reui-kit'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
ActivityIcon,
GlobeIcon,
NetworkIcon,
PencilIcon,
ServerIcon,
} from 'lucide-react'
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
import { ChangeIpSheet } from '@/components/change-ip-sheet'
import { EmptyState } from '@/components/empty-state'
import { FailoverTimeline } from '@/components/failover-timeline'
import { FormFieldSimple } from '@/components/form-field'
import { FormSheet } from '@/components/form-sheet'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { Badge } from '@/components/reui/badge'
import { serviceOverviewQueryOptions } from '@/queries'
import { LoadingButton } from '@/components/loading-button'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
import {
ServiceDetailGrid,
type ServiceFqdnRow,
} from '@/components/services/service-detail-grid'
import { LbModeTile } from '@/components/services/service-unit-card'
import {
KpiStatGrid,
ServiceHealthMonitor,
} from '@/components/reui-kit'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { api } from '@/lib/api-client'
import {
enabledHealthProviders,
providerHealthStatuses,
} from '@/lib/health-log'
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
import {
createServiceNode,
deleteServiceNode,
domainKeys,
domainsListQueryOptions,
serviceBindingKeys,
serviceDetailKeys,
serviceGroupKeys,
serviceGroupsQueryOptions,
serviceHealthLogQueryOptions,
serviceKeys,
serviceNodesQueryOptions,
serviceOverviewQueryOptions,
serviceViewQueryOptions,
} from '@/queries'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
export const Route = createFileRoute('/_auth/services/$serviceId/')({
component: ServiceOverviewPage,
component: ServiceDetailPage,
})
function ServiceOverviewPage() {
const { serviceId } = Route.useParams()
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
const overview = data as {
service: {
name: string
enabled: boolean
health_status: 'up' | 'down' | 'degraded' | 'unknown'
domains: Array<{ fqdn: string; zone_name: string }>
}
nodes: Array<{ id: number; address: string; health_status: string }>
routing_strategy: string
active_addresses: string[]
} | undefined
interface OverviewPayload {
routing_strategy?: string
active_addresses?: string[]
nodes?: Array<{
id: number
address: string
protocol: string
port: number | null
health_status: string
weight: number
priority: number
consecutive_failures: number
last_failure_reason: string | null
}>
}
if (!overview) {
return (
<EmptyState
title="Сервис не найден"
description="Вернитесь в каталог и выберите сервис."
/>
)
function ServiceDetailPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const navigate = useNavigate()
const queryClient = useQueryClient()
const viewQuery = useQuery(serviceViewQueryOptions(id))
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
const groupsQuery = useQuery(serviceGroupsQueryOptions())
const domainsQuery = useQuery(domainsListQueryOptions())
const service = viewQuery.data
const overview = overviewQuery.data as OverviewPayload | undefined
const logItems = useMemo(
() => logQuery.data?.items ?? [],
[logQuery.data?.items],
)
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
const [editOpen, setEditOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [togglingIp, setTogglingIp] = useState<string | null>(null)
const [changeIp, setChangeIp] = useState<{
bindingId: number
ip?: string
} | null>(null)
const [changeDomain, setChangeDomain] = useState(false)
const [addNodeOpen, setAddNodeOpen] = useState(false)
const nodeForm = useForm<{ address: string; port: string }>({
defaultValues: { address: '', port: '' },
})
const groups = groupsQuery.data
? [...groupsQuery.data.groups]
: []
async function invalidateService() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
])
}
const nodes = overview.nodes ?? []
const domains = overview.service.domains ?? []
const updateMutation = useMutation({
mutationFn: ({ body }: { body: UpdateServiceConfigInput }) =>
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
onSuccess: async () => {
await invalidateService()
setEditOpen(false)
toast.success('Сервис сохранён')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
},
onSettled: () => setSaving(false),
})
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/api/v1/services/${id}`),
onSuccess: async () => {
await invalidateService()
toast.success('Сервис удалён')
await navigate({ to: '/services' })
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
},
})
const toggleIpMutation = useMutation({
mutationFn: ({ ip, enabled }: { ip: string; enabled: boolean }) =>
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, { ip, enabled }),
onSuccess: async (_data, { enabled }) => {
await invalidateService()
toast.success(
enabled
? 'IP включён и добавлен в DNS-привязки'
: 'IP выключен и снят с DNS-привязок',
)
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось переключить IP')
},
onSettled: () => setTogglingIp(null),
})
const createNodeMut = useMutation({
mutationFn: (values: { address: string; port: string }) =>
createServiceNode(id, {
address: values.address.trim(),
port: values.port ? Number(values.port) : null,
}),
onSuccess: async () => {
toast.success('Нода добавлена, статус CHECKING')
await invalidateService()
setAddNodeOpen(false)
nodeForm.reset()
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
})
const deleteNodeMut = useMutation({
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
onSuccess: async () => {
toast.success('Нода удалена')
await invalidateService()
},
})
const isLoading = viewQuery.isLoading || overviewQuery.isLoading
const isError = viewQuery.isError || overviewQuery.isError
const error = viewQuery.error ?? overviewQuery.error
const failoverEvents =
(nodes.length > 0 ? nodes : (overview?.nodes ?? []))
.filter(
(node) =>
node.health_status === 'unhealthy' ||
node.health_status === 'down' ||
node.health_status === 'checking',
)
.map((node) => ({
id: node.address,
title: `${node.address}: ${node.health_status}`,
detail: node.last_failure_reason
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
: `fail ${node.consecutive_failures}`,
}))
const enabledProviders = useMemo(
() => enabledHealthProviders(service?.domains ?? []),
[service],
)
const providerStatuses = useMemo(
() => providerHealthStatuses(logItems, enabledProviders),
[logItems, enabledProviders],
)
return (
<DetailPanel>
<DetailPanel.Header
title={overview.service.name}
description={`Маршрутизация: ${overview.routing_strategy}. Активные IP: ${
overview.active_addresses.join(', ') || '—'
}`}
actions={
<HealthCheckBadge status={overview.service.health_status} />
}
/>
<DetailPanel.Metrics
cards={[
{
id: 'subdomains',
icon: <GlobeIcon />,
label: 'Поддомены',
description:
domains.length > 0
? domains.map((d) => d.fqdn).join(', ')
: 'Нет привязанных FQDN',
footer: <Badge variant="outline">{domains.length}</Badge>,
},
{
id: 'nodes',
icon: <ServerIcon />,
label: 'Ноды',
description:
nodes.length > 0
? nodes.map((n) => n.address).join(', ')
: 'Добавьте ноду, чтобы публиковать DNS',
footer: <Badge variant="outline">{nodes.length}</Badge>,
},
{
id: 'health',
icon: <ActivityIcon />,
label: 'Пул',
description:
overview.active_addresses.length > 0
? 'Здоровые адреса участвуют в DNS'
: 'unknown не попадает в пул, пока не станет healthy',
},
]}
/>
{domains.length === 0 && nodes.length === 0 ? (
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => {
void viewQuery.refetch()
void overviewQuery.refetch()
}}
>
{!service ? (
<EmptyState
title="Пустой сервис"
description="Добавьте поддомен и ноду, затем настройте health-check."
stackedIcon
title="Сервис не найден"
description="Вернитесь в каталог и выберите сервис."
/>
) : null}
</DetailPanel>
) : (
<div className="@container flex w-full flex-col gap-4 md:gap-6">
<PageHeader
title={service.name}
description="Domain → Service → Node → Health → Failover"
actions={
<>
<LbModeTile mode={service.lb_mode} />
<HealthCheckBadge status={service.health_status} />
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Изменить"
onClick={() => setEditOpen(true)}
/>
}
>
<PencilIcon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>Изменить</TooltipContent>
</Tooltip>
</>
}
/>
<KpiStatGrid
cards={[
{
id: 'status',
icon: <ActivityIcon />,
label: 'Статус',
value: service.health_status === 'up' ? 'OK' : service.health_status,
variant:
service.health_status === 'down'
? 'destructive'
: service.health_status === 'degraded'
? 'warning'
: 'default',
iconClassName:
service.health_status === 'down'
? 'text-destructive'
: service.health_status === 'degraded'
? 'text-warning'
: 'text-success',
hint: <HealthCheckBadge status={service.health_status} size="xs" />,
},
{
id: 'fqdn',
icon: <GlobeIcon />,
label: 'FQDN',
value: String(service.domains.length),
hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN',
},
{
id: 'ip',
icon: <NetworkIcon />,
label: 'IP',
value: String(service.ips.length),
hint: `${service.active_ips.length} в пуле`,
},
{
id: 'pool',
icon: <ServerIcon />,
label: 'Активный пул',
value: String((overview?.active_addresses ?? service.active_ips).length),
hint: (overview?.active_addresses ?? service.active_ips).join(', ') || 'нет',
},
]}
/>
<section
aria-label="Мониторинг"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
>
<ServiceHealthMonitor
items={logItems}
enabledProviders={enabledProviders}
statuses={providerStatuses}
isLoading={logQuery.isLoading}
/>
<Frame dense spacing="sm" className="min-w-0 w-full">
<FrameHeader>
<FrameTitle>Failover</FrameTitle>
<FrameDescription>
Нездоровые ноды и причины последней ошибки
</FrameDescription>
</FrameHeader>
<FramePanel>
<FailoverTimeline events={failoverEvents} />
</FramePanel>
</Frame>
</section>
{service.ips.length === 0 && service.domains.length === 0 ? (
<EmptyState
title="Пустой сервис"
description="Добавьте поддомен и ноду, затем настройте health-check."
stackedIcon
/>
) : (
<ServiceDetailGrid
service={service}
nodes={nodes}
togglingIp={togglingIp}
onToggleIp={(ip, enabled) => {
setTogglingIp(ip)
toggleIpMutation.mutate({ ip, enabled })
}}
onChangeIp={(row: ServiceFqdnRow) =>
setChangeIp({
bindingId: row.binding_id,
ip: row.target_ips[0],
})
}
onChangeDomain={() => setChangeDomain(true)}
onAddNode={() => setAddNodeOpen(true)}
onDeleteNode={(nodeId) => deleteNodeMut.mutate(nodeId)}
isLoading={nodesQuery.isLoading}
/>
)}
<ServiceEditSheet
mode="edit"
service={service}
groups={groups}
open={editOpen}
knownDomains={domainsQuery.data ?? []}
isSaving={saving}
isDeleting={deleteMutation.isPending}
onOpenChange={setEditOpen}
onSave={(_serviceId, body) => {
setSaving(true)
updateMutation.mutate({ body })
}}
onDelete={() => deleteMutation.mutate()}
/>
<ChangeIpSheet
open={changeIp != null}
onOpenChange={(open) => {
if (!open) setChangeIp(null)
}}
bindingId={changeIp?.bindingId ?? null}
serviceId={id}
currentIp={changeIp?.ip}
/>
<ChangeDomainSheet
open={changeDomain}
onOpenChange={setChangeDomain}
serviceId={id}
fromDomainId={service.domains[0]?.domain_id ?? null}
/>
<FormSheet
open={addNodeOpen}
onOpenChange={setAddNodeOpen}
title="Добавить ноду"
description="IP станет CHECKING до порога успешных проверок."
form={nodeForm}
onSubmit={(values) => createNodeMut.mutate(values)}
footer={
<LoadingButton type="submit" isLoading={createNodeMut.isPending}>
Добавить
</LoadingButton>
}
>
<FormFieldSimple label="IP" htmlFor="address">
<Input id="address" {...nodeForm.register('address')} placeholder="10.0.0.10" />
</FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
<Input id="port" {...nodeForm.register('port')} placeholder="443" />
</FormFieldSimple>
</FormSheet>
</div>
)}
</QueryState>
)
}
@@ -1,143 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { PlusIcon } from 'lucide-react'
import { DetailPanel } from '@/components/reui-kit'
import { EmptyState } from '@/components/empty-state'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { createServiceNode, deleteServiceNode, serviceNodesQueryOptions } from '@/queries'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({
component: ServiceNodesPage,
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
})
interface NodeRow {
id: number
address: string
port: number | null
protocol: string
health_status: 'up' | 'down' | 'degraded' | 'unknown' | 'healthy' | 'unhealthy' | 'checking' | 'disabled'
weight: number
priority: number
}
function mapHealth(
status: NodeRow['health_status'],
): 'up' | 'down' | 'degraded' | 'unknown' {
if (status === 'healthy' || status === 'up') return 'up'
if (status === 'unhealthy' || status === 'down') return 'down'
if (status === 'degraded') return 'degraded'
return 'unknown'
}
export function ServiceNodesPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const queryClient = useQueryClient()
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
const nodes = (nodesQuery.data ?? []) as NodeRow[]
const [open, setOpen] = useState(false)
const form = useForm<{ address: string; port: string }>({
defaultValues: { address: '', port: '' },
})
const createMut = useMutation({
mutationFn: (values: { address: string; port: string }) =>
createServiceNode(id, {
address: values.address.trim(),
port: values.port ? Number(values.port) : null,
}),
onSuccess: async () => {
toast.success('Нода добавлена, статус CHECKING')
await queryClient.invalidateQueries({ queryKey: ['services'] })
setOpen(false)
form.reset()
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
})
const deleteMut = useMutation({
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
onSuccess: async () => {
toast.success('Нода удалена')
await queryClient.invalidateQueries({ queryKey: ['services'] })
},
})
return (
<DetailPanel>
<DetailPanel.Header
title="Ноды"
description="Адреса происхождения сервиса."
actions={
<Button size="sm" onClick={() => setOpen(true)}>
<PlusIcon className="size-4" aria-hidden />
Добавить ноду
</Button>
}
/>
{nodes.length === 0 ? (
<EmptyState
title="Нет нод"
description="Добавьте IP, затем настройте health-check."
/>
) : (
<div className="flex flex-col gap-2">
{nodes.map((node) => (
<div
key={node.id}
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
>
<div className="flex flex-col gap-1">
<span className="font-medium">{node.address}</span>
<span className="text-muted-foreground text-xs">
{node.protocol}
{node.port ? `:${node.port}` : ''} · вес {node.weight} · приоритет{' '}
{node.priority}
</span>
</div>
<div className="flex items-center gap-2">
<HealthCheckBadge status={mapHealth(node.health_status)} />
<Button
size="sm"
variant="outline"
onClick={() => deleteMut.mutate(node.id)}
>
Удалить
</Button>
</div>
</div>
))}
</div>
)}
<FormSheet
open={open}
onOpenChange={setOpen}
title="Добавить ноду"
description="IP станет CHECKING до порога успешных проверок."
form={form}
onSubmit={(values) => createMut.mutate(values)}
footer={
<LoadingButton type="submit" isLoading={createMut.isPending}>
Добавить
</LoadingButton>
}
>
<FormFieldSimple label="IP" htmlFor="address">
<Input id="address" {...form.register('address')} placeholder="10.0.0.10" />
</FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
<Input id="port" {...form.register('port')} placeholder="443" />
</FormFieldSimple>
</FormSheet>
</DetailPanel>
)
}
@@ -1,79 +1,30 @@
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowLeftIcon } from 'lucide-react'
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { Button } from '@cfdm/ui/components/button'
import { serviceOverviewQueryOptions } from '@/queries'
import { cn } from '@cfdm/ui/lib/utils'
import {
serviceHealthLogQueryOptions,
serviceNodesQueryOptions,
serviceOverviewQueryOptions,
serviceViewQueryOptions,
} from '@/queries'
export const Route = createFileRoute('/_auth/services/$serviceId')({
loader: ({ context: { queryClient }, params }) =>
queryClient.ensureQueryData(serviceOverviewQueryOptions(Number(params.serviceId))),
loader: async ({ context: { queryClient }, params }) => {
const id = Number(params.serviceId)
const [view] = await Promise.all([
queryClient.ensureQueryData(serviceViewQueryOptions(id)),
queryClient.ensureQueryData(serviceOverviewQueryOptions(id)),
queryClient.ensureQueryData(serviceHealthLogQueryOptions(id)),
queryClient.ensureQueryData(serviceNodesQueryOptions(id)),
])
return { breadcrumb: view.name }
},
component: ServiceLayout,
})
const tabs = [
{ to: '/services/$serviceId', label: 'Обзор', exact: true },
{ to: '/services/$serviceId/subdomains', label: 'Поддомены', exact: false },
{ to: '/services/$serviceId/nodes', label: 'Ноды', exact: false },
{ to: '/services/$serviceId/health', label: 'Health', exact: false },
{ to: '/services/$serviceId/routing', label: 'Маршрутизация', exact: false },
] as const
function ServiceLayout() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const pathname = useRouterState({ select: (s) => s.location.pathname })
const overview = useQuery(serviceOverviewQueryOptions(id))
const name = (overview.data as { service?: { name?: string } } | undefined)?.service?.name
return (
<PageShell>
<PageHeader
title={name ?? 'Сервис'}
description="Domain → Service → Node → Health → Failover"
actions={
<Button
variant="outline"
size="sm"
render={<Link to="/services" />}
>
<ArrowLeftIcon className="size-4" aria-hidden />
К каталогу
</Button>
}
/>
<nav className="flex flex-wrap gap-4 border-b">
{tabs.map((tab) => {
const href = tab.to.replace('$serviceId', serviceId)
const active = tab.exact
? pathname === `/services/${serviceId}` || pathname === `/services/${serviceId}/`
: pathname.startsWith(href)
return (
<Link
key={tab.to}
to={tab.to}
params={{ serviceId }}
className={cn(
'text-muted-foreground hover:text-foreground pb-3 text-sm font-medium',
active && 'text-foreground border-b-2 border-primary',
)}
>
{tab.label}
</Link>
)
})}
</nav>
<QueryState
isLoading={overview.isLoading}
isError={overview.isError}
error={overview.error}
onRetry={() => void overview.refetch()}
>
<Outlet />
</QueryState>
<Outlet />
</PageShell>
)
}
@@ -1,59 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { DetailPanel } from '@/components/reui-kit'
import { FailoverTimeline } from '@/components/failover-timeline'
import { Badge } from '@/components/reui/badge'
import { serviceOverviewQueryOptions } from '@/queries'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({
component: ServiceRoutingPage,
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
})
export function ServiceRoutingPage() {
const { serviceId } = Route.useParams()
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
const overview = data as {
routing_strategy: string
active_addresses: string[]
nodes: Array<{
address: string
health_status: string
consecutive_failures: number
last_failure_reason: string | null
}>
} | undefined
const events =
overview?.nodes
.filter(
(node) =>
node.health_status === 'unhealthy' ||
node.health_status === 'down' ||
node.health_status === 'checking',
)
.map((node) => ({
id: node.address,
title: `${node.address}: ${node.health_status}`,
detail: node.last_failure_reason
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
: `fail ${node.consecutive_failures}`,
})) ?? []
return (
<DetailPanel>
<DetailPanel.Header
title="Маршрутизация"
description="Round Robin / Failover. Weighted на DNS = alias Round Robin."
actions={<Badge variant="outline">{overview?.routing_strategy ?? 'round_robin'}</Badge>}
/>
<p className="text-sm">
Активные адреса:{' '}
{overview?.active_addresses.join(', ') || 'нет (unknown не в пуле)'}
</p>
<p className="text-muted-foreground text-xs">
Запись обновляется в Cloudflare. Распространение зависит от TTL.
</p>
<FailoverTimeline events={events} />
</DetailPanel>
)
}
@@ -1,114 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ArrowRightLeftIcon } from 'lucide-react'
import { DetailPanel } from '@/components/reui-kit'
import { EmptyState } from '@/components/empty-state'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@/components/reui/badge'
import { ChangeIpSheet } from '@/components/change-ip-sheet'
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
import { serviceOverviewQueryOptions } from '@/queries'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({
component: ServiceSubdomainsPage,
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
})
export function ServiceSubdomainsPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const { data } = useQuery(serviceOverviewQueryOptions(id))
const overview = data as {
service: {
domains: Array<{
binding_id: number
domain_id: number
fqdn: string
zone_name: string
target_ips: string[]
}>
}
} | undefined
const rows = overview?.service.domains ?? []
const [changeIp, setChangeIp] = useState<{
bindingId: number
ip?: string
} | null>(null)
const [changeDomain, setChangeDomain] = useState(false)
const fromDomainId = useMemo(
() => rows[0]?.domain_id ?? null,
[rows],
)
return (
<DetailPanel>
<DetailPanel.Header
title="Поддомены"
description="FQDN сервиса в одной или нескольких зонах Cloudflare."
actions={
<Button
variant="outline"
size="sm"
onClick={() => setChangeDomain(true)}
disabled={rows.length === 0}
>
<ArrowRightLeftIcon className="size-4" aria-hidden />
Сменить домен
</Button>
}
/>
{rows.length === 0 ? (
<EmptyState
title="Нет поддоменов"
description="Привяжите FQDN к сервису из карточки редактирования."
/>
) : (
<div className="flex flex-col gap-2">
{rows.map((row) => (
<div
key={row.binding_id}
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
>
<div className="flex min-w-0 flex-col gap-1">
<span className="font-medium">{row.fqdn}</span>
<span className="text-muted-foreground text-xs">
{row.zone_name} · {row.target_ips.join(', ') || 'нет IP'}
</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{row.target_ips.length} IP</Badge>
<Button
size="sm"
variant="outline"
onClick={() =>
setChangeIp({
bindingId: row.binding_id,
ip: row.target_ips[0],
})
}
>
Сменить IP
</Button>
</div>
</div>
))}
</div>
)}
<ChangeIpSheet
open={changeIp != null}
onOpenChange={(open) => {
if (!open) setChangeIp(null)
}}
bindingId={changeIp?.bindingId ?? null}
serviceId={id}
currentIp={changeIp?.ip}
/>
<ChangeDomainSheet
open={changeDomain}
onOpenChange={setChangeDomain}
serviceId={id}
fromDomainId={fromDomainId}
/>
</DetailPanel>
)
}
+313 -6
View File
@@ -5,7 +5,7 @@ import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { HeartPulseIcon } from 'lucide-react'
import { HeartPulseIcon, GlobeIcon } from 'lucide-react'
import { api } from '@/lib/api-client'
import { SettingRow } from '@/components/setting-row'
@@ -27,6 +27,9 @@ import {
} from '@/components/reui/number-field'
import { FieldGroup } from '@cfdm/ui/components/field'
import { Input } from '@cfdm/ui/components/input'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import { Button } from '@cfdm/ui/components/button'
const formSchema = z.object({
healthCheckCron: z.string().trim().min(1, 'Укажите cron').max(64),
@@ -44,10 +47,27 @@ const formSchema = z.object({
}
})
const globalpingSchema = z.object({
globalpingToken: z.string().optional(),
globalpingLocations: z.string().trim().min(1).max(200),
globalpingLimit: z.number().int().min(1).max(10),
})
type FormValues = z.infer<typeof formSchema>
type GlobalpingValues = z.infer<typeof globalpingSchema>
type HealthWorkerStatus = 'missing' | 'ready' | 'error'
type SettingsResponse = FormValues & {
id: string
healthWorkerUrl?: string
healthWorkerStatus?: HealthWorkerStatus
healthWorkerError?: string | null
healthWorkerDeployedAt?: string | null
healthWorkerLastIngestAt?: string | null
healthWorkerKvNamespaceId?: string
globalpingTokenSet?: boolean
globalpingLocations?: string
globalpingLimit?: number
}
export const Route = createFileRoute('/_auth/settings/health')({
@@ -90,6 +110,28 @@ function CompactNumberInput({
)
}
function statusBadge(status: HealthWorkerStatus | undefined) {
if (status === 'ready') {
return (
<Badge variant="success-light" size="sm">
Готов
</Badge>
)
}
if (status === 'error') {
return (
<Badge variant="destructive-light" size="sm">
Ошибка
</Badge>
)
}
return (
<Badge variant="outline" size="sm">
Не создан
</Badge>
)
}
function HealthSettingsPage() {
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({
@@ -108,6 +150,15 @@ function HealthSettingsPage() {
},
})
const gpForm = useForm<GlobalpingValues>({
resolver: zodResolver(globalpingSchema),
defaultValues: {
globalpingToken: '',
globalpingLocations: 'World',
globalpingLimit: 3,
},
})
useEffect(() => {
if (!data) return
form.reset({
@@ -117,11 +168,22 @@ function HealthSettingsPage() {
healthLatencyWarnMs: data.healthLatencyWarnMs,
healthSuccessRecoveries: data.healthSuccessRecoveries,
})
}, [data, form])
gpForm.reset({
globalpingToken: '',
globalpingLocations: data.globalpingLocations || 'World',
globalpingLimit: data.globalpingLimit ?? 3,
})
}, [data, form, gpForm])
const saveMut = useMutation({
mutationFn: (values: FormValues) =>
api.patch<SettingsResponse>('/api/v1/settings', values),
api.patch<SettingsResponse>('/api/v1/settings', {
healthCheckCron: values.healthCheckCron,
healthDegradedFailures: values.healthDegradedFailures,
healthDownFailures: values.healthDownFailures,
healthLatencyWarnMs: values.healthLatencyWarnMs,
healthSuccessRecoveries: values.healthSuccessRecoveries,
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
toast.success('Настройки health-check сохранены')
@@ -130,7 +192,40 @@ function HealthSettingsPage() {
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
const saveGpMut = useMutation({
mutationFn: (values: GlobalpingValues) =>
api.patch<SettingsResponse>('/api/v1/settings', {
globalpingLocations: values.globalpingLocations,
globalpingLimit: values.globalpingLimit,
...(values.globalpingToken?.trim()
? { globalpingToken: values.globalpingToken.trim() }
: {}),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
toast.success('Настройки Globalping сохранены')
gpForm.reset({
...gpForm.getValues(),
globalpingToken: '',
})
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
})
const ensureMut = useMutation({
mutationFn: () =>
api.post<SettingsResponse>('/api/v1/settings/health/worker/ensure'),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
toast.success('Worker создан или обновлён')
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось создать Worker'),
})
return (
<div className="flex w-full flex-col gap-4">
<form
className="flex w-full flex-col gap-4"
onSubmit={(event) =>
@@ -144,15 +239,15 @@ function HealthSettingsPage() {
Local health-check
</FrameTitle>
<FrameDescription>
Расписание и пороги движка. Параметры Cloudflare Health Checks
задаются в карточке сервиса.
Расписание и пороги движка общие для Local, Cloudflare Worker и Globalping.
Тип/порт/path и правило агрегации задаются в карточке сервиса.
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<FieldGroup className="gap-0">
<SettingRow
title="Cron"
description="Расписание проб (6 полей: сек мин час день месяц день-недели). Env: HEALTH_CHECK_CRON."
description="Расписание проб CFDM (6 полей). Worker на edge получает 5-польное cron без секунд. Env: HEALTH_CHECK_CRON."
labelFor="health-cron"
stacked
>
@@ -276,6 +371,218 @@ function HealthSettingsPage() {
</FrameFooter>
</FramePanel>
</Frame>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle className="flex items-center gap-2">
Cloudflare Worker
{statusBadge(data?.healthWorkerStatus)}
</FrameTitle>
<FrameDescription>
Worker сам опрашивает IP/порты с edge. CFDM создаёт скрипт через API
и забирает результаты из KV. Preview:{' '}
<a
href="https://reui.io/preview/base/settings-16"
className="underline"
target="_blank"
rel="noreferrer"
>
settings-16
</a>
.
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-3 p-4">
<Alert variant={data?.healthWorkerStatus === 'error' ? 'destructive' : 'info'}>
<AlertTitle>Не Health Checks API</AlertTitle>
<AlertDescription>
На Free-плане продукта Health Checks нет. Нужен Account-токен с
Workers Scripts Write и Workers KV Storage Write Zone DNS
недостаточно. Лимиты Free: 5 cron на аккаунт, KV 1000 writes/сутки
(интервал 2 мин), до 48 целей за тик.
</AlertDescription>
</Alert>
{data?.healthWorkerError ? (
<Alert variant="destructive">
<AlertTitle>Ошибка деплоя</AlertTitle>
<AlertDescription>{data.healthWorkerError}</AlertDescription>
</Alert>
) : null}
<FieldGroup className="gap-0">
<SettingRow title="Скрипт" compact>
<span className="font-mono text-sm">cfdm-health-probe</span>
</SettingRow>
<SettingRow
title="KV namespace"
description="id mailbox targets/results"
compact
>
<span className="font-mono text-sm break-all">
{data?.healthWorkerKvNamespaceId || '—'}
</span>
</SettingRow>
<SettingRow
title="URL"
description="workers.dev после автодеплоя"
compact
>
<span className="font-mono text-sm break-all">
{data?.healthWorkerUrl || '—'}
</span>
</SettingRow>
<SettingRow
title="Последний деплой"
compact
>
<span className="text-sm text-muted-foreground">
{data?.healthWorkerDeployedAt || '—'}
</span>
</SettingRow>
<SettingRow
title="Последний ingest"
description="colo пишется в журнал проб"
compact
last
>
<span className="text-sm text-muted-foreground">
{data?.healthWorkerLastIngestAt || '—'}
</span>
</SettingRow>
</FieldGroup>
<div className="flex justify-end">
<Button
type="button"
variant="outline"
disabled={ensureMut.isPending}
onClick={() => ensureMut.mutate()}
>
{ensureMut.isPending ? 'Создаём…' : 'Создать / обновить Worker'}
</Button>
</div>
</FramePanel>
</Frame>
</form>
<form
className="flex w-full flex-col gap-4"
onSubmit={(event) =>
void gpForm.handleSubmit((values) => saveGpMut.mutate(values))(event)
}
>
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle className="flex items-center gap-2">
<GlobeIcon className="size-4" aria-hidden />
Globalping
<Badge
variant={data?.globalpingTokenSet ? 'success-light' : 'outline'}
size="sm"
>
{data?.globalpingTokenSet ? 'Токен задан' : 'Нет токена'}
</Badge>
</FrameTitle>
<FrameDescription>
Пробы из сети globalping.io. Poll 500 мс.{' '}
<a
href="https://reui.io/preview/base/settings-16"
className="underline"
target="_blank"
rel="noreferrer"
>
settings-16
</a>
.
</FrameDescription>
</FrameHeader>
<FramePanel className="p-0">
<div className="flex flex-col gap-3 p-4">
<Alert>
<AlertTitle>Лимиты и credits</AlertTitle>
<AlertDescription>
Без токена 250 tests/hour, с токеном 500 +{' '}
<a
href="https://globalping.io/credits"
className="underline"
target="_blank"
rel="noreferrer"
>
credits
</a>
. Токен: dash.globalping.io/tokens. Один measurement на
уникальный IP/порт за тик cron. При десятках IP следите за
hourly credits.
</AlertDescription>
</Alert>
</div>
<FieldGroup className="gap-0">
<SettingRow
title="Токен"
description={
data?.globalpingTokenSet
? 'Оставьте пустым, чтобы не менять сохранённый токен.'
: 'Authorization: Bearer. Без токена источник Globalping = fail.'
}
labelFor="gp-token"
stacked
>
<Input
id="gp-token"
type="password"
autoComplete="off"
placeholder={data?.globalpingTokenSet ? '••••••••' : 'gp_…'}
disabled={isLoading || saveGpMut.isPending}
{...gpForm.register('globalpingToken')}
/>
</SettingRow>
<SettingRow
title="Локации"
description="Magic CSV, например World или EU,US. Default World."
labelFor="gp-locations"
stacked
>
<Input
id="gp-locations"
spellCheck={false}
autoComplete="off"
disabled={isLoading || saveGpMut.isPending}
{...gpForm.register('globalpingLocations')}
/>
</SettingRow>
<SettingRow
title="Проб в measurement"
description="limit 110. Default 3."
labelFor="gp-limit"
compact
last
>
<Controller
control={gpForm.control}
name="globalpingLimit"
render={({ field }) => (
<CompactNumberInput
id="gp-limit"
value={field.value}
min={1}
max={10}
disabled={isLoading || saveGpMut.isPending}
onValueChange={field.onChange}
/>
)}
/>
</SettingRow>
</FieldGroup>
<FrameFooter className="flex flex-row justify-end">
<LoadingButton
type="submit"
isLoading={saveGpMut.isPending}
disabled={isLoading || !gpForm.formState.isDirty}
>
Сохранить
</LoadingButton>
</FrameFooter>
</FramePanel>
</Frame>
</form>
</div>
)
}
+1
View File
@@ -21,6 +21,7 @@ COPY apps/api apps/api
COPY packages/ui packages/ui
COPY packages/shared packages/shared
COPY packages/db packages/db
COPY workers/health-probe workers/health-probe
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
pnpm turbo build --filter=web --filter=@cfdm/api \
&& pnpm --filter @cfdm/api deploy --prod /out \
+43 -3
View File
@@ -14,7 +14,7 @@ Manage Cloudflare zones, DNS records, domain groups, and TLS certificate expiry
| Variable | Description |
|----------|-------------|
| `CLOUDFLARE_API_TOKEN` | API token with Zone.DNS permissions |
| `CLOUDFLARE_API_TOKEN` | API token: Zone.DNS **и** для Worker — Account Workers Scripts Write + Workers KV Storage Write |
| `DATABASE_URL` | SQLite path (`sqlite:/data/app.db`) |
| `JWT_SECRET` | JWT signing secret |
| `ADMIN_USERNAME` | Admin username |
@@ -48,8 +48,48 @@ health-check работают на двух уровнях:
не попадает в пул, пока нет успешных проб; восстановление — `UNHEALTHY → CHECKING → HEALTHY`
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в
**Настройки → Health-check** (env — fallback, пока значения не сохранены в UI).
Reconcile DNS запускается cron-задачей
`health-check`. Cloudflare Health Checks — официальный API зоны, Workers не используются.
### Источники проб: Local, Cloudflare Worker, Globalping
На привязке/группе задаётся **мультивыбор** источников (`health_check_providers` JSON)
и **правило агрегации** (`health_check_aggregate`: `any` | `all` | `majority`).
Failover читает одну строку `ip_health_status` (агрегат). Журнал `health_probe_log`
строка на каждый источник.
| | Local | Cloudflare Worker | Globalping |
|---|---|---|---|
| Кто пробирует | процесс API CFDM | Worker на edge (Cron Trigger) | [globalping.io](https://globalping.io) |
| Планировщик | глобальный cron CFDM | cron Worker + ingest KV | тот же cron CFDM (POST/GET measurements) |
| Пороги Slow/Down | Настройки → Health-check | те же | те же (по агрегату) |
| Результат | SQLite `ip_health_status` | та же SQLite + `colo` из KV | та же SQLite, colo = city/country пробы |
| Fallback | — | нет (не Local) | нет (нет токена / 429 / timeout = fail) |
**Агрегация (на сервисе/группе):**
- `any` — Down, если хотя бы один выбранный источник Down
- `all` — Down, только если все выбранные Down
- `majority` — Down по большинству (2 источника → оба; 3 → ≥2)
**Cloudflare в CFDM — это Worker**, не [Health Checks API](https://developers.cloudflare.com/api/resources/healthchecks).
Продукт Health Checks на Free-плане недоступен и **не используется**.
Worker **сам** опрашивает IP/порты/протоколы (TCP/HTTP, паттерн [UptimeFlare](https://github.com/lyc8503/UptimeFlare): `sockets.opened`, p-limit 5).
CFDM создаёт скрипт через Workers Scripts API, кладёт список целей в KV и читает результаты.
Публичный URL API не нужен. Если Worker/KV не готовы, cloudflare-цели **не** пробируются как Local.
Кнопка **Создать / обновить Worker****Настройки → Health-check**. Токен:
Account `Workers Scripts Write` + `Workers KV Storage Write`. Zone DNS недостаточно.
Free: 5 Cron Triggers на аккаунт; KV 1000 writes/сутки (интервал ≥ 2 мин);
≤ 48 целей за тик. Исходник: [`workers/health-probe/`](../workers/health-probe/).
**Globalping:** `POST /v1/measurements` → poll `GET` каждые ≥ 500 мс.
CFDM TCP → `type: ping` + `protocol: TCP`; HTTP → `type: http`, `target` = IP, `request.host` = hostname.
Токен: [dash.globalping.io/tokens](https://dash.globalping.io/tokens). Без токена 250 tests/hour, с токеном 500 + [credits](https://globalping.io/credits).
Локации (magic CSV, default `World`) и `limit` (110, default 3) — **Настройки → Health-check**.
Один measurement на уникальный origin (IP/порт/path) за тик.
Reconcile DNS запускается cron-задачей `health-check` после ingest KV и агрегации.
## Docker
+1243 -6
View File
File diff suppressed because one or more lines are too long
+358 -41
View File
@@ -52,6 +52,9 @@ var serviceGroups = sqliteTable("service_groups", {
health_check_interval_sec: integer("health_check_interval_sec").notNull().default(30),
health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3),
health_check_verify_tls: integer("health_check_verify_tls", { mode: "boolean" }).notNull().default(false),
health_check_provider: text("health_check_provider").notNull().default("local"),
health_check_providers: text("health_check_providers").notNull().default('["local"]'),
health_check_aggregate: text("health_check_aggregate").notNull().default("majority"),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
});
@@ -117,6 +120,10 @@ var serviceBindings = sqliteTable(
health_check_verify_tls: integer("health_check_verify_tls", {
mode: "boolean"
}).notNull().default(false),
health_check_provider: text("health_check_provider").notNull().default("local"),
health_check_providers: text("health_check_providers").notNull().default('["local"]'),
health_check_aggregate: text("health_check_aggregate").notNull().default("majority"),
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
operation_version: integer("operation_version").notNull().default(0),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
@@ -222,6 +229,9 @@ var certificates = sqliteTable("certificates", {
subdomain_id: integer("subdomain_id").references(() => subdomains.id, {
onDelete: "set null"
}),
service_id: integer("service_id").references(() => services.id, {
onDelete: "set null"
}),
hostname: text("hostname").notNull().unique(),
expires_at: text("expires_at"),
last_checked_at: text("last_checked_at"),
@@ -252,6 +262,8 @@ var ipHealthStatus = sqliteTable(
consecutive_successes: integer("consecutive_successes").notNull().default(0),
last_checked_at: text("last_checked_at"),
last_error: text("last_error"),
colo: text("colo"),
provider: text("provider").notNull().default("local"),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
},
@@ -275,6 +287,16 @@ var appSettings = sqliteTable("app_settings", {
health_down_failures: integer("health_down_failures"),
health_latency_warn_ms: integer("health_latency_warn_ms"),
health_success_recoveries: integer("health_success_recoveries"),
health_worker_url: text("health_worker_url"),
health_worker_token: text("health_worker_token"),
health_worker_account_id: text("health_worker_account_id"),
health_worker_kv_namespace_id: text("health_worker_kv_namespace_id"),
health_worker_error: text("health_worker_error"),
health_worker_deployed_at: text("health_worker_deployed_at"),
health_worker_last_ingest_at: text("health_worker_last_ingest_at"),
globalping_token: text("globalping_token"),
globalping_locations: text("globalping_locations"),
globalping_limit: integer("globalping_limit"),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
});
@@ -311,6 +333,19 @@ var domainMonitorResults = sqliteTable("domain_monitor_results", {
error: text("error"),
checked_at: text("checked_at").notNull().default(sql`datetime('now')`)
});
var healthProbeLog = sqliteTable("health_probe_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
scope: text("scope").notNull(),
ref_id: integer("ref_id").notNull(),
ip: text("ip").notNull(),
provider: text("provider").notNull(),
status: text("status").notNull(),
ok: integer("ok", { mode: "boolean" }).notNull(),
latency_ms: integer("latency_ms"),
colo: text("colo"),
error: text("error"),
checked_at: text("checked_at").notNull().default(sql`datetime('now')`)
});
var notificationLog = sqliteTable("notification_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
kind: text("kind").notNull(),
@@ -358,6 +393,7 @@ var schema = {
domainTags,
domainMonitors,
domainMonitorResults,
healthProbeLog,
notificationLog,
auditLog
};
@@ -502,13 +538,22 @@ var SETTINGS_ID = "settings-main";
function coalesceInt(value, fallback) {
return value == null || Number.isNaN(value) || value < 1 ? fallback : value;
}
function workerStatus(row, envUrl) {
if (row.health_worker_error?.trim()) return "error";
const url = row.health_worker_url?.trim() || envUrl;
const kv = row.health_worker_kv_namespace_id?.trim();
if (kv && url) return "ready";
return "missing";
}
function toDto(row, fallbacks) {
const env = fallbacks ?? {
healthCheckCron: "0 */2 * * * *",
healthDegradedFailures: 1,
healthDownFailures: 2,
healthLatencyWarnMs: 1e3,
healthSuccessRecoveries: 2
healthSuccessRecoveries: 2,
healthWorkerUrl: "",
healthWorkerTokenSet: false
};
return {
id: row.id,
@@ -535,7 +580,18 @@ function toDto(row, fallbacks) {
healthSuccessRecoveries: coalesceInt(
row.health_success_recoveries,
env.healthSuccessRecoveries
)
),
healthWorkerUrl: row.health_worker_url?.trim() || env.healthWorkerUrl,
healthWorkerTokenSet: Boolean(row.health_worker_token?.trim()) || env.healthWorkerTokenSet,
healthWorkerAccountId: row.health_worker_account_id?.trim() ?? "",
healthWorkerKvNamespaceId: row.health_worker_kv_namespace_id?.trim() ?? "",
healthWorkerError: row.health_worker_error?.trim() || null,
healthWorkerDeployedAt: row.health_worker_deployed_at ?? null,
healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null,
healthWorkerStatus: workerStatus(row, env.healthWorkerUrl),
globalpingTokenSet: Boolean(row.globalping_token?.trim()),
globalpingLocations: row.globalping_locations?.trim() || "World",
globalpingLimit: row.globalping_limit == null || Number.isNaN(row.globalping_limit) || row.globalping_limit < 1 ? 3 : Math.min(10, row.globalping_limit)
};
}
function getAppSettings(db, fallbacks) {
@@ -551,10 +607,16 @@ function getAppSettings(db, fallbacks) {
}
function getAppSettingsSecrets(db) {
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
const limit = row?.globalping_limit;
return {
vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "",
vpsTrackerIntegrationToken: row?.vps_tracker_integration_token?.trim() ?? "",
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled)
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled),
healthWorkerUrl: row?.health_worker_url?.trim() ?? "",
healthWorkerToken: row?.health_worker_token?.trim() ?? "",
globalpingToken: row?.globalping_token?.trim() ?? "",
globalpingLocations: row?.globalping_locations?.trim() || "World",
globalpingLimit: limit == null || Number.isNaN(limit) || limit < 1 ? 3 : Math.min(10, limit)
};
}
function updateAppSettings(db, patch, fallbacks) {
@@ -564,7 +626,6 @@ function updateAppSettings(db, patch, fallbacks) {
}
const current = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
db.update(appSettings).set({
// app_switcher_json: deprecated — source of truth is auth-portal
vps_tracker_url: patch.vpsTrackerUrl !== void 0 ? patch.vpsTrackerUrl : current.vps_tracker_url,
vps_tracker_integration_token: patch.vpsTrackerIntegrationToken !== void 0 && patch.vpsTrackerIntegrationToken.trim() !== "" ? patch.vpsTrackerIntegrationToken : current.vps_tracker_integration_token,
vps_tracker_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled,
@@ -574,6 +635,16 @@ function updateAppSettings(db, patch, fallbacks) {
health_down_failures: patch.healthDownFailures !== void 0 ? patch.healthDownFailures : current.health_down_failures,
health_latency_warn_ms: patch.healthLatencyWarnMs !== void 0 ? patch.healthLatencyWarnMs : current.health_latency_warn_ms,
health_success_recoveries: patch.healthSuccessRecoveries !== void 0 ? patch.healthSuccessRecoveries : current.health_success_recoveries,
health_worker_url: patch.healthWorkerUrl !== void 0 ? patch.healthWorkerUrl.trim() || null : current.health_worker_url,
health_worker_token: patch.healthWorkerToken !== void 0 && patch.healthWorkerToken.trim() !== "" ? patch.healthWorkerToken : current.health_worker_token,
health_worker_account_id: patch.healthWorkerAccountId !== void 0 ? patch.healthWorkerAccountId?.trim() || null : current.health_worker_account_id,
health_worker_kv_namespace_id: patch.healthWorkerKvNamespaceId !== void 0 ? patch.healthWorkerKvNamespaceId?.trim() || null : current.health_worker_kv_namespace_id,
health_worker_error: patch.healthWorkerError !== void 0 ? patch.healthWorkerError?.trim() || null : current.health_worker_error,
health_worker_deployed_at: patch.healthWorkerDeployedAt !== void 0 ? patch.healthWorkerDeployedAt : current.health_worker_deployed_at,
health_worker_last_ingest_at: patch.healthWorkerLastIngestAt !== void 0 ? patch.healthWorkerLastIngestAt : current.health_worker_last_ingest_at,
globalping_token: patch.globalpingToken !== void 0 && patch.globalpingToken.trim() !== "" ? patch.globalpingToken : current.globalping_token,
globalping_locations: patch.globalpingLocations !== void 0 ? patch.globalpingLocations.trim() || "World" : current.globalping_locations,
globalping_limit: patch.globalpingLimit !== void 0 ? Math.min(10, Math.max(1, patch.globalpingLimit)) : current.globalping_limit,
updated_at: (/* @__PURE__ */ new Date()).toISOString()
}).where(eq2(appSettings.id, SETTINGS_ID)).run();
return getAppSettings(db, fallbacks);
@@ -644,6 +715,7 @@ __export(repos_exports, {
getSyncJob: () => getSyncJob,
insertBinding: () => insertBinding,
insertDnsRecord: () => insertDnsRecord,
insertHealthProbeLog: () => insertHealthProbeLog,
insertNotificationLog: () => insertNotificationLog,
linkBindingRecord: () => linkBindingRecord,
linkGroupDnsRecord: () => linkGroupDnsRecord,
@@ -670,6 +742,7 @@ __export(repos_exports, {
listGroups: () => listGroups,
listHealthCheckTargets: () => listHealthCheckTargets,
listHealthChecks: () => listHealthChecks,
listHealthProbeLogForService: () => listHealthProbeLogForService,
listIpHealthByServiceIds: () => listIpHealthByServiceIds,
listIpHealthStatus: () => listIpHealthStatus,
listNodes: () => listNodes,
@@ -719,7 +792,15 @@ __export(repos_exports, {
upsertIpHealthStatus: () => upsertIpHealthStatus,
upsertSubdomain: () => upsertSubdomain
});
import { dnsRecordNamesMatch, isIpLiteral } from "@cfdm/shared";
import {
derivePrimaryProvider,
dnsRecordNamesMatch,
isIpLiteral,
parseHealthAggregate,
parseHealthProviders,
serializeHealthProviders,
normalizeStatusProvider
} from "@cfdm/shared";
import { and as and2, asc, count, eq as eq3, isNull, like, notInArray, or as or2, sql as sql2 } from "drizzle-orm";
function listGroups(db) {
return db.select().from(groups).orderBy(asc(groups.name)).all();
@@ -1106,6 +1187,60 @@ function deleteService(db, id) {
const result = db.delete(services).where(eq3(services.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service ${id}`);
}
function healthProviderColumns(patch) {
const out = {};
if (patch.health_check_providers !== void 0) {
const list = parseHealthProviders(patch.health_check_providers);
out.health_check_providers = serializeHealthProviders(list);
out.health_check_provider = derivePrimaryProvider(list);
} else if (patch.health_check_provider !== void 0) {
const list = parseHealthProviders(null, patch.health_check_provider);
out.health_check_providers = serializeHealthProviders(list);
out.health_check_provider = derivePrimaryProvider(list);
}
if (patch.health_check_aggregate !== void 0) {
out.health_check_aggregate = parseHealthAggregate(
patch.health_check_aggregate
);
}
return out;
}
function mapHealthFields(row) {
const providers = parseHealthProviders(
row.health_check_providers,
row.health_check_provider
);
return {
health_check_providers: providers,
health_check_provider: derivePrimaryProvider(providers),
health_check_aggregate: parseHealthAggregate(row.health_check_aggregate)
};
}
function mapServiceBinding(row) {
return {
id: row.id,
domain_id: row.domain_id,
service_id: row.service_id,
hostname: row.hostname,
cname_target: row.cname_target,
dns_record_id: row.dns_record_id,
lb_mode: row.lb_mode,
health_check_enabled: Boolean(row.health_check_enabled),
health_check_type: row.health_check_type,
health_check_port: row.health_check_port,
health_check_path: row.health_check_path,
health_check_expected_status: row.health_check_expected_status,
health_check_interval_sec: row.health_check_interval_sec,
health_check_timeout_ms: row.health_check_timeout_ms,
health_check_verify_tls: Boolean(row.health_check_verify_tls),
...mapHealthFields(row),
cert_monitoring: row.cert_monitoring ?? "auto",
routing_strategy: row.routing_strategy,
operation_version: row.operation_version,
created_at: row.created_at,
updated_at: row.updated_at
};
}
function mapServiceGroup(row) {
return {
id: row.id,
@@ -1123,6 +1258,7 @@ function mapServiceGroup(row) {
health_check_interval_sec: row.health_check_interval_sec,
health_check_timeout_ms: row.health_check_timeout_ms,
health_check_verify_tls: row.health_check_verify_tls,
...mapHealthFields(row),
created_at: row.created_at,
updated_at: row.updated_at
};
@@ -1149,7 +1285,12 @@ function createServiceGroup(db, name, groupType, icon, domain, lbPatch) {
health_check_expected_status: lbPatch?.health_check_expected_status ?? null,
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3e3,
health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false
health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false,
...healthProviderColumns({
health_check_provider: lbPatch?.health_check_provider ?? "local",
health_check_providers: lbPatch?.health_check_providers,
health_check_aggregate: lbPatch?.health_check_aggregate ?? "majority"
})
}).returning({ id: serviceGroups.id }).get().id;
return getServiceGroup(db, id);
}
@@ -1179,6 +1320,7 @@ function updateServiceGroup(db, id, name, groupType, icon, domain, lbPatch) {
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
if (lbPatch.health_check_verify_tls !== void 0)
update.health_check_verify_tls = lbPatch.health_check_verify_tls;
Object.assign(update, healthProviderColumns(lbPatch));
}
const result = db.update(serviceGroups).set(update).where(eq3(serviceGroups.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
@@ -1488,6 +1630,9 @@ function updateBindingLbConfig(db, bindingId, patch) {
update.health_check_timeout_ms = patch.health_check_timeout_ms;
if (patch.health_check_verify_tls !== void 0)
update.health_check_verify_tls = patch.health_check_verify_tls;
if (patch.cert_monitoring !== void 0)
update.cert_monitoring = patch.cert_monitoring;
Object.assign(update, healthProviderColumns(patch));
db.update(serviceBindings).set(update).where(eq3(serviceBindings.id, bindingId)).run();
}
function setBindingCnameTarget(db, bindingId, target) {
@@ -1546,7 +1691,8 @@ function dnsRecordMatchesHostname(recordName, hostname, zoneName) {
var SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.cname_target,
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider,
sb.health_check_providers, sb.health_check_aggregate, sb.cert_monitoring, sb.cname_target,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
@@ -1594,6 +1740,8 @@ function enrichServiceBindingView(db, row) {
return {
...row,
cname_target: row.cname_target ?? null,
cert_monitoring: row.cert_monitoring ?? "auto",
...mapHealthFields(row),
target_ips,
target_ip: target_ips[0] ?? null,
target_ip_weights,
@@ -1625,16 +1773,20 @@ function listBindingsByDomain(db, domainId) {
`).map((row) => enrichServiceBindingView(db, row));
}
function listBindingsByService(db, serviceId) {
return db.all(sql2`
const rows = db.all(sql2`
SELECT sb.*, d.zone_name FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
WHERE sb.service_id = ${serviceId}
`);
return rows.map((row) => ({
...mapServiceBinding(row),
zone_name: row.zone_name
}));
}
function getBinding(db, id) {
const row = db.select().from(serviceBindings).where(eq3(serviceBindings.id, id)).get();
if (!row) throw new NotFoundError(`service binding ${id}`);
return row;
return mapServiceBinding(row);
}
function getBindingView(db, id) {
const rows = db.all(sql2`
@@ -1657,7 +1809,8 @@ function findBinding(db, serviceId, domainId, hostname) {
eq3(serviceBindings.hostname, hostname)
)
).get();
return row ?? null;
if (!row) return null;
return mapServiceBinding(row);
}
function insertBinding(db, domainId, serviceId, hostname, dnsRecordId) {
const id = db.insert(serviceBindings).values({
@@ -1683,7 +1836,7 @@ function setBindingDnsRecordId(db, bindingId, dnsRecordId) {
}).where(eq3(serviceBindings.id, bindingId)).run();
}
function bindingsToRemove(db, serviceId, keepIds) {
const all = db.select().from(serviceBindings).where(eq3(serviceBindings.service_id, serviceId)).all();
const all = db.select().from(serviceBindings).where(eq3(serviceBindings.service_id, serviceId)).all().map(mapServiceBinding);
return all.filter((b) => !keepIds.includes(b.id));
}
function deleteBindingsExcept(db, serviceId, keepIds) {
@@ -1698,23 +1851,57 @@ function deleteBinding(db, id) {
const result = db.delete(serviceBindings).where(eq3(serviceBindings.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service binding ${id}`);
}
var CERTIFICATE_SELECT = `c.id, c.domain_id, c.subdomain_id, c.service_id, c.hostname,
c.expires_at, c.last_checked_at, c.last_error, c.status, c.created_at, c.updated_at,
s.name AS service_name`;
function mapCertificate(row) {
return {
id: row.id,
domain_id: row.domain_id,
subdomain_id: row.subdomain_id,
service_id: row.service_id ?? null,
service_name: row.service_name ?? null,
hostname: row.hostname,
expires_at: row.expires_at,
last_checked_at: row.last_checked_at,
last_error: row.last_error,
status: row.status,
created_at: row.created_at,
updated_at: row.updated_at
};
}
function listCertificates(db, status) {
if (status) {
return db.select().from(certificates).where(eq3(certificates.status, status)).orderBy(asc(certificates.expires_at)).all();
}
return db.select().from(certificates).orderBy(asc(certificates.expires_at)).all();
const rows = status ? db.all(sql2`
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
FROM certificates c
LEFT JOIN services s ON s.id = c.service_id
WHERE c.status = ${status}
ORDER BY c.expires_at ASC
`) : db.all(sql2`
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
FROM certificates c
LEFT JOIN services s ON s.id = c.service_id
ORDER BY c.expires_at ASC
`);
return rows.map(mapCertificate);
}
function getCertificate(db, id) {
const row = db.select().from(certificates).where(eq3(certificates.id, id)).get();
const row = db.all(sql2`
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
FROM certificates c
LEFT JOIN services s ON s.id = c.service_id
WHERE c.id = ${id}
`)[0];
if (!row) throw new NotFoundError(`certificate ${id}`);
return row;
return mapCertificate(row);
}
function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, status, lastError) {
function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, status, lastError, serviceId) {
const existing = db.select().from(certificates).where(eq3(certificates.hostname, hostname)).get();
if (existing) {
db.update(certificates).set({
domain_id: domainId,
subdomain_id: subdomainId,
service_id: serviceId === void 0 ? existing.service_id : serviceId,
expires_at: expiresAt,
last_checked_at: sql2`datetime('now')`,
last_error: lastError,
@@ -1726,6 +1913,7 @@ function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt,
const id = db.insert(certificates).values({
domain_id: domainId,
subdomain_id: subdomainId,
service_id: serviceId ?? null,
hostname,
expires_at: expiresAt,
last_checked_at: sql2`datetime('now')`,
@@ -1767,7 +1955,7 @@ function finishSyncJob(db, id, status, message) {
function listIpHealthStatus(db, scope, refId) {
return db.all(sql2`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error
last_checked_at, last_error, colo, provider
FROM ip_health_status
WHERE scope = ${scope} AND ref_id = ${refId}
`);
@@ -1891,7 +2079,11 @@ function listIpHealthByServiceIds(db, serviceIds) {
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
MAX(ihs.latency_ms) AS health_latency_ms,
MAX(ihs.last_checked_at) AS last_checked_at,
MAX(ihs.last_error) AS last_error,
MAX(ihs.provider) AS provider,
MAX(ihs.colo) AS colo
FROM ip_health_status ihs
INNER JOIN service_bindings sb
ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
@@ -1907,7 +2099,11 @@ function listIpHealthByServiceIds(db, serviceIds) {
list.push({
ip: row.ip,
status: parsed.health_status,
latency_ms: parsed.health_latency_ms
latency_ms: parsed.health_latency_ms,
last_checked_at: row.last_checked_at,
last_error: row.last_error,
provider: normalizeStatusProvider(row.provider),
colo: row.colo
});
result.set(row.service_id, list);
}
@@ -1942,20 +2138,24 @@ function mergeHealthAggregates(parts) {
function getIpHealthStatusRow(db, scope, refId, ip) {
const rows = db.all(sql2`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
consecutive_successes, last_checked_at, last_error
consecutive_successes, last_checked_at, last_error, colo, provider
FROM ip_health_status
WHERE scope = ${scope} AND ref_id = ${refId} AND ip = ${ip}
LIMIT 1
`);
return rows[0] ?? null;
}
function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecutiveFailures, lastError, consecutiveSuccesses = 0) {
function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecutiveFailures, lastError, consecutiveSuccesses = 0, extras) {
const colo = extras?.colo ?? null;
const provider = extras?.provider ?? "local";
db.run(sql2`
INSERT INTO ip_health_status
(scope, ref_id, ip, status, latency_ms, consecutive_failures,
consecutive_successes, last_checked_at, last_error, created_at, updated_at)
consecutive_successes, last_checked_at, last_error, colo, provider,
created_at, updated_at)
VALUES (${scope}, ${refId}, ${ip}, ${status}, ${latencyMs}, ${consecutiveFailures},
${consecutiveSuccesses}, datetime('now'), ${lastError}, datetime('now'), datetime('now'))
${consecutiveSuccesses}, datetime('now'), ${lastError}, ${colo}, ${provider},
datetime('now'), datetime('now'))
ON CONFLICT(scope, ref_id, ip) DO UPDATE SET
status = excluded.status,
latency_ms = excluded.latency_ms,
@@ -1963,6 +2163,8 @@ function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecuti
consecutive_successes = excluded.consecutive_successes,
last_checked_at = excluded.last_checked_at,
last_error = excluded.last_error,
colo = excluded.colo,
provider = excluded.provider,
updated_at = datetime('now')
`);
}
@@ -2015,6 +2217,40 @@ function pruneStaleIpHealthStatus(db, activeTargets) {
}
return deleted;
}
function normalizeCnameHost(target, zoneName) {
const trimmed = target.trim().toLowerCase().replace(/\.+$/, "");
if (!trimmed) return "";
if (trimmed.includes(".")) return trimmed;
const zone = zoneName.trim().toLowerCase().replace(/\.+$/, "");
return zone ? `${trimmed}.${zone}` : trimmed;
}
function resolveCnameProbeIps(db, cnameTarget, zoneName, serviceId) {
const fqdn = normalizeCnameHost(cnameTarget, zoneName);
if (fqdn) {
const fromDns = listOriginIpsForFqdn(db, fqdn).filter(isIpLiteral);
if (fromDns.length > 0) return [...new Set(fromDns)];
}
if (serviceId > 0) {
return [...new Set(listServiceIps(db, serviceId).filter(isIpLiteral))];
}
return [];
}
function expandCnameHealthTargets(db, rows) {
const expanded = [];
for (const row of rows) {
const ips = resolveCnameProbeIps(
db,
row.ip,
row.zone_name ?? "",
row.service_id ?? 0
);
const resolved = ips.length > 0 ? ips : [row.ip];
for (const ip of resolved) {
expanded.push({ ...row, ip });
}
}
return expanded;
}
function listHealthCheckTargets(db) {
const fqdnExpr = sql2`CASE WHEN sb.hostname = '@' OR sb.hostname IS NULL THEN d.zone_name ELSE sb.hostname || '.' || d.zone_name END`;
const bindingTargets = db.all(sql2`
@@ -2025,12 +2261,15 @@ function listHealthCheckTargets(db) {
sb.health_check_path AS path,
sb.health_check_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms,
sb.health_check_verify_tls AS verify_tls
sb.health_check_verify_tls AS verify_tls,
sb.health_check_providers AS providers_json,
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sb.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN domains d ON d.id = sb.domain_id
WHERE sb.health_check_enabled = 1
`);
`).filter((t) => isIpLiteral(t.ip));
const groupTargets = db.all(sql2`
SELECT DISTINCT 'group' AS scope, sg.id AS ref_id, sbi.ip,
sg.domain AS hostname,
@@ -2039,7 +2278,10 @@ function listHealthCheckTargets(db) {
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls
sg.health_check_verify_tls AS verify_tls,
sg.health_check_providers AS providers_json,
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN services s ON s.id = sb.service_id
@@ -2050,7 +2292,7 @@ function listHealthCheckTargets(db) {
AND s.enabled = 1
AND sg.enabled = 1
AND (sb.cname_target IS NULL OR sb.cname_target = '')
`);
`).filter((t) => isIpLiteral(t.ip));
const groupInheritedBindingTargets = db.all(sql2`
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
${fqdnExpr} AS hostname,
@@ -2059,7 +2301,10 @@ function listHealthCheckTargets(db) {
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls
sg.health_check_verify_tls AS verify_tls,
sg.health_check_providers AS providers_json,
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN domains d ON d.id = sb.domain_id
@@ -2070,8 +2315,10 @@ function listHealthCheckTargets(db) {
AND s.enabled = 1
AND sg.enabled = 1
AND sb.health_check_enabled = 0
`);
const cnameBindingTargets = db.all(sql2`
`).filter((t) => isIpLiteral(t.ip));
const cnameBindingTargets = expandCnameHealthTargets(
db,
db.all(sql2`
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
${fqdnExpr} AS hostname,
sb.health_check_type AS type,
@@ -2079,7 +2326,12 @@ function listHealthCheckTargets(db) {
sb.health_check_path AS path,
sb.health_check_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms,
sb.health_check_verify_tls AS verify_tls
sb.health_check_verify_tls AS verify_tls,
sb.health_check_providers AS providers_json,
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sb.health_check_provider, 'local') AS provider,
d.zone_name AS zone_name,
sb.service_id AS service_id
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -2087,8 +2339,11 @@ function listHealthCheckTargets(db) {
AND sb.cname_target IS NOT NULL
AND sb.cname_target <> ''
AND s.enabled = 1
`);
const groupInheritedCnameBindingTargets = db.all(sql2`
`)
);
const groupInheritedCnameBindingTargets = expandCnameHealthTargets(
db,
db.all(sql2`
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
${fqdnExpr} AS hostname,
sg.health_check_type AS type,
@@ -2096,7 +2351,12 @@ function listHealthCheckTargets(db) {
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls
sg.health_check_verify_tls AS verify_tls,
sg.health_check_providers AS providers_json,
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sg.health_check_provider, 'local') AS provider,
d.zone_name AS zone_name,
sb.service_id AS service_id
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -2108,17 +2368,33 @@ function listHealthCheckTargets(db) {
AND sb.health_check_enabled = 0
AND sb.cname_target IS NOT NULL
AND sb.cname_target <> ''
`);
`)
);
return [
...bindingTargets,
...groupTargets,
...groupInheritedBindingTargets,
...cnameBindingTargets,
...groupInheritedCnameBindingTargets
].map((t) => ({
...t,
verify_tls: Boolean(t.verify_tls)
}));
].map((t) => {
const row = t;
const providers = parseHealthProviders(row.providers_json, row.provider);
return {
scope: row.scope,
ref_id: row.ref_id,
ip: row.ip,
hostname: row.hostname,
type: row.type,
port: row.port,
path: row.path,
expected_status: row.expected_status,
timeout_ms: row.timeout_ms,
verify_tls: Boolean(row.verify_tls),
providers,
aggregate: parseHealthAggregate(row.aggregate),
provider: derivePrimaryProvider(providers)
};
});
}
function listDomainTags(db, domainId) {
return db.select({ tag: domainTags.tag }).from(domainTags).where(eq3(domainTags.domain_id, domainId)).all().map((r) => r.tag);
@@ -2212,6 +2488,46 @@ function listDomainMonitorResultsForDomain(db, domainId, limit = 50) {
LIMIT ${limit}
`);
}
var HEALTH_PROBE_LOG_KEEP = 50;
function insertHealthProbeLog(db, entry) {
db.insert(healthProbeLog).values({
scope: entry.scope,
ref_id: entry.refId,
ip: entry.ip,
provider: entry.provider,
status: entry.status,
ok: entry.ok,
latency_ms: entry.latencyMs,
colo: entry.colo,
error: entry.error
}).run();
db.run(sql2`
DELETE FROM health_probe_log
WHERE id IN (
SELECT id FROM health_probe_log
WHERE scope = ${entry.scope} AND ref_id = ${entry.refId} AND ip = ${entry.ip}
ORDER BY checked_at DESC, id DESC
LIMIT -1 OFFSET ${HEALTH_PROBE_LOG_KEEP}
)
`);
}
function listHealthProbeLogForService(db, serviceId, limit = 50) {
const rows = db.all(sql2`
SELECT l.id, l.scope, l.ref_id, l.ip, l.provider, l.status, l.ok,
l.latency_ms, l.colo, l.error, l.checked_at
FROM health_probe_log l
INNER JOIN service_bindings sb
ON l.scope = 'binding' AND l.ref_id = sb.id
WHERE sb.service_id = ${serviceId}
ORDER BY l.checked_at DESC, l.id DESC
LIMIT ${limit}
`);
return rows.map((row) => ({
...row,
provider: parseHealthProviders(null, row.provider)[0] ?? "local",
ok: Boolean(row.ok)
}));
}
function insertNotificationLog(db, kind, refType, refId, title, message) {
db.insert(notificationLog).values({
kind,
@@ -2249,6 +2565,7 @@ export {
groups,
healthCheck,
healthChecks,
healthProbeLog,
ipHealthStatus,
listAudit,
nodes,
@@ -0,0 +1,27 @@
-- XOR health-check: persist provider on bindings/groups; Worker URL/token;
-- colo + probe journal. Cloudflare = Worker edge probe, not Health Checks API.
ALTER TABLE service_bindings ADD COLUMN health_check_provider TEXT NOT NULL DEFAULT 'local';
ALTER TABLE service_groups ADD COLUMN health_check_provider TEXT NOT NULL DEFAULT 'local';
ALTER TABLE app_settings ADD COLUMN health_worker_url TEXT;
ALTER TABLE app_settings ADD COLUMN health_worker_token TEXT;
ALTER TABLE ip_health_status ADD COLUMN colo TEXT;
ALTER TABLE ip_health_status ADD COLUMN provider TEXT NOT NULL DEFAULT 'local';
CREATE TABLE IF NOT EXISTS health_probe_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL,
ref_id INTEGER NOT NULL,
ip TEXT NOT NULL,
provider TEXT NOT NULL,
status TEXT NOT NULL,
ok INTEGER NOT NULL,
latency_ms INTEGER,
colo TEXT,
error TEXT,
checked_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_health_probe_log_target
ON health_probe_log(scope, ref_id, ip, checked_at DESC);
@@ -0,0 +1,5 @@
ALTER TABLE app_settings ADD COLUMN health_worker_account_id TEXT;
ALTER TABLE app_settings ADD COLUMN health_worker_kv_namespace_id TEXT;
ALTER TABLE app_settings ADD COLUMN health_worker_error TEXT;
ALTER TABLE app_settings ADD COLUMN health_worker_deployed_at TEXT;
ALTER TABLE app_settings ADD COLUMN health_worker_last_ingest_at TEXT;
@@ -0,0 +1,21 @@
ALTER TABLE service_bindings ADD COLUMN health_check_providers TEXT;
ALTER TABLE service_bindings ADD COLUMN health_check_aggregate TEXT NOT NULL DEFAULT 'majority';
UPDATE service_bindings
SET health_check_providers = CASE
WHEN health_check_provider = 'cloudflare' THEN '["cloudflare"]'
ELSE '["local"]'
END
WHERE health_check_providers IS NULL;
ALTER TABLE service_groups ADD COLUMN health_check_providers TEXT;
ALTER TABLE service_groups ADD COLUMN health_check_aggregate TEXT NOT NULL DEFAULT 'majority';
UPDATE service_groups
SET health_check_providers = CASE
WHEN health_check_provider = 'cloudflare' THEN '["cloudflare"]'
ELSE '["local"]'
END
WHERE health_check_providers IS NULL;
ALTER TABLE app_settings ADD COLUMN globalping_token TEXT;
ALTER TABLE app_settings ADD COLUMN globalping_locations TEXT;
ALTER TABLE app_settings ADD COLUMN globalping_limit INTEGER;
@@ -0,0 +1,37 @@
ALTER TABLE service_bindings ADD COLUMN cert_monitoring TEXT NOT NULL DEFAULT 'auto'
CHECK (cert_monitoring IN ('auto', 'required', 'skipped'));
ALTER TABLE certificates ADD COLUMN service_id INTEGER REFERENCES services(id) ON DELETE SET NULL;
UPDATE service_bindings
SET cert_monitoring = COALESCE(
(
SELECT d.cert_monitoring FROM domains d
WHERE d.id = service_bindings.domain_id
),
'auto'
)
WHERE hostname = '@';
UPDATE service_bindings
SET cert_monitoring = COALESCE(
(
SELECT s.cert_monitoring FROM subdomains s
WHERE s.domain_id = service_bindings.domain_id
AND s.name = service_bindings.hostname
),
'auto'
)
WHERE hostname != '@';
UPDATE certificates
SET service_id = (
SELECT sb.service_id
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
WHERE CASE
WHEN sb.hostname = '@' THEN d.zone_name
ELSE sb.hostname || '.' || d.zone_name
END = certificates.hostname
LIMIT 1
);
+427 -46
View File
@@ -5,9 +5,12 @@ import type {
DomainListItem,
Group,
GroupWithStats,
HealthCheckAggregate,
HealthCheckProvider,
HealthCheckScope,
HealthCheckTarget,
HealthCheckType,
HealthStatusProvider,
IpHealthState,
IpHealthStatus,
LbMode,
@@ -20,7 +23,15 @@ import type {
Subdomain,
SyncJob,
} from "@cfdm/shared";
import { dnsRecordNamesMatch, isIpLiteral } from "@cfdm/shared";
import {
derivePrimaryProvider,
dnsRecordNamesMatch,
isIpLiteral,
parseHealthAggregate,
parseHealthProviders,
serializeHealthProviders,
normalizeStatusProvider,
} from "@cfdm/shared";
import { and, asc, count, eq, isNull, like, notInArray, or, sql } from "drizzle-orm";
import type { Db } from "./client.js";
import { ConflictError, NotFoundError } from "./errors.js";
@@ -33,6 +44,7 @@ import {
domains,
groups,
healthChecks,
healthProbeLog,
ipHealthStatus,
nodes,
bindingNodes,
@@ -768,6 +780,85 @@ export function deleteService(db: Db, id: number): void {
// --- Service Groups ---
function healthProviderColumns(patch: {
health_check_provider?: HealthCheckProvider;
health_check_providers?: HealthCheckProvider[];
health_check_aggregate?: HealthCheckAggregate;
}): {
health_check_provider?: string;
health_check_providers?: string;
health_check_aggregate?: string;
} {
const out: {
health_check_provider?: string;
health_check_providers?: string;
health_check_aggregate?: string;
} = {};
if (patch.health_check_providers !== undefined) {
const list = parseHealthProviders(patch.health_check_providers);
out.health_check_providers = serializeHealthProviders(list);
out.health_check_provider = derivePrimaryProvider(list);
} else if (patch.health_check_provider !== undefined) {
const list = parseHealthProviders(null, patch.health_check_provider);
out.health_check_providers = serializeHealthProviders(list);
out.health_check_provider = derivePrimaryProvider(list);
}
if (patch.health_check_aggregate !== undefined) {
out.health_check_aggregate = parseHealthAggregate(
patch.health_check_aggregate,
);
}
return out;
}
function mapHealthFields(row: {
health_check_provider?: unknown;
health_check_providers?: unknown;
health_check_aggregate?: unknown;
}): {
health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
} {
const providers = parseHealthProviders(
row.health_check_providers,
row.health_check_provider,
);
return {
health_check_providers: providers,
health_check_provider: derivePrimaryProvider(providers),
health_check_aggregate: parseHealthAggregate(row.health_check_aggregate),
};
}
function mapServiceBinding(
row: typeof serviceBindings.$inferSelect,
): ServiceBinding {
return {
id: row.id,
domain_id: row.domain_id,
service_id: row.service_id,
hostname: row.hostname,
cname_target: row.cname_target,
dns_record_id: row.dns_record_id,
lb_mode: row.lb_mode as LbMode,
health_check_enabled: Boolean(row.health_check_enabled),
health_check_type: row.health_check_type as HealthCheckType,
health_check_port: row.health_check_port,
health_check_path: row.health_check_path,
health_check_expected_status: row.health_check_expected_status,
health_check_interval_sec: row.health_check_interval_sec,
health_check_timeout_ms: row.health_check_timeout_ms,
health_check_verify_tls: Boolean(row.health_check_verify_tls),
...mapHealthFields(row),
cert_monitoring: row.cert_monitoring ?? "auto",
routing_strategy: row.routing_strategy as LbMode,
operation_version: row.operation_version,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
function mapServiceGroup(row: typeof serviceGroups.$inferSelect): ServiceGroup {
return {
id: row.id,
@@ -785,6 +876,7 @@ function mapServiceGroup(row: typeof serviceGroups.$inferSelect): ServiceGroup {
health_check_interval_sec: row.health_check_interval_sec,
health_check_timeout_ms: row.health_check_timeout_ms,
health_check_verify_tls: row.health_check_verify_tls,
...mapHealthFields(row),
created_at: row.created_at,
updated_at: row.updated_at,
};
@@ -819,6 +911,9 @@ export interface ServiceGroupLbPatch {
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
health_check_verify_tls?: boolean;
health_check_provider?: HealthCheckProvider;
health_check_providers?: HealthCheckProvider[];
health_check_aggregate?: HealthCheckAggregate;
}
export function createServiceGroup(
@@ -845,6 +940,11 @@ export function createServiceGroup(
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3000,
health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false,
...healthProviderColumns({
health_check_provider: lbPatch?.health_check_provider ?? "local",
health_check_providers: lbPatch?.health_check_providers,
health_check_aggregate: lbPatch?.health_check_aggregate ?? "majority",
}),
})
.returning({ id: serviceGroups.id })
.get()!.id;
@@ -885,6 +985,7 @@ export function updateServiceGroup(
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
if (lbPatch.health_check_verify_tls !== undefined)
update.health_check_verify_tls = lbPatch.health_check_verify_tls;
Object.assign(update, healthProviderColumns(lbPatch));
}
const result = db
.update(serviceGroups)
@@ -1427,6 +1528,10 @@ export interface BindingLbPatch {
health_check_interval_sec?: number;
health_check_timeout_ms?: number;
health_check_verify_tls?: boolean;
health_check_provider?: HealthCheckProvider;
health_check_providers?: HealthCheckProvider[];
health_check_aggregate?: HealthCheckAggregate;
cert_monitoring?: string;
}
export function updateBindingLbConfig(
@@ -1457,6 +1562,9 @@ export function updateBindingLbConfig(
update.health_check_timeout_ms = patch.health_check_timeout_ms;
if (patch.health_check_verify_tls !== undefined)
update.health_check_verify_tls = patch.health_check_verify_tls;
if (patch.cert_monitoring !== undefined)
update.cert_monitoring = patch.cert_monitoring;
Object.assign(update, healthProviderColumns(patch));
db.update(serviceBindings)
.set(update)
.where(eq(serviceBindings.id, bindingId))
@@ -1564,7 +1672,8 @@ function dnsRecordMatchesHostname(
const SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.cname_target,
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider,
sb.health_check_providers, sb.health_check_aggregate, sb.cert_monitoring, sb.cname_target,
d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status,
@@ -1628,6 +1737,8 @@ function enrichServiceBindingView(
return {
...row,
cname_target: row.cname_target ?? null,
cert_monitoring: row.cert_monitoring ?? "auto",
...mapHealthFields(row),
target_ips,
target_ip: target_ips[0] ?? null,
target_ip_weights,
@@ -1666,11 +1777,15 @@ export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingVi
}
export function listBindingsByService(db: Db, serviceId: number): Array<ServiceBinding & { zone_name: string }> {
return db.all(sql`
const rows = db.all<typeof serviceBindings.$inferSelect & { zone_name: string }>(sql`
SELECT sb.*, d.zone_name FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
WHERE sb.service_id = ${serviceId}
`);
return rows.map((row) => ({
...mapServiceBinding(row),
zone_name: row.zone_name,
}));
}
export function getBinding(db: Db, id: number): ServiceBinding {
@@ -1680,7 +1795,7 @@ export function getBinding(db: Db, id: number): ServiceBinding {
.where(eq(serviceBindings.id, id))
.get();
if (!row) throw new NotFoundError(`service binding ${id}`);
return row as ServiceBinding;
return mapServiceBinding(row);
}
export function getBindingView(db: Db, id: number): ServiceBindingView {
@@ -1714,7 +1829,8 @@ export function findBinding(
),
)
.get();
return (row as ServiceBinding) ?? null;
if (!row) return null;
return mapServiceBinding(row);
}
export function insertBinding(
@@ -1778,7 +1894,8 @@ export function bindingsToRemove(
.select()
.from(serviceBindings)
.where(eq(serviceBindings.service_id, serviceId))
.all() as ServiceBinding[];
.all()
.map(mapServiceBinding);
return all.filter((b) => !keepIds.includes(b.id));
}
@@ -1806,26 +1923,69 @@ export function deleteBinding(db: Db, id: number): void {
// --- Certificates ---
const CERTIFICATE_SELECT = `c.id, c.domain_id, c.subdomain_id, c.service_id, c.hostname,
c.expires_at, c.last_checked_at, c.last_error, c.status, c.created_at, c.updated_at,
s.name AS service_name`;
type CertificateRow = {
id: number;
domain_id: number;
subdomain_id: number | null;
service_id: number | null;
hostname: string;
expires_at: string | null;
last_checked_at: string | null;
last_error: string | null;
status: string;
created_at: string;
updated_at: string;
service_name: string | null;
};
function mapCertificate(row: CertificateRow): Certificate {
return {
id: row.id,
domain_id: row.domain_id,
subdomain_id: row.subdomain_id,
service_id: row.service_id ?? null,
service_name: row.service_name ?? null,
hostname: row.hostname,
expires_at: row.expires_at,
last_checked_at: row.last_checked_at,
last_error: row.last_error,
status: row.status,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
export function listCertificates(db: Db, status?: string): Certificate[] {
if (status) {
return db
.select()
.from(certificates)
.where(eq(certificates.status, status))
.orderBy(asc(certificates.expires_at))
.all() as Certificate[];
}
return db
.select()
.from(certificates)
.orderBy(asc(certificates.expires_at))
.all() as Certificate[];
const rows = status
? db.all<CertificateRow>(sql`
SELECT ${sql.raw(CERTIFICATE_SELECT)}
FROM certificates c
LEFT JOIN services s ON s.id = c.service_id
WHERE c.status = ${status}
ORDER BY c.expires_at ASC
`)
: db.all<CertificateRow>(sql`
SELECT ${sql.raw(CERTIFICATE_SELECT)}
FROM certificates c
LEFT JOIN services s ON s.id = c.service_id
ORDER BY c.expires_at ASC
`);
return rows.map(mapCertificate);
}
export function getCertificate(db: Db, id: number): Certificate {
const row = db.select().from(certificates).where(eq(certificates.id, id)).get();
const row = db.all<CertificateRow>(sql`
SELECT ${sql.raw(CERTIFICATE_SELECT)}
FROM certificates c
LEFT JOIN services s ON s.id = c.service_id
WHERE c.id = ${id}
`)[0];
if (!row) throw new NotFoundError(`certificate ${id}`);
return row as Certificate;
return mapCertificate(row);
}
export function upsertCertificateCheck(
@@ -1836,6 +1996,7 @@ export function upsertCertificateCheck(
expiresAt: string | null,
status: string,
lastError: string | null,
serviceId?: number | null,
): Certificate {
const existing = db
.select()
@@ -1848,6 +2009,7 @@ export function upsertCertificateCheck(
.set({
domain_id: domainId,
subdomain_id: subdomainId,
service_id: serviceId === undefined ? existing.service_id : serviceId,
expires_at: expiresAt,
last_checked_at: sql`datetime('now')`,
last_error: lastError,
@@ -1864,6 +2026,7 @@ export function upsertCertificateCheck(
.values({
domain_id: domainId,
subdomain_id: subdomainId,
service_id: serviceId ?? null,
hostname,
expires_at: expiresAt,
last_checked_at: sql`datetime('now')`,
@@ -1943,7 +2106,7 @@ export function listIpHealthStatus(
return db
.all<IpHealthStatus>(sql`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
last_checked_at, last_error
last_checked_at, last_error, colo, provider
FROM ip_health_status
WHERE scope = ${scope} AND ref_id = ${refId}
`);
@@ -2108,6 +2271,10 @@ export type ServiceIpHealthRow = {
ip: string;
status: IpHealthState;
latency_ms: number | null;
last_checked_at: string | null;
last_error: string | null;
provider: HealthStatusProvider;
colo: string | null;
};
/** Per-IP binding-scope health, worst status if the same IP is on several bindings. */
@@ -2126,11 +2293,19 @@ export function listIpHealthByServiceIds(
ip: string;
health_status: string | null;
health_latency_ms: number | null;
last_checked_at: string | null;
last_error: string | null;
provider: string | null;
colo: string | 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
MAX(ihs.latency_ms) AS health_latency_ms,
MAX(ihs.last_checked_at) AS last_checked_at,
MAX(ihs.last_error) AS last_error,
MAX(ihs.provider) AS provider,
MAX(ihs.colo) AS colo
FROM ip_health_status ihs
INNER JOIN service_bindings sb
ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
@@ -2147,6 +2322,10 @@ export function listIpHealthByServiceIds(
ip: row.ip,
status: parsed.health_status,
latency_ms: parsed.health_latency_ms,
last_checked_at: row.last_checked_at,
last_error: row.last_error,
provider: normalizeStatusProvider(row.provider),
colo: row.colo,
});
result.set(row.service_id, list);
}
@@ -2195,7 +2374,7 @@ export function getIpHealthStatusRow(
): IpHealthStatus | null {
const rows = db.all<IpHealthStatus>(sql`
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
consecutive_successes, last_checked_at, last_error
consecutive_successes, last_checked_at, last_error, colo, provider
FROM ip_health_status
WHERE scope = ${scope} AND ref_id = ${refId} AND ip = ${ip}
LIMIT 1
@@ -2213,13 +2392,18 @@ export function upsertIpHealthStatus(
consecutiveFailures: number,
lastError: string | null,
consecutiveSuccesses = 0,
extras?: { colo?: string | null; provider?: HealthStatusProvider },
): void {
const colo = extras?.colo ?? null;
const provider = extras?.provider ?? "local";
db.run(sql`
INSERT INTO ip_health_status
(scope, ref_id, ip, status, latency_ms, consecutive_failures,
consecutive_successes, last_checked_at, last_error, created_at, updated_at)
consecutive_successes, last_checked_at, last_error, colo, provider,
created_at, updated_at)
VALUES (${scope}, ${refId}, ${ip}, ${status}, ${latencyMs}, ${consecutiveFailures},
${consecutiveSuccesses}, datetime('now'), ${lastError}, datetime('now'), datetime('now'))
${consecutiveSuccesses}, datetime('now'), ${lastError}, ${colo}, ${provider},
datetime('now'), datetime('now'))
ON CONFLICT(scope, ref_id, ip) DO UPDATE SET
status = excluded.status,
latency_ms = excluded.latency_ms,
@@ -2227,6 +2411,8 @@ export function upsertIpHealthStatus(
consecutive_successes = excluded.consecutive_successes,
last_checked_at = excluded.last_checked_at,
last_error = excluded.last_error,
colo = excluded.colo,
provider = excluded.provider,
updated_at = datetime('now')
`);
}
@@ -2307,13 +2493,73 @@ export function pruneStaleIpHealthStatus(
// --- Health Check Targets ---
function normalizeCnameHost(target: string, zoneName: string): string {
const trimmed = target.trim().toLowerCase().replace(/\.+$/, "");
if (!trimmed) return "";
if (trimmed.includes(".")) return trimmed;
const zone = zoneName.trim().toLowerCase().replace(/\.+$/, "");
return zone ? `${trimmed}.${zone}` : trimmed;
}
/**
* Unwrap a CNAME health target to origin IPs:
* 1) A/AAAA from local dns_records (follows CNAME chain)
* 2) this service's IP pool
*
* Hostname is kept as fallback so probes still run when nothing resolves.
*/
function resolveCnameProbeIps(
db: Db,
cnameTarget: string,
zoneName: string,
serviceId: number,
): string[] {
const fqdn = normalizeCnameHost(cnameTarget, zoneName);
if (fqdn) {
const fromDns = listOriginIpsForFqdn(db, fqdn).filter(isIpLiteral);
if (fromDns.length > 0) return [...new Set(fromDns)];
}
if (serviceId > 0) {
return [...new Set(listServiceIps(db, serviceId).filter(isIpLiteral))];
}
return [];
}
type RawHealthTarget = HealthCheckTarget & {
providers_json?: string | null;
aggregate?: string | null;
zone_name?: string | null;
service_id?: number | null;
};
function expandCnameHealthTargets(
db: Db,
rows: RawHealthTarget[],
): RawHealthTarget[] {
const expanded: RawHealthTarget[] = [];
for (const row of rows) {
const ips = resolveCnameProbeIps(
db,
row.ip,
row.zone_name ?? "",
row.service_id ?? 0,
);
const resolved = ips.length > 0 ? ips : [row.ip];
for (const ip of resolved) {
expanded.push({ ...row, ip });
}
}
return expanded;
}
export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
// FQDN for a binding: "@" => zone_name, else "<hostname>.<zone_name>".
// Used as SNI / Host header for HTTP(S) probes — the raw `sb.hostname` is just the record name
// (e.g. "de" or "@"), which would break TLS SNI (ssl alert 112 "unrecognized name").
const fqdnExpr = sql`CASE WHEN sb.hostname = '@' OR sb.hostname IS NULL THEN d.zone_name ELSE sb.hostname || '.' || d.zone_name END`;
const bindingTargets = db.all<HealthCheckTarget>(sql`
const bindingTargets = db
.all<RawHealthTarget>(sql`
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
${fqdnExpr} AS hostname,
sb.health_check_type AS type,
@@ -2321,17 +2567,22 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
sb.health_check_path AS path,
sb.health_check_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms,
sb.health_check_verify_tls AS verify_tls
sb.health_check_verify_tls AS verify_tls,
sb.health_check_providers AS providers_json,
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sb.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN domains d ON d.id = sb.domain_id
WHERE sb.health_check_enabled = 1
`);
`)
.filter((t) => isIpLiteral(t.ip));
// Group FQDN probes the same A-record IPs that DNS publishes (binding IPs of
// enabled services) — NOT the full service_ips pool. Pool-only dead IPs must
// not mark the group Down while the published domain stays healthy.
const groupTargets = db.all<HealthCheckTarget>(sql`
const groupTargets = db
.all<RawHealthTarget>(sql`
SELECT DISTINCT 'group' AS scope, sg.id AS ref_id, sbi.ip,
sg.domain AS hostname,
sg.health_check_type AS type,
@@ -2339,7 +2590,10 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls
sg.health_check_verify_tls AS verify_tls,
sg.health_check_providers AS providers_json,
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN services s ON s.id = sb.service_id
@@ -2350,13 +2604,14 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
AND s.enabled = 1
AND sg.enabled = 1
AND (sb.cname_target IS NULL OR sb.cname_target = '')
`);
`)
.filter((t) => isIpLiteral(t.ip));
// IPs of A-bindings of enabled services in a group whose group has health-check enabled.
// These inherit the group's health-check config (scope='binding', ref_id=binding_id),
// so per-domain badges reflect group rules. Skipped for bindings that already have
// their own health_check_enabled=1 (covered by bindingTargets above).
const groupInheritedBindingTargets = db.all<HealthCheckTarget>(sql`
const groupInheritedBindingTargets = db.all<RawHealthTarget>(sql`
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
${fqdnExpr} AS hostname,
sg.health_check_type AS type,
@@ -2364,7 +2619,10 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls
sg.health_check_verify_tls AS verify_tls,
sg.health_check_providers AS providers_json,
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sg.health_check_provider, 'local') AS provider
FROM service_binding_ips sbi
JOIN service_bindings sb ON sb.id = sbi.binding_id
JOIN domains d ON d.id = sb.domain_id
@@ -2375,10 +2633,13 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
AND s.enabled = 1
AND sg.enabled = 1
AND sb.health_check_enabled = 0
`);
`)
.filter((t) => isIpLiteral(t.ip));
// CNAME-bindings with their own health_check_enabled: probe the CNAME target host.
const cnameBindingTargets = db.all<HealthCheckTarget>(sql`
// CNAME-bindings: unwrap to origin/service IPs so ip_health keys match the IP table.
const cnameBindingTargets = expandCnameHealthTargets(
db,
db.all<RawHealthTarget>(sql`
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
${fqdnExpr} AS hostname,
sb.health_check_type AS type,
@@ -2386,7 +2647,12 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
sb.health_check_path AS path,
sb.health_check_expected_status AS expected_status,
sb.health_check_timeout_ms AS timeout_ms,
sb.health_check_verify_tls AS verify_tls
sb.health_check_verify_tls AS verify_tls,
sb.health_check_providers AS providers_json,
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sb.health_check_provider, 'local') AS provider,
d.zone_name AS zone_name,
sb.service_id AS service_id
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -2394,11 +2660,14 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
AND sb.cname_target IS NOT NULL
AND sb.cname_target <> ''
AND s.enabled = 1
`);
`),
);
// CNAME-bindings of services in a group with group health-check enabled
// (inherit group config). Only for bindings without their own health_check_enabled.
const groupInheritedCnameBindingTargets = db.all<HealthCheckTarget>(sql`
const groupInheritedCnameBindingTargets = expandCnameHealthTargets(
db,
db.all<RawHealthTarget>(sql`
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
${fqdnExpr} AS hostname,
sg.health_check_type AS type,
@@ -2406,7 +2675,12 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
sg.health_check_path AS path,
sg.health_check_expected_status AS expected_status,
sg.health_check_timeout_ms AS timeout_ms,
sg.health_check_verify_tls AS verify_tls
sg.health_check_verify_tls AS verify_tls,
sg.health_check_providers AS providers_json,
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
COALESCE(sg.health_check_provider, 'local') AS provider,
d.zone_name AS zone_name,
sb.service_id AS service_id
FROM service_bindings sb
JOIN domains d ON d.id = sb.domain_id
JOIN services s ON s.id = sb.service_id
@@ -2418,7 +2692,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
AND sb.health_check_enabled = 0
AND sb.cname_target IS NOT NULL
AND sb.cname_target <> ''
`);
`),
);
return [
...bindingTargets,
@@ -2426,10 +2701,28 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
...groupInheritedBindingTargets,
...cnameBindingTargets,
...groupInheritedCnameBindingTargets,
].map((t) => ({
...t,
verify_tls: Boolean(t.verify_tls),
}));
].map((t) => {
const row = t as HealthCheckTarget & {
providers_json?: string | null;
aggregate?: string | null;
};
const providers = parseHealthProviders(row.providers_json, row.provider);
return {
scope: row.scope,
ref_id: row.ref_id,
ip: row.ip,
hostname: row.hostname,
type: row.type,
port: row.port,
path: row.path,
expected_status: row.expected_status,
timeout_ms: row.timeout_ms,
verify_tls: Boolean(row.verify_tls),
providers,
aggregate: parseHealthAggregate(row.aggregate),
provider: derivePrimaryProvider(providers),
};
});
}
// --- Domain tags ---
@@ -2633,6 +2926,94 @@ export function listDomainMonitorResultsForDomain(
`);
}
// --- Health probe journal ---
const HEALTH_PROBE_LOG_KEEP = 50;
export function insertHealthProbeLog(
db: Db,
entry: {
scope: HealthCheckScope;
refId: number;
ip: string;
provider: HealthCheckProvider;
status: string;
ok: boolean;
latencyMs: number | null;
colo: string | null;
error: string | null;
},
): void {
db.insert(healthProbeLog)
.values({
scope: entry.scope,
ref_id: entry.refId,
ip: entry.ip,
provider: entry.provider,
status: entry.status,
ok: entry.ok,
latency_ms: entry.latencyMs,
colo: entry.colo,
error: entry.error,
})
.run();
db.run(sql`
DELETE FROM health_probe_log
WHERE id IN (
SELECT id FROM health_probe_log
WHERE scope = ${entry.scope} AND ref_id = ${entry.refId} AND ip = ${entry.ip}
ORDER BY checked_at DESC, id DESC
LIMIT -1 OFFSET ${HEALTH_PROBE_LOG_KEEP}
)
`);
}
export function listHealthProbeLogForService(
db: Db,
serviceId: number,
limit = 50,
): {
id: number;
scope: string;
ref_id: number;
ip: string;
provider: HealthCheckProvider;
status: string;
ok: boolean;
latency_ms: number | null;
colo: string | null;
error: string | null;
checked_at: string;
}[] {
const rows = db.all<{
id: number;
scope: string;
ref_id: number;
ip: string;
provider: string;
status: string;
ok: number;
latency_ms: number | null;
colo: string | null;
error: string | null;
checked_at: string;
}>(sql`
SELECT l.id, l.scope, l.ref_id, l.ip, l.provider, l.status, l.ok,
l.latency_ms, l.colo, l.error, l.checked_at
FROM health_probe_log l
INNER JOIN service_bindings sb
ON l.scope = 'binding' AND l.ref_id = sb.id
WHERE sb.service_id = ${serviceId}
ORDER BY l.checked_at DESC, l.id DESC
LIMIT ${limit}
`);
return rows.map((row) => ({
...row,
provider: parseHealthProviders(null, row.provider)[0] ?? "local",
ok: Boolean(row.ok),
}));
}
// --- Notification log ---
export function insertNotificationLog(
+45
View File
@@ -64,6 +64,9 @@ export const serviceGroups = sqliteTable("service_groups", {
health_check_verify_tls: integer("health_check_verify_tls", { mode: "boolean" })
.notNull()
.default(false),
health_check_provider: text("health_check_provider").notNull().default("local"),
health_check_providers: text("health_check_providers").notNull().default('["local"]'),
health_check_aggregate: text("health_check_aggregate").notNull().default("majority"),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
@@ -165,6 +168,16 @@ export const serviceBindings = sqliteTable(
})
.notNull()
.default(false),
health_check_provider: text("health_check_provider")
.notNull()
.default("local"),
health_check_providers: text("health_check_providers")
.notNull()
.default('["local"]'),
health_check_aggregate: text("health_check_aggregate")
.notNull()
.default("majority"),
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
operation_version: integer("operation_version").notNull().default(0),
created_at: text("created_at")
@@ -312,6 +325,9 @@ export const certificates = sqliteTable("certificates", {
subdomain_id: integer("subdomain_id").references(() => subdomains.id, {
onDelete: "set null",
}),
service_id: integer("service_id").references(() => services.id, {
onDelete: "set null",
}),
hostname: text("hostname").notNull().unique(),
expires_at: text("expires_at"),
last_checked_at: text("last_checked_at"),
@@ -354,6 +370,8 @@ export const ipHealthStatus = sqliteTable(
.default(0),
last_checked_at: text("last_checked_at"),
last_error: text("last_error"),
colo: text("colo"),
provider: text("provider").notNull().default("local"),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
@@ -385,6 +403,16 @@ export const appSettings = sqliteTable("app_settings", {
health_down_failures: integer("health_down_failures"),
health_latency_warn_ms: integer("health_latency_warn_ms"),
health_success_recoveries: integer("health_success_recoveries"),
health_worker_url: text("health_worker_url"),
health_worker_token: text("health_worker_token"),
health_worker_account_id: text("health_worker_account_id"),
health_worker_kv_namespace_id: text("health_worker_kv_namespace_id"),
health_worker_error: text("health_worker_error"),
health_worker_deployed_at: text("health_worker_deployed_at"),
health_worker_last_ingest_at: text("health_worker_last_ingest_at"),
globalping_token: text("globalping_token"),
globalping_locations: text("globalping_locations"),
globalping_limit: integer("globalping_limit"),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
@@ -441,6 +469,22 @@ export const domainMonitorResults = sqliteTable("domain_monitor_results", {
.default(sql`datetime('now')`),
});
export const healthProbeLog = sqliteTable("health_probe_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
scope: text("scope").notNull(),
ref_id: integer("ref_id").notNull(),
ip: text("ip").notNull(),
provider: text("provider").notNull(),
status: text("status").notNull(),
ok: integer("ok", { mode: "boolean" }).notNull(),
latency_ms: integer("latency_ms"),
colo: text("colo"),
error: text("error"),
checked_at: text("checked_at")
.notNull()
.default(sql`datetime('now')`),
});
export const notificationLog = sqliteTable("notification_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
kind: text("kind").notNull(),
@@ -494,6 +538,7 @@ export const schema = {
domainTags,
domainMonitors,
domainMonitorResults,
healthProbeLog,
notificationLog,
auditLog,
};
+112 -2
View File
@@ -1,4 +1,5 @@
import { eq } from "drizzle-orm";
import type { HealthWorkerStatus } from "@cfdm/shared";
import type { Db } from "./client.js";
import { appSettings } from "./schema.js";
@@ -19,6 +20,17 @@ export type AppSettingsDto = {
vpsTrackerSyncEnabled: boolean;
vpsTrackerLastSyncAt: string | null;
showQuickActions: boolean;
healthWorkerUrl: string;
healthWorkerTokenSet: boolean;
healthWorkerStatus: HealthWorkerStatus;
healthWorkerAccountId: string;
healthWorkerKvNamespaceId: string;
healthWorkerError: string | null;
healthWorkerDeployedAt: string | null;
healthWorkerLastIngestAt: string | null;
globalpingTokenSet: boolean;
globalpingLocations: string;
globalpingLimit: number;
} & HealthEngineSettings;
export type AppSettingsPatch = {
@@ -31,14 +43,38 @@ export type AppSettingsPatch = {
healthDownFailures?: number;
healthLatencyWarnMs?: number;
healthSuccessRecoveries?: number;
healthWorkerUrl?: string;
healthWorkerToken?: string;
healthWorkerAccountId?: string | null;
healthWorkerKvNamespaceId?: string | null;
healthWorkerError?: string | null;
healthWorkerDeployedAt?: string | null;
healthWorkerLastIngestAt?: string | null;
globalpingToken?: string;
globalpingLocations?: string;
globalpingLimit?: number;
};
export type HealthEngineFallbacks = HealthEngineSettings;
export type HealthEngineFallbacks = HealthEngineSettings & {
healthWorkerUrl: string;
healthWorkerTokenSet: boolean;
};
function coalesceInt(value: number | null | undefined, fallback: number): number {
return value == null || Number.isNaN(value) || value < 1 ? fallback : value;
}
function workerStatus(
row: typeof appSettings.$inferSelect,
envUrl: string,
): HealthWorkerStatus {
if (row.health_worker_error?.trim()) return "error";
const url = row.health_worker_url?.trim() || envUrl;
const kv = row.health_worker_kv_namespace_id?.trim();
if (kv && url) return "ready";
return "missing";
}
function toDto(
row: typeof appSettings.$inferSelect,
fallbacks?: HealthEngineFallbacks,
@@ -49,6 +85,8 @@ function toDto(
healthDownFailures: 2,
healthLatencyWarnMs: 1000,
healthSuccessRecoveries: 2,
healthWorkerUrl: "",
healthWorkerTokenSet: false,
};
return {
id: row.id,
@@ -77,6 +115,23 @@ function toDto(
row.health_success_recoveries,
env.healthSuccessRecoveries,
),
healthWorkerUrl: row.health_worker_url?.trim() || env.healthWorkerUrl,
healthWorkerTokenSet:
Boolean(row.health_worker_token?.trim()) || env.healthWorkerTokenSet,
healthWorkerAccountId: row.health_worker_account_id?.trim() ?? "",
healthWorkerKvNamespaceId: row.health_worker_kv_namespace_id?.trim() ?? "",
healthWorkerError: row.health_worker_error?.trim() || null,
healthWorkerDeployedAt: row.health_worker_deployed_at ?? null,
healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null,
healthWorkerStatus: workerStatus(row, env.healthWorkerUrl),
globalpingTokenSet: Boolean(row.globalping_token?.trim()),
globalpingLocations: row.globalping_locations?.trim() || "World",
globalpingLimit:
row.globalping_limit == null ||
Number.isNaN(row.globalping_limit) ||
row.globalping_limit < 1
? 3
: Math.min(10, row.globalping_limit),
};
}
@@ -103,17 +158,31 @@ export function getAppSettingsSecrets(db: Db): {
vpsTrackerUrl: string;
vpsTrackerIntegrationToken: string;
vpsTrackerSyncEnabled: boolean;
healthWorkerUrl: string;
healthWorkerToken: string;
globalpingToken: string;
globalpingLocations: string;
globalpingLimit: number;
} {
const row = db
.select()
.from(appSettings)
.where(eq(appSettings.id, SETTINGS_ID))
.get();
const limit = row?.globalping_limit;
return {
vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "",
vpsTrackerIntegrationToken:
row?.vps_tracker_integration_token?.trim() ?? "",
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled),
healthWorkerUrl: row?.health_worker_url?.trim() ?? "",
healthWorkerToken: row?.health_worker_token?.trim() ?? "",
globalpingToken: row?.globalping_token?.trim() ?? "",
globalpingLocations: row?.globalping_locations?.trim() || "World",
globalpingLimit:
limit == null || Number.isNaN(limit) || limit < 1
? 3
: Math.min(10, limit),
};
}
@@ -138,7 +207,6 @@ export function updateAppSettings(
db.update(appSettings)
.set({
// app_switcher_json: deprecated — source of truth is auth-portal
vps_tracker_url:
patch.vpsTrackerUrl !== undefined
? patch.vpsTrackerUrl
@@ -176,6 +244,48 @@ export function updateAppSettings(
patch.healthSuccessRecoveries !== undefined
? patch.healthSuccessRecoveries
: current.health_success_recoveries,
health_worker_url:
patch.healthWorkerUrl !== undefined
? patch.healthWorkerUrl.trim() || null
: current.health_worker_url,
health_worker_token:
patch.healthWorkerToken !== undefined &&
patch.healthWorkerToken.trim() !== ""
? patch.healthWorkerToken
: current.health_worker_token,
health_worker_account_id:
patch.healthWorkerAccountId !== undefined
? patch.healthWorkerAccountId?.trim() || null
: current.health_worker_account_id,
health_worker_kv_namespace_id:
patch.healthWorkerKvNamespaceId !== undefined
? patch.healthWorkerKvNamespaceId?.trim() || null
: current.health_worker_kv_namespace_id,
health_worker_error:
patch.healthWorkerError !== undefined
? patch.healthWorkerError?.trim() || null
: current.health_worker_error,
health_worker_deployed_at:
patch.healthWorkerDeployedAt !== undefined
? patch.healthWorkerDeployedAt
: current.health_worker_deployed_at,
health_worker_last_ingest_at:
patch.healthWorkerLastIngestAt !== undefined
? patch.healthWorkerLastIngestAt
: current.health_worker_last_ingest_at,
globalping_token:
patch.globalpingToken !== undefined &&
patch.globalpingToken.trim() !== ""
? patch.globalpingToken
: current.globalping_token,
globalping_locations:
patch.globalpingLocations !== undefined
? patch.globalpingLocations.trim() || "World"
: current.globalping_locations,
globalping_limit:
patch.globalpingLimit !== undefined
? Math.min(10, Math.max(1, patch.globalpingLimit))
: current.globalping_limit,
updated_at: new Date().toISOString(),
})
.where(eq(appSettings.id, SETTINGS_ID))
+523 -18
View File
@@ -15,6 +15,36 @@ declare const CERT_MONITOR_REQUIRED = "required";
declare const CERT_MONITOR_SKIPPED = "skipped";
declare const CERT_MONITORING_VALUES: readonly ["auto", "required", "skipped"];
declare const HEALTH_CHECK_PROVIDERS: readonly ["local", "cloudflare", "globalping"];
type HealthCheckProvider = (typeof HEALTH_CHECK_PROVIDERS)[number];
declare const HEALTH_STATUS_PROVIDERS: readonly ["local", "cloudflare", "globalping", "aggregate"];
type HealthStatusProvider = (typeof HEALTH_STATUS_PROVIDERS)[number];
declare const HEALTH_CHECK_AGGREGATES: readonly ["any", "all", "majority"];
type HealthCheckAggregate = (typeof HEALTH_CHECK_AGGREGATES)[number];
declare function normalizeProbeProvider(value: unknown): HealthCheckProvider;
declare function normalizeStatusProvider(value: unknown): HealthStatusProvider;
declare function uniqueHealthProviders(values: readonly unknown[]): HealthCheckProvider[];
declare function parseHealthProviders(json: unknown, fallback?: unknown): HealthCheckProvider[];
declare function serializeHealthProviders(providers: readonly HealthCheckProvider[]): string;
declare function parseHealthAggregate(value: unknown): HealthCheckAggregate;
declare function derivePrimaryProvider(providers: readonly HealthCheckProvider[]): HealthCheckProvider;
declare function targetProviders(target: {
providers?: readonly HealthCheckProvider[] | null;
provider?: HealthCheckProvider | null;
}): HealthCheckProvider[];
declare function targetHasProvider(target: {
providers?: readonly HealthCheckProvider[] | null;
provider?: HealthCheckProvider | null;
}, provider: HealthCheckProvider): boolean;
/**
* any Down if at least one source is Down (ok only if all ok).
* all Down only if every source is Down (ok if any ok).
* majority Down if a strict majority of sources are Down (2 both, 3 2).
*/
declare function aggregateHealthOk(oks: readonly boolean[], policy: HealthCheckAggregate): boolean;
declare function clampGlobalpingLimit(value: unknown, fallback?: number): number;
declare function parseGlobalpingLocations(value: unknown): string[];
interface ServiceGroup$1 {
id: number;
name: string;
@@ -31,6 +61,9 @@ interface ServiceGroup$1 {
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
created_at: string;
updated_at: string;
}
@@ -60,6 +93,10 @@ interface ServiceBinding {
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
cert_monitoring: string;
routing_strategy: LbMode;
operation_version: number;
created_at: string;
@@ -90,6 +127,10 @@ interface ServiceBindingView {
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
cert_monitoring: string;
sync_status: string | null;
created_at: string;
updated_at: string;
@@ -114,6 +155,10 @@ interface ServiceDomainBindingView {
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
cert_monitoring: string;
sync_status: string | null;
}
interface ServiceView$1 {
@@ -134,6 +179,8 @@ interface ServiceView$1 {
health_latency_ms: number | null;
ip_health: ServiceIpHealth$1[];
ip_enabled: Record<string, boolean>;
lb_mode: LbMode;
active_ips: string[];
}
interface SyncJob {
id: string;
@@ -147,6 +194,10 @@ interface CfZone {
id: string;
name: string;
status: string;
account?: {
id: string;
name?: string;
};
}
interface CfDnsRecord {
id?: string;
@@ -181,7 +232,6 @@ type LbMode = "round_robin" | "failover" | "weighted";
type HealthCheckType = "tcp" | "http" | "ping" | "dns";
type IpHealthState = "up" | "down" | "degraded" | "unknown";
type NodeHealthState = "unknown" | "checking" | "healthy" | "degraded" | "unhealthy" | "disabled";
type HealthCheckProvider = "local" | "cloudflare";
type HealthCheckScope = "binding" | "group";
interface IpHealthStatus {
scope: HealthCheckScope;
@@ -193,11 +243,17 @@ interface IpHealthStatus {
consecutive_successes?: number;
last_checked_at: string | null;
last_error: string | null;
colo?: string | null;
provider?: HealthStatusProvider;
}
interface ServiceIpHealth$1 {
ip: string;
status: IpHealthState;
latency_ms: number | null;
last_checked_at?: string | null;
last_error?: string | null;
provider?: HealthStatusProvider;
colo?: string | null;
}
interface ServiceNode {
id: number;
@@ -275,6 +331,9 @@ interface HealthCheckTarget {
expected_status: number | null;
timeout_ms: number;
verify_tls: boolean;
provider: HealthCheckProvider;
providers?: HealthCheckProvider[];
aggregate?: HealthCheckAggregate;
}
declare class ValidationError extends Error {
@@ -355,7 +414,24 @@ declare const nodeHealthStateSchema: z.ZodEnum<{
declare const healthCheckProviderSchema: z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>;
declare const healthStatusProviderSchema: z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
aggregate: "aggregate";
}>;
declare const healthCheckAggregateSchema: z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>;
declare const healthCheckProvidersSchema: z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
declare const healthCheckScopeSchema: z.ZodEnum<{
binding: "binding";
group: "group";
@@ -378,6 +454,13 @@ declare const ipHealthStatusSchema: z.ZodObject<{
consecutive_successes: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
last_checked_at: z.ZodNullable<z.ZodString>;
last_error: z.ZodNullable<z.ZodString>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
aggregate: "aggregate";
}>>;
}, z.core.$strip>;
declare const serviceIpHealthSchema: z.ZodObject<{
ip: z.ZodString;
@@ -388,8 +471,40 @@ declare const serviceIpHealthSchema: z.ZodObject<{
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
last_checked_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
last_error: z.ZodOptional<z.ZodNullable<z.ZodString>>;
provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
aggregate: "aggregate";
}>>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>;
type ServiceIpHealth = z.infer<typeof serviceIpHealthSchema>;
declare const healthProbeLogSchema: z.ZodObject<{
id: z.ZodNumber;
scope: z.ZodString;
ref_id: z.ZodNumber;
ip: z.ZodString;
provider: z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>;
status: z.ZodEnum<{
unknown: "unknown";
up: "up";
down: "down";
degraded: "degraded";
}>;
ok: z.ZodCoercedBoolean<unknown>;
latency_ms: z.ZodNullable<z.ZodNumber>;
colo: z.ZodNullable<z.ZodString>;
error: z.ZodNullable<z.ZodString>;
checked_at: z.ZodString;
}, z.core.$strip>;
type HealthProbeLog = z.infer<typeof healthProbeLogSchema>;
declare const groupSchema: z.ZodObject<{
id: z.ZodNumber;
name: z.ZodString;
@@ -406,21 +521,21 @@ declare const groupWithStatsSchema: z.ZodObject<{
domain_count: z.ZodNumber;
}, z.core.$strip>;
declare const serviceGroupTypeSchema: z.ZodEnum<{
custom: "custom";
vpn: "vpn";
network: "network";
internet: "internet";
bgp: "bgp";
custom: "custom";
}>;
declare const serviceGroupSchema: z.ZodObject<{
id: z.ZodNumber;
name: z.ZodString;
type: z.ZodCatch<z.ZodEnum<{
custom: "custom";
vpn: "vpn";
network: "network";
internet: "internet";
bgp: "bgp";
custom: "custom";
}>>;
icon: z.ZodDefault<z.ZodNullable<z.ZodString>>;
domain: z.ZodDefault<z.ZodNullable<z.ZodString>>;
@@ -443,6 +558,21 @@ declare const serviceGroupSchema: z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
created_at: z.ZodString;
updated_at: z.ZodString;
}, z.core.$strip>;
@@ -492,6 +622,26 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
cert_monitoring: z.ZodDefault<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
@@ -513,6 +663,10 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
target_ip?: string | null | undefined;
}, {
@@ -531,6 +685,10 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
@@ -584,6 +742,26 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
cert_monitoring: z.ZodDefault<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
@@ -605,6 +783,10 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
target_ip?: string | null | undefined;
}, {
@@ -623,6 +805,10 @@ declare const serviceViewSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
@@ -646,18 +832,33 @@ declare const serviceViewSchema: z.ZodObject<{
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
last_checked_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
last_error: z.ZodOptional<z.ZodNullable<z.ZodString>>;
provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
aggregate: "aggregate";
}>>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
declare const serviceGroupViewSchema: z.ZodObject<{
id: z.ZodNumber;
name: z.ZodString;
type: z.ZodCatch<z.ZodEnum<{
custom: "custom";
vpn: "vpn";
network: "network";
internet: "internet";
bgp: "bgp";
custom: "custom";
}>>;
icon: z.ZodDefault<z.ZodNullable<z.ZodString>>;
domain: z.ZodDefault<z.ZodNullable<z.ZodString>>;
@@ -680,6 +881,21 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
created_at: z.ZodString;
updated_at: z.ZodString;
services: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -728,6 +944,26 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
cert_monitoring: z.ZodDefault<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
@@ -749,6 +985,10 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
target_ip?: string | null | undefined;
}, {
@@ -767,6 +1007,10 @@ declare const serviceGroupViewSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
@@ -790,8 +1034,23 @@ declare const serviceGroupViewSchema: z.ZodObject<{
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
last_checked_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
last_error: z.ZodOptional<z.ZodNullable<z.ZodString>>;
provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
aggregate: "aggregate";
}>>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>>;
health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown";
@@ -806,11 +1065,11 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
id: z.ZodNumber;
name: z.ZodString;
type: z.ZodCatch<z.ZodEnum<{
custom: "custom";
vpn: "vpn";
network: "network";
internet: "internet";
bgp: "bgp";
custom: "custom";
}>>;
icon: z.ZodDefault<z.ZodNullable<z.ZodString>>;
domain: z.ZodDefault<z.ZodNullable<z.ZodString>>;
@@ -833,6 +1092,21 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
created_at: z.ZodString;
updated_at: z.ZodString;
services: z.ZodDefault<z.ZodArray<z.ZodObject<{
@@ -881,6 +1155,26 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
cert_monitoring: z.ZodDefault<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
@@ -902,6 +1196,10 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
target_ip?: string | null | undefined;
}, {
@@ -920,6 +1218,10 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
@@ -943,8 +1245,23 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
last_checked_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
last_error: z.ZodOptional<z.ZodNullable<z.ZodString>>;
provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
aggregate: "aggregate";
}>>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>>;
health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown";
@@ -1000,6 +1317,26 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodCatch<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
cert_monitoring: z.ZodDefault<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>, z.ZodTransform<{
target_ips: string[];
@@ -1021,6 +1358,10 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
target_ip?: string | null | undefined;
}, {
@@ -1039,6 +1380,10 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: "local" | "cloudflare" | "globalping";
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
health_check_aggregate: "any" | "all" | "majority";
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
target_ips?: string[] | undefined;
target_ip?: string | null | undefined;
@@ -1062,8 +1407,23 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
degraded: "degraded";
}>;
latency_ms: z.ZodNullable<z.ZodNumber>;
last_checked_at: z.ZodOptional<z.ZodNullable<z.ZodString>>;
last_error: z.ZodOptional<z.ZodNullable<z.ZodString>>;
provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
aggregate: "aggregate";
}>>;
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>>;
}, z.core.$strip>;
declare const domainSchema: z.ZodObject<{
@@ -1149,6 +1509,11 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
cert_monitoring: z.ZodDefault<z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>>;
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
created_at: z.ZodString;
updated_at: z.ZodString;
@@ -1176,6 +1541,7 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
created_at: string;
updated_at: string;
@@ -1200,6 +1566,7 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
cert_monitoring: "auto" | "required" | "skipped";
sync_status: string | null;
created_at: string;
updated_at: string;
@@ -1227,6 +1594,8 @@ declare const certificateSchema: z.ZodObject<{
id: z.ZodNumber;
domain_id: z.ZodNumber;
subdomain_id: z.ZodNullable<z.ZodNumber>;
service_id: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
service_name: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
hostname: z.ZodString;
expires_at: z.ZodNullable<z.ZodString>;
last_checked_at: z.ZodNullable<z.ZodString>;
@@ -1235,6 +1604,22 @@ declare const certificateSchema: z.ZodObject<{
created_at: z.ZodString;
updated_at: z.ZodString;
}, z.core.$strip>;
declare const serviceCertificateRowSchema: z.ZodObject<{
binding_id: z.ZodNumber;
domain_id: z.ZodNumber;
service_id: z.ZodNumber;
hostname: z.ZodString;
cert_monitoring: z.ZodEnum<{
auto: "auto";
required: "required";
skipped: "skipped";
}>;
id: z.ZodNullable<z.ZodNumber>;
status: z.ZodString;
expires_at: z.ZodNullable<z.ZodString>;
last_checked_at: z.ZodNullable<z.ZodString>;
last_error: z.ZodNullable<z.ZodString>;
}, z.core.$strip>;
type Group = z.infer<typeof groupSchema>;
type GroupWithStats = z.infer<typeof groupWithStatsSchema>;
type Service = z.infer<typeof serviceSchema>;
@@ -1247,12 +1632,13 @@ type Domain = z.infer<typeof domainSchema>;
type DomainListItem = z.infer<typeof domainListItemSchema>;
type DnsRecord = z.infer<typeof dnsRecordSchema>;
type Certificate = z.infer<typeof certificateSchema>;
type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>;
declare const createGroupSchema: z.ZodObject<{
name: z.ZodString;
slug: z.ZodString;
}, z.core.$strip>;
declare const healthCheckConfigSchema: z.ZodObject<{
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
@@ -1264,7 +1650,22 @@ declare const healthCheckConfigSchema: z.ZodObject<{
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
}, z.core.$strip>;
type HealthCheckConfig = z.infer<typeof healthCheckConfigSchema>;
declare const createServiceSchema: z.ZodObject<{
@@ -1279,7 +1680,7 @@ declare const createServiceWithConfigSchema: z.ZodObject<{
lb_weight: z.ZodOptional<z.ZodNumber>;
lb_priority: z.ZodOptional<z.ZodNumber>;
domains: z.ZodDefault<z.ZodArray<z.ZodObject<{
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
@@ -1291,7 +1692,22 @@ declare const createServiceWithConfigSchema: z.ZodObject<{
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
fqdn: z.ZodString;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_cname: z.ZodOptional<z.ZodString>;
@@ -1467,7 +1883,7 @@ declare const updateServiceConfigSchema: z.ZodObject<{
lb_weight: z.ZodOptional<z.ZodNumber>;
lb_priority: z.ZodOptional<z.ZodNumber>;
domains: z.ZodOptional<z.ZodArray<z.ZodObject<{
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
@@ -1479,7 +1895,22 @@ declare const updateServiceConfigSchema: z.ZodObject<{
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
fqdn: z.ZodString;
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
target_cname: z.ZodOptional<z.ZodString>;
@@ -1494,7 +1925,7 @@ declare const updateServiceConfigSchema: z.ZodObject<{
}, z.core.$strip>;
type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>;
declare const createServiceGroupSchema: z.ZodObject<{
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
@@ -1506,14 +1937,29 @@ declare const createServiceGroupSchema: z.ZodObject<{
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
name: z.ZodString;
type: z.ZodDefault<z.ZodEnum<{
custom: "custom";
vpn: "vpn";
network: "network";
internet: "internet";
bgp: "bgp";
custom: "custom";
}>>;
icon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
domain: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1524,7 +1970,7 @@ declare const createServiceGroupSchema: z.ZodObject<{
}>>;
}, z.core.$strip>;
declare const updateServiceGroupSchema: z.ZodObject<{
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_type: z.ZodOptional<z.ZodEnum<{
tcp: "tcp";
http: "http";
@@ -1536,14 +1982,29 @@ declare const updateServiceGroupSchema: z.ZodObject<{
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
health_check_provider: z.ZodOptional<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>;
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
any: "any";
all: "all";
majority: "majority";
}>>;
name: z.ZodOptional<z.ZodString>;
type: z.ZodOptional<z.ZodEnum<{
custom: "custom";
vpn: "vpn";
network: "network";
internet: "internet";
bgp: "bgp";
custom: "custom";
}>>;
icon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
domain: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1637,6 +2098,7 @@ declare const originHealthCheckSchema: z.ZodObject<{
provider: z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>;
cf_healthcheck_id: z.ZodNullable<z.ZodString>;
cf_zone_id: z.ZodNullable<z.ZodString>;
@@ -1658,6 +2120,7 @@ declare const createOriginHealthCheckSchema: z.ZodObject<{
provider: z.ZodEnum<{
local: "local";
cloudflare: "cloudflare";
globalping: "globalping";
}>;
name: z.ZodString;
cf_zone_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1783,6 +2246,11 @@ declare const appSettingsPatchSchema: z.ZodObject<{
healthDownFailures: z.ZodOptional<z.ZodNumber>;
healthLatencyWarnMs: z.ZodOptional<z.ZodNumber>;
healthSuccessRecoveries: z.ZodOptional<z.ZodNumber>;
healthWorkerUrl: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodLiteral<"">]>>;
healthWorkerToken: z.ZodOptional<z.ZodString>;
globalpingToken: z.ZodOptional<z.ZodString>;
globalpingLocations: z.ZodOptional<z.ZodString>;
globalpingLimit: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>;
type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
declare const vpsTrackerEventSchema: z.ZodObject<{
@@ -1799,6 +2267,43 @@ declare const vpsTrackerEventSchema: z.ZodObject<{
}, z.core.$strip>;
type VpsTrackerEvent = z.infer<typeof vpsTrackerEventSchema>;
declare const HEALTH_PROBE_SCRIPT_NAME = "cfdm-health-probe";
declare const HEALTH_PROBE_KV_TITLE = "cfdm-health-probe";
declare const HEALTH_KV_TARGETS_KEY = "targets";
declare const HEALTH_KV_RESULTS_KEY = "results";
declare const HEALTH_KV_CURSOR_KEY = "cursor";
declare const HEALTH_PROBE_BATCH = 48;
declare const HEALTH_PROBE_CONCURRENCY = 5;
type HealthWorkerStatus = "missing" | "ready" | "error";
interface HealthProbeTargetItem {
key: string;
ip: string;
hostname: string;
type: "tcp" | "http";
port: number;
path?: string;
expectedStatus?: number | null;
timeoutMs?: number;
verifyTls?: boolean;
}
interface HealthProbeTargetsDoc {
fingerprint: string;
updatedAt: string;
items: HealthProbeTargetItem[];
}
interface HealthProbeResultItem {
key: string;
ok: boolean;
latencyMs: number;
error: string | null;
}
interface HealthProbeResultsDoc {
probedAt: string;
colo: string | null;
fingerprint?: string;
items: HealthProbeResultItem[];
}
declare const AUDIT_SEVERITIES: readonly ["info", "warning", "critical"];
type AuditSeverity = (typeof AUDIT_SEVERITIES)[number];
declare const auditSeveritySchema: z.ZodEnum<{
@@ -1907,4 +2412,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 ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, 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, toggleServiceIpSchema, 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, HEALTH_CHECK_AGGREGATES, HEALTH_CHECK_PROVIDERS, HEALTH_KV_CURSOR_KEY, HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, HEALTH_PROBE_BATCH, HEALTH_PROBE_CONCURRENCY, HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, HEALTH_STATUS_PROVIDERS, type HealthCheckAggregate, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthProbeLog, type HealthProbeResultItem, type HealthProbeResultsDoc, type HealthProbeTargetItem, type HealthProbeTargetsDoc, type HealthStatusProvider, type HealthStatusQuery, type HealthWorkerStatus, 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 ServiceCertificateRow, 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 ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, aggregateHealthOk, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, clampGlobalpingLimit, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, derivePrimaryProvider, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckAggregateSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckProvidersSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, healthStatusProviderSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, normalizeProbeProvider, normalizeStatusProvider, notificationLogSchema, originHealthCheckSchema, parseFqdn, parseGlobalpingLocations, parseHealthAggregate, parseHealthProviders, reorderServicesSchema, serializeHealthProviders, serviceBindingSchema, serviceCertificateRowSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, targetHasProvider, targetProviders, toggleEnabledSchema, toggleServiceIpSchema, uniqueHealthProviders, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
+200 -7
View File
@@ -165,6 +165,100 @@ function bindingToFqdn(binding) {
// src/schemas.ts
import { z } from "zod";
// src/health-providers.ts
var HEALTH_CHECK_PROVIDERS = [
"local",
"cloudflare",
"globalping"
];
var HEALTH_STATUS_PROVIDERS = [
...HEALTH_CHECK_PROVIDERS,
"aggregate"
];
var HEALTH_CHECK_AGGREGATES = ["any", "all", "majority"];
var PROVIDER_SET = new Set(HEALTH_CHECK_PROVIDERS);
var STATUS_SET = new Set(HEALTH_STATUS_PROVIDERS);
var AGGREGATE_SET = new Set(HEALTH_CHECK_AGGREGATES);
function normalizeProbeProvider(value) {
return value === "cloudflare" || value === "globalping" || value === "local" ? value : "local";
}
function normalizeStatusProvider(value) {
if (typeof value === "string" && STATUS_SET.has(value)) {
return value;
}
return "local";
}
function uniqueHealthProviders(values) {
const out = [];
for (const value of values) {
if (!PROVIDER_SET.has(String(value))) continue;
const next = value;
if (!out.includes(next)) out.push(next);
}
return out;
}
function parseHealthProviders(json, fallback) {
if (Array.isArray(json)) {
const parsed = uniqueHealthProviders(json);
if (parsed.length > 0) return parsed;
}
if (typeof json === "string" && json.trim()) {
const trimmed = json.trim();
if (trimmed.startsWith("[")) {
try {
const parsed = uniqueHealthProviders(JSON.parse(trimmed));
if (parsed.length > 0) return parsed;
} catch {
}
}
const one = uniqueHealthProviders(trimmed.split(","));
if (one.length > 0) return one;
}
return [normalizeProbeProvider(fallback)];
}
function serializeHealthProviders(providers) {
const unique = uniqueHealthProviders(providers);
return JSON.stringify(unique.length > 0 ? unique : ["local"]);
}
function parseHealthAggregate(value) {
if (typeof value === "string" && AGGREGATE_SET.has(value)) {
return value;
}
return "majority";
}
function derivePrimaryProvider(providers) {
return uniqueHealthProviders(providers)[0] ?? "local";
}
function targetProviders(target) {
if (target.providers && target.providers.length > 0) {
return uniqueHealthProviders(target.providers);
}
return [normalizeProbeProvider(target.provider)];
}
function targetHasProvider(target, provider) {
return targetProviders(target).includes(provider);
}
function aggregateHealthOk(oks, policy) {
const n = oks.length;
if (n === 0) return false;
const down = oks.filter((ok) => !ok).length;
if (policy === "any") return down === 0;
if (policy === "all") return down < n;
return down < Math.floor(n / 2) + 1;
}
function clampGlobalpingLimit(value, fallback = 3) {
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(10, Math.max(1, Math.trunc(n)));
}
function parseGlobalpingLocations(value) {
const raw = typeof value === "string" ? value : "";
const parts = raw.split(",").map((part) => part.trim()).filter(Boolean);
return parts.length > 0 ? parts : ["World"];
}
// src/schemas.ts
var certMonitoringSchema = z.enum(["auto", "required", "skipped"]);
var lbModeSchema = z.enum(["round_robin", "failover", "weighted"]);
var healthCheckTypeSchema = z.enum(["tcp", "http", "ping", "dns"]);
@@ -179,7 +273,19 @@ var nodeHealthStateSchema = z.enum([
"unhealthy",
"disabled"
]);
var healthCheckProviderSchema = z.enum(["local", "cloudflare"]);
var healthCheckProviderSchema = z.enum(HEALTH_CHECK_PROVIDERS);
var healthStatusProviderSchema = z.enum(HEALTH_STATUS_PROVIDERS);
var healthCheckAggregateSchema = z.enum(HEALTH_CHECK_AGGREGATES);
var healthCheckProvidersSchema = z.preprocess(
(value) => {
if (value === void 0) return void 0;
return Array.isArray(value) ? value : parseHealthProviders(value);
},
z.array(healthCheckProviderSchema).min(1).transform((arr) => {
const unique = uniqueHealthProviders(arr);
return unique.length > 0 ? unique : ["local"];
})
);
var healthCheckScopeSchema = z.enum(["binding", "group"]);
var ipHealthStatusSchema = z.object({
scope: healthCheckScopeSchema,
@@ -190,12 +296,31 @@ var ipHealthStatusSchema = z.object({
consecutive_failures: z.number(),
consecutive_successes: z.number().optional().default(0),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable()
last_error: z.string().nullable(),
colo: z.string().nullable().optional(),
provider: healthStatusProviderSchema.optional()
});
var serviceIpHealthSchema = z.object({
ip: z.string(),
status: ipHealthStateSchema,
latency_ms: z.number().nullable()
latency_ms: z.number().nullable(),
last_checked_at: z.string().nullable().optional(),
last_error: z.string().nullable().optional(),
provider: healthStatusProviderSchema.optional(),
colo: z.string().nullable().optional()
});
var healthProbeLogSchema = z.object({
id: z.number(),
scope: z.string(),
ref_id: z.number(),
ip: z.string(),
provider: healthCheckProviderSchema,
status: ipHealthStateSchema,
ok: z.coerce.boolean(),
latency_ms: z.number().nullable(),
colo: z.string().nullable(),
error: z.string().nullable(),
checked_at: z.string()
});
var groupSchema = z.object({
id: z.number(),
@@ -230,6 +355,9 @@ var serviceGroupSchema = z.object({
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3e3),
health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: healthCheckProviderSchema.catch("local"),
health_check_providers: healthCheckProvidersSchema.catch(["local"]),
health_check_aggregate: healthCheckAggregateSchema.catch("majority"),
created_at: z.string(),
updated_at: z.string()
});
@@ -267,6 +395,10 @@ var serviceDomainBindingSchema = z.object({
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3e3),
health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: healthCheckProviderSchema.catch("local"),
health_check_providers: healthCheckProvidersSchema.catch(["local"]),
health_check_aggregate: healthCheckAggregateSchema.catch("majority"),
cert_monitoring: certMonitoringSchema.default("auto"),
sync_status: z.string().nullable().default(null)
}).transform((binding) => ({
...binding,
@@ -284,7 +416,9 @@ var serviceViewSchema = serviceSchema.extend({
health_status: ipHealthStateSchema.default("unknown"),
health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]),
ip_enabled: z.record(z.string(), z.boolean()).default({})
ip_enabled: z.record(z.string(), z.boolean()).default({}),
lb_mode: lbModeSchema.catch("round_robin"),
active_ips: z.array(z.string()).default([])
});
var serviceGroupViewSchema = serviceGroupSchema.extend({
services: z.array(serviceViewSchema).default([]),
@@ -338,6 +472,7 @@ var serviceBindingSchema = z.object({
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3e3),
health_check_verify_tls: z.coerce.boolean().default(false),
cert_monitoring: certMonitoringSchema.default("auto"),
sync_status: z.string().nullable().default(null),
created_at: z.string(),
updated_at: z.string()
@@ -367,6 +502,8 @@ var certificateSchema = z.object({
id: z.number(),
domain_id: z.number(),
subdomain_id: z.number().nullable(),
service_id: z.number().nullable().optional().default(null),
service_name: z.string().nullable().optional().default(null),
hostname: z.string(),
expires_at: z.string().nullable(),
last_checked_at: z.string().nullable(),
@@ -375,6 +512,18 @@ var certificateSchema = z.object({
created_at: z.string(),
updated_at: z.string()
});
var serviceCertificateRowSchema = z.object({
binding_id: z.number(),
domain_id: z.number(),
service_id: z.number(),
hostname: z.string(),
cert_monitoring: certMonitoringSchema,
id: z.number().nullable(),
status: z.string(),
expires_at: z.string().nullable(),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable()
});
var createGroupSchema = z.object({
name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435"),
slug: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 slug")
@@ -385,14 +534,17 @@ var ipv4Schema = z.string().regex(
);
var nodeAddressSchema = z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 hostname").max(255);
var healthCheckConfigFields = {
health_check_enabled: z.boolean().optional(),
health_check_enabled: z.coerce.boolean().optional(),
health_check_type: healthCheckTypeSchema.optional(),
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
health_check_path: z.string().nullable().optional(),
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
health_check_timeout_ms: z.number().int().min(100).max(3e4).optional(),
health_check_verify_tls: z.boolean().optional()
health_check_verify_tls: z.coerce.boolean().optional(),
health_check_provider: healthCheckProviderSchema.optional(),
health_check_providers: healthCheckProvidersSchema.optional(),
health_check_aggregate: healthCheckAggregateSchema.optional()
};
var healthCheckConfigSchema = z.object(healthCheckConfigFields);
var serviceDomainInputSchema = z.object({
@@ -703,7 +855,12 @@ var appSettingsPatchSchema = z3.object({
healthDegradedFailures: z3.number().int().min(1).max(20).optional(),
healthDownFailures: z3.number().int().min(1).max(50).optional(),
healthLatencyWarnMs: z3.number().int().min(50).max(6e4).optional(),
healthSuccessRecoveries: z3.number().int().min(1).max(20).optional()
healthSuccessRecoveries: z3.number().int().min(1).max(20).optional(),
healthWorkerUrl: z3.string().url().or(z3.literal("")).optional(),
healthWorkerToken: z3.string().optional(),
globalpingToken: z3.string().optional(),
globalpingLocations: z3.string().trim().max(200).optional(),
globalpingLimit: z3.number().int().min(1).max(10).optional()
}).superRefine((data, ctx) => {
if (data.healthDegradedFailures != null && data.healthDownFailures != null && data.healthDownFailures < data.healthDegradedFailures) {
ctx.addIssue({
@@ -725,6 +882,15 @@ var vpsTrackerEventSchema = z3.object({
timestamp: z3.string().datetime().optional()
});
// src/health-probe-mailbox.ts
var HEALTH_PROBE_SCRIPT_NAME = "cfdm-health-probe";
var HEALTH_PROBE_KV_TITLE = "cfdm-health-probe";
var HEALTH_KV_TARGETS_KEY = "targets";
var HEALTH_KV_RESULTS_KEY = "results";
var HEALTH_KV_CURSOR_KEY = "cursor";
var HEALTH_PROBE_BATCH = 48;
var HEALTH_PROBE_CONCURRENCY = 5;
// src/audit.ts
import { z as z4 } from "zod";
var AUDIT_SEVERITIES = ["info", "warning", "critical"];
@@ -796,12 +962,23 @@ export {
CERT_OK,
CERT_UNKNOWN,
CERT_WARNING,
HEALTH_CHECK_AGGREGATES,
HEALTH_CHECK_PROVIDERS,
HEALTH_KV_CURSOR_KEY,
HEALTH_KV_RESULTS_KEY,
HEALTH_KV_TARGETS_KEY,
HEALTH_PROBE_BATCH,
HEALTH_PROBE_CONCURRENCY,
HEALTH_PROBE_KV_TITLE,
HEALTH_PROBE_SCRIPT_NAME,
HEALTH_STATUS_PROVIDERS,
SYNC_CONFLICT,
SYNC_ERROR,
SYNC_PENDING_DELETE,
SYNC_PENDING_PUSH,
SYNC_SYNCED,
ValidationError,
aggregateHealthOk,
appSettingsPatchSchema,
appSwitcherConfigSchema,
appSwitcherEntrySchema,
@@ -820,6 +997,7 @@ export {
cfdmSyncBindingsBodySchema,
changeDomainSchema,
changeIpSchema,
clampGlobalpingLimit,
createDnsRecordSchema,
createDomainMonitorSchema,
createDomainSchema,
@@ -831,6 +1009,7 @@ export {
createServiceSchema,
createServiceWithConfigSchema,
createSubdomainSchema,
derivePrimaryProvider,
dnsNameToSubdomainLabel,
dnsRecordNamesMatch,
dnsRecordSchema,
@@ -843,10 +1022,14 @@ export {
fqdnToDisplay,
groupSchema,
groupWithStatsSchema,
healthCheckAggregateSchema,
healthCheckConfigSchema,
healthCheckProviderSchema,
healthCheckProvidersSchema,
healthCheckScopeSchema,
healthCheckTypeSchema,
healthProbeLogSchema,
healthStatusProviderSchema,
healthStatusQuerySchema,
ingestAuditEventSchema,
ipHealthStateSchema,
@@ -857,11 +1040,18 @@ export {
loginSchema,
nodeHealthStateSchema,
normalizeDnsRecordName,
normalizeProbeProvider,
normalizeStatusProvider,
notificationLogSchema,
originHealthCheckSchema,
parseFqdn,
parseGlobalpingLocations,
parseHealthAggregate,
parseHealthProviders,
reorderServicesSchema,
serializeHealthProviders,
serviceBindingSchema,
serviceCertificateRowSchema,
serviceDomainBindingSchema,
serviceGroupSchema,
serviceGroupTypeSchema,
@@ -874,8 +1064,11 @@ export {
shouldMonitorService,
subdomainLabelToFqdn,
subdomainSchema,
targetHasProvider,
targetProviders,
toggleEnabledSchema,
toggleServiceIpSchema,
uniqueHealthProviders,
updateDomainGroupSchema,
updateDomainSchema,
updateServiceConfigSchema,
@@ -0,0 +1,41 @@
export const HEALTH_PROBE_SCRIPT_NAME = "cfdm-health-probe";
export const HEALTH_PROBE_KV_TITLE = "cfdm-health-probe";
export const HEALTH_KV_TARGETS_KEY = "targets";
export const HEALTH_KV_RESULTS_KEY = "results";
export const HEALTH_KV_CURSOR_KEY = "cursor";
export const HEALTH_PROBE_BATCH = 48;
export const HEALTH_PROBE_CONCURRENCY = 5;
export type HealthWorkerStatus = "missing" | "ready" | "error";
export interface HealthProbeTargetItem {
key: string;
ip: string;
hostname: string;
type: "tcp" | "http";
port: number;
path?: string;
expectedStatus?: number | null;
timeoutMs?: number;
verifyTls?: boolean;
}
export interface HealthProbeTargetsDoc {
fingerprint: string;
updatedAt: string;
items: HealthProbeTargetItem[];
}
export interface HealthProbeResultItem {
key: string;
ok: boolean;
latencyMs: number;
error: string | null;
}
export interface HealthProbeResultsDoc {
probedAt: string;
colo: string | null;
fingerprint?: string;
items: HealthProbeResultItem[];
}
+143
View File
@@ -0,0 +1,143 @@
export const HEALTH_CHECK_PROVIDERS = [
"local",
"cloudflare",
"globalping",
] as const;
export type HealthCheckProvider = (typeof HEALTH_CHECK_PROVIDERS)[number];
export const HEALTH_STATUS_PROVIDERS = [
...HEALTH_CHECK_PROVIDERS,
"aggregate",
] as const;
export type HealthStatusProvider = (typeof HEALTH_STATUS_PROVIDERS)[number];
export const HEALTH_CHECK_AGGREGATES = ["any", "all", "majority"] as const;
export type HealthCheckAggregate = (typeof HEALTH_CHECK_AGGREGATES)[number];
const PROVIDER_SET = new Set<string>(HEALTH_CHECK_PROVIDERS);
const STATUS_SET = new Set<string>(HEALTH_STATUS_PROVIDERS);
const AGGREGATE_SET = new Set<string>(HEALTH_CHECK_AGGREGATES);
export function normalizeProbeProvider(value: unknown): HealthCheckProvider {
return value === "cloudflare" || value === "globalping" || value === "local"
? value
: "local";
}
export function normalizeStatusProvider(value: unknown): HealthStatusProvider {
if (typeof value === "string" && STATUS_SET.has(value)) {
return value as HealthStatusProvider;
}
return "local";
}
export function uniqueHealthProviders(
values: readonly unknown[],
): HealthCheckProvider[] {
const out: HealthCheckProvider[] = [];
for (const value of values) {
if (!PROVIDER_SET.has(String(value))) continue;
const next = value as HealthCheckProvider;
if (!out.includes(next)) out.push(next);
}
return out;
}
export function parseHealthProviders(
json: unknown,
fallback?: unknown,
): HealthCheckProvider[] {
if (Array.isArray(json)) {
const parsed = uniqueHealthProviders(json);
if (parsed.length > 0) return parsed;
}
if (typeof json === "string" && json.trim()) {
const trimmed = json.trim();
if (trimmed.startsWith("[")) {
try {
const parsed = uniqueHealthProviders(JSON.parse(trimmed) as unknown[]);
if (parsed.length > 0) return parsed;
} catch {
// fall through to single-provider
}
}
const one = uniqueHealthProviders(trimmed.split(","));
if (one.length > 0) return one;
}
return [normalizeProbeProvider(fallback)];
}
export function serializeHealthProviders(
providers: readonly HealthCheckProvider[],
): string {
const unique = uniqueHealthProviders(providers);
return JSON.stringify(unique.length > 0 ? unique : ["local"]);
}
export function parseHealthAggregate(value: unknown): HealthCheckAggregate {
if (typeof value === "string" && AGGREGATE_SET.has(value)) {
return value as HealthCheckAggregate;
}
return "majority";
}
export function derivePrimaryProvider(
providers: readonly HealthCheckProvider[],
): HealthCheckProvider {
return uniqueHealthProviders(providers)[0] ?? "local";
}
export function targetProviders(target: {
providers?: readonly HealthCheckProvider[] | null;
provider?: HealthCheckProvider | null;
}): HealthCheckProvider[] {
if (target.providers && target.providers.length > 0) {
return uniqueHealthProviders(target.providers);
}
return [normalizeProbeProvider(target.provider)];
}
export function targetHasProvider(
target: {
providers?: readonly HealthCheckProvider[] | null;
provider?: HealthCheckProvider | null;
},
provider: HealthCheckProvider,
): boolean {
return targetProviders(target).includes(provider);
}
/**
* any Down if at least one source is Down (ok only if all ok).
* all Down only if every source is Down (ok if any ok).
* majority Down if a strict majority of sources are Down (2 both, 3 2).
*/
export function aggregateHealthOk(
oks: readonly boolean[],
policy: HealthCheckAggregate,
): boolean {
const n = oks.length;
if (n === 0) return false;
const down = oks.filter((ok) => !ok).length;
if (policy === "any") return down === 0;
if (policy === "all") return down < n;
return down < Math.floor(n / 2) + 1;
}
export function clampGlobalpingLimit(value: unknown, fallback = 3): number {
const n = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(10, Math.max(1, Math.trunc(n)));
}
export function parseGlobalpingLocations(value: unknown): string[] {
const raw = typeof value === "string" ? value : "";
const parts = raw
.split(",")
.map((part) => part.trim())
.filter(Boolean);
return parts.length > 0 ? parts : ["World"];
}
+2 -1
View File
@@ -5,6 +5,8 @@ export * from "./parse-fqdn.js";
export * from "./schemas.js";
export * from "./app-switcher.js";
export * from "./integration-vps-tracker.js";
export * from "./health-probe-mailbox.js";
export * from "./health-providers.js";
export * from "./audit.js";
export type {
CfZone,
@@ -22,7 +24,6 @@ export type {
HealthCheckType,
IpHealthState,
NodeHealthState,
HealthCheckProvider,
HealthCheckScope,
IpHealthStatus,
HealthCheckTarget,
@@ -35,6 +35,11 @@ export const appSettingsPatchSchema = z.object({
healthDownFailures: z.number().int().min(1).max(50).optional(),
healthLatencyWarnMs: z.number().int().min(50).max(60_000).optional(),
healthSuccessRecoveries: z.number().int().min(1).max(20).optional(),
healthWorkerUrl: z.string().url().or(z.literal("")).optional(),
healthWorkerToken: z.string().optional(),
globalpingToken: z.string().optional(),
globalpingLocations: z.string().trim().max(200).optional(),
globalpingLimit: z.number().int().min(1).max(10).optional(),
}).superRefine((data, ctx) => {
if (
data.healthDegradedFailures != null &&
+76 -4
View File
@@ -1,4 +1,11 @@
import { z } from 'zod'
import {
HEALTH_CHECK_AGGREGATES,
HEALTH_CHECK_PROVIDERS,
HEALTH_STATUS_PROVIDERS,
parseHealthProviders,
uniqueHealthProviders,
} from './health-providers.js'
export const certMonitoringSchema = z.enum(['auto', 'required', 'skipped'])
@@ -29,8 +36,22 @@ export const nodeHealthStateSchema = z.enum([
])
export type NodeHealthState = z.infer<typeof nodeHealthStateSchema>
export const healthCheckProviderSchema = z.enum(['local', 'cloudflare'])
export type HealthCheckProvider = z.infer<typeof healthCheckProviderSchema>
export const healthCheckProviderSchema = z.enum(HEALTH_CHECK_PROVIDERS)
export const healthStatusProviderSchema = z.enum(HEALTH_STATUS_PROVIDERS)
export const healthCheckAggregateSchema = z.enum(HEALTH_CHECK_AGGREGATES)
export const healthCheckProvidersSchema = z.preprocess(
(value) => {
if (value === undefined) return undefined
return Array.isArray(value) ? value : parseHealthProviders(value)
},
z.array(healthCheckProviderSchema).min(1).transform((arr) => {
const unique = uniqueHealthProviders(arr)
return unique.length > 0 ? unique : (['local'] as const)
}),
)
export const healthCheckScopeSchema = z.enum(['binding', 'group'])
export type HealthCheckScope = z.infer<typeof healthCheckScopeSchema>
@@ -45,6 +66,8 @@ export const ipHealthStatusSchema = z.object({
consecutive_successes: z.number().optional().default(0),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable(),
colo: z.string().nullable().optional(),
provider: healthStatusProviderSchema.optional(),
})
export type IpHealthStatus = z.infer<typeof ipHealthStatusSchema>
@@ -53,10 +76,30 @@ export const serviceIpHealthSchema = z.object({
ip: z.string(),
status: ipHealthStateSchema,
latency_ms: z.number().nullable(),
last_checked_at: z.string().nullable().optional(),
last_error: z.string().nullable().optional(),
provider: healthStatusProviderSchema.optional(),
colo: z.string().nullable().optional(),
})
export type ServiceIpHealth = z.infer<typeof serviceIpHealthSchema>
export const healthProbeLogSchema = z.object({
id: z.number(),
scope: z.string(),
ref_id: z.number(),
ip: z.string(),
provider: healthCheckProviderSchema,
status: ipHealthStateSchema,
ok: z.coerce.boolean(),
latency_ms: z.number().nullable(),
colo: z.string().nullable(),
error: z.string().nullable(),
checked_at: z.string(),
})
export type HealthProbeLog = z.infer<typeof healthProbeLogSchema>
export const groupSchema = z.object({
id: z.number(),
name: z.string(),
@@ -93,6 +136,9 @@ export const serviceGroupSchema = z.object({
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: healthCheckProviderSchema.catch('local'),
health_check_providers: healthCheckProvidersSchema.catch(['local']),
health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
created_at: z.string(),
updated_at: z.string(),
})
@@ -133,6 +179,10 @@ export const serviceDomainBindingSchema = z
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false),
health_check_provider: healthCheckProviderSchema.catch('local'),
health_check_providers: healthCheckProvidersSchema.catch(['local']),
health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
cert_monitoring: certMonitoringSchema.default('auto'),
sync_status: z.string().nullable().default(null),
})
.transform((binding) => ({
@@ -160,6 +210,8 @@ export const serviceViewSchema = serviceSchema.extend({
health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]),
ip_enabled: z.record(z.string(), z.boolean()).default({}),
lb_mode: lbModeSchema.catch('round_robin'),
active_ips: z.array(z.string()).default([]),
})
export const serviceGroupViewSchema = serviceGroupSchema.extend({
@@ -219,6 +271,7 @@ export const serviceBindingSchema = z
health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false),
cert_monitoring: certMonitoringSchema.default('auto'),
sync_status: z.string().nullable().default(null),
created_at: z.string(),
updated_at: z.string(),
@@ -256,6 +309,8 @@ export const certificateSchema = z.object({
id: z.number(),
domain_id: z.number(),
subdomain_id: z.number().nullable(),
service_id: z.number().nullable().optional().default(null),
service_name: z.string().nullable().optional().default(null),
hostname: z.string(),
expires_at: z.string().nullable(),
last_checked_at: z.string().nullable(),
@@ -265,6 +320,19 @@ export const certificateSchema = z.object({
updated_at: z.string(),
})
export const serviceCertificateRowSchema = z.object({
binding_id: z.number(),
domain_id: z.number(),
service_id: z.number(),
hostname: z.string(),
cert_monitoring: certMonitoringSchema,
id: z.number().nullable(),
status: z.string(),
expires_at: z.string().nullable(),
last_checked_at: z.string().nullable(),
last_error: z.string().nullable(),
})
export type Group = z.infer<typeof groupSchema>
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
export type Service = z.infer<typeof serviceSchema>
@@ -277,6 +345,7 @@ export type Domain = z.infer<typeof domainSchema>
export type DomainListItem = z.infer<typeof domainListItemSchema>
export type DnsRecord = z.infer<typeof dnsRecordSchema>
export type Certificate = z.infer<typeof certificateSchema>
export type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>
export const createGroupSchema = z.object({
name: z.string().min(1, 'Укажите название'),
@@ -296,14 +365,17 @@ const nodeAddressSchema = z
.max(255)
const healthCheckConfigFields = {
health_check_enabled: z.boolean().optional(),
health_check_enabled: z.coerce.boolean().optional(),
health_check_type: healthCheckTypeSchema.optional(),
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
health_check_path: z.string().nullable().optional(),
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
health_check_verify_tls: z.boolean().optional(),
health_check_verify_tls: z.coerce.boolean().optional(),
health_check_provider: healthCheckProviderSchema.optional(),
health_check_providers: healthCheckProvidersSchema.optional(),
health_check_aggregate: healthCheckAggregateSchema.optional(),
}
export const healthCheckConfigSchema = z.object(healthCheckConfigFields)
+41 -2
View File
@@ -1,3 +1,15 @@
import type {
HealthCheckProvider,
HealthCheckAggregate,
HealthStatusProvider,
} from "./health-providers.js";
export type {
HealthCheckProvider,
HealthCheckAggregate,
HealthStatusProvider,
} from "./health-providers.js";
export interface Group {
id: number;
name: string;
@@ -22,6 +34,9 @@ export interface ServiceGroup {
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
created_at: string;
updated_at: string;
}
@@ -96,6 +111,8 @@ export interface Certificate {
id: number;
domain_id: number;
subdomain_id: number | null;
service_id: number | null;
service_name: string | null;
hostname: string;
expires_at: string | null;
last_checked_at: string | null;
@@ -121,6 +138,10 @@ export interface ServiceBinding {
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
cert_monitoring: string;
routing_strategy: LbMode;
operation_version: number;
created_at: string;
@@ -152,6 +173,10 @@ export interface ServiceBindingView {
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
cert_monitoring: string;
sync_status: string | null;
created_at: string;
updated_at: string;
@@ -177,6 +202,10 @@ export interface ServiceDomainBindingView {
health_check_interval_sec: number;
health_check_timeout_ms: number;
health_check_verify_tls: boolean;
health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate;
cert_monitoring: string;
sync_status: string | null;
}
@@ -198,6 +227,8 @@ export interface ServiceView {
health_latency_ms: number | null;
ip_health: ServiceIpHealth[];
ip_enabled: Record<string, boolean>;
lb_mode: LbMode;
active_ips: string[];
}
export interface GroupWithStats extends Group {
@@ -225,6 +256,7 @@ export interface CfZone {
id: string;
name: string;
status: string;
account?: { id: string; name?: string };
}
export interface CfDnsRecord {
@@ -279,8 +311,6 @@ export type NodeHealthState =
| "unhealthy"
| "disabled";
export type HealthCheckProvider = "local" | "cloudflare";
export type HealthCheckScope = "binding" | "group";
export interface IpHealthStatus {
@@ -293,12 +323,18 @@ export interface IpHealthStatus {
consecutive_successes?: number;
last_checked_at: string | null;
last_error: string | null;
colo?: string | null;
provider?: HealthStatusProvider;
}
export interface ServiceIpHealth {
ip: string;
status: IpHealthState;
latency_ms: number | null;
last_checked_at?: string | null;
last_error?: string | null;
provider?: HealthStatusProvider;
colo?: string | null;
}
export interface ServiceNode {
@@ -382,4 +418,7 @@ export interface HealthCheckTarget {
expected_status: number | null;
timeout_ms: number;
verify_tls: boolean;
provider: HealthCheckProvider;
providers?: HealthCheckProvider[];
aggregate?: HealthCheckAggregate;
}

Some files were not shown because too many files have changed in this diff Show More