Compare commits

...
11 Commits
Author SHA1 Message Date
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 <[email protected]>
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 <[email protected]>
2026-08-19 20:04:19 +07:00
DenozordecandCursor 9b9dcc3b12 fix(ui): выровнять health-check плитки и тип TCP/HTTP под ReUI
Co-authored-by: Cursor <[email protected]>
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
51 changed files with 3172 additions and 1119 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 *) 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 # ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components # 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. **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 ## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange` **Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:** **Shape:**
```tsx ```tsx
const [filters, setFilters] = useState<Filter[]>([ const fields: FilterField[] = [
createFilter("priority", "is_any_of", ["low"]), { id: "title", label: "Title", type: "text" },
]) {
const fields: FilterFieldConfig[] = [ id: "status",
{ key: "priority", label: "Priority", type: "multiselect", label: "Status",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] }, 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 ## 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 *) 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 # ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components # 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. **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 ## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange` **Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:** **Shape:**
```tsx ```tsx
const [filters, setFilters] = useState<Filter[]>([ const fields: FilterField[] = [
createFilter("priority", "is_any_of", ["low"]), { id: "title", label: "Title", type: "text" },
]) {
const fields: FilterFieldConfig[] = [ id: "status",
{ key: "priority", label: "Priority", type: "multiselect", label: "Status",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] }, 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 ## date-selector
+4 -4
View File
@@ -6,18 +6,18 @@ alwaysApply: false
--- ---
name: reui 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 user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) 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 for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: 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 - **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 - **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 - **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/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/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/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/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) - [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 *) 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 # ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components # 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. **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 ## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange` **Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:** **Shape:**
```tsx ```tsx
const [filters, setFilters] = useState<Filter[]>([ const fields: FilterField[] = [
createFilter("priority", "is_any_of", ["low"]), { id: "title", label: "Title", type: "text" },
]) {
const fields: FilterFieldConfig[] = [ id: "status",
{ key: "priority", label: "Priority", type: "multiselect", label: "Status",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] }, 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 ## date-selector
+8 -7
View File
@@ -38,14 +38,11 @@ jobs:
api: ${{ steps.detect.outputs.api }} api: ${{ steps.detect.outputs.api }}
docker: ${{ steps.detect.outputs.docker }} docker: ${{ steps.detect.outputs.docker }}
steps: steps:
- if: ${{ inputs.is_pull_request }} - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: 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 fetch-depth: 0
- if: ${{ inputs.is_pull_request == false }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 2
- id: detect - id: detect
name: Detect changed paths per module name: Detect changed paths per module
env: env:
@@ -72,12 +69,16 @@ jobs:
done done
} }
has_commit() {
git cat-file -e "${1}^{commit}" 2>/dev/null
}
if [ "$IS_PR" = "true" ]; then if [ "$IS_PR" = "true" ]; then
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")" FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
else else
after="${HEAD_SHA:-$(git rev-parse HEAD)}" after="${HEAD_SHA:-$(git rev-parse HEAD)}"
before="$BEFORE_SHA" 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")" FILES="$(git diff --name-only "$before" "$after")"
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)" 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 *) 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 # ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components # 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. **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 ## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange` **Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:** **Shape:**
```tsx ```tsx
const [filters, setFilters] = useState<Filter[]>([ const fields: FilterField[] = [
createFilter("priority", "is_any_of", ["low"]), { id: "title", label: "Title", type: "text" },
]) {
const fields: FilterFieldConfig[] = [ id: "status",
{ key: "priority", label: "Priority", type: "multiselect", label: "Status",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] }, 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 ## date-selector
+2 -1
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { z } from "zod"; 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 bindingService from "../services/binding-service.js";
import * as changeIp from "../services/change-ip-service.js"; import * as changeIp from "../services/change-ip-service.js";
import { recordAudit } from "../lib/audit.js"; import { recordAudit } from "../lib/audit.js";
@@ -17,6 +17,7 @@ export async function serviceBindingRoutes(app: FastifyInstance) {
service_id: z.number().optional(), service_id: z.number().optional(),
hostname: z.string().optional(), hostname: z.string().optional(),
target_ip: z.string().optional(), target_ip: z.string().optional(),
cert_monitoring: certMonitoringSchema.optional(),
}); });
app.get("/service-bindings", async (request) => { app.get("/service-bindings", async (request) => {
+23 -1
View File
@@ -12,6 +12,7 @@ import { repos } from "@cfdm/db";
import * as serviceConfig from "../services/service-config-service.js"; import * as serviceConfig from "../services/service-config-service.js";
import * as nodeService from "../services/node-service.js"; import * as nodeService from "../services/node-service.js";
import * as changeDomain from "../services/change-domain-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"; import { recordAudit } from "../lib/audit.js";
export async function serviceRoutes(app: FastifyInstance) { export async function serviceRoutes(app: FastifyInstance) {
@@ -69,10 +70,31 @@ export async function serviceRoutes(app: FastifyInstance) {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
repos.getService(request.server.db, Number(id)); repos.getService(request.server.db, Number(id));
return { return {
items: repos.listHealthProbeLogForService(request.server.db, Number(id)), 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) => { app.get("/services/:id/overview", async (request) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
return nodeService.getOverview(request.server.db, Number(id)); return nodeService.getOverview(request.server.db, Number(id));
+15
View File
@@ -15,6 +15,7 @@ export interface UpdateBindingRequest {
service_id?: number; service_id?: number;
hostname?: string; hostname?: string;
target_ip?: string; target_ip?: string;
cert_monitoring?: string;
} }
function normalizeHostname(hostname?: string): string { function normalizeHostname(hostname?: string): string {
@@ -92,6 +93,20 @@ export async function update(
req: UpdateBindingRequest, req: UpdateBindingRequest,
): Promise<ServiceBindingView> { ): Promise<ServiceBindingView> {
const existing = repos.getBinding(db, id); 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; const serviceId = req.service_id ?? existing.service_id;
if (req.service_id) repos.getService(db, req.service_id); if (req.service_id) repos.getService(db, req.service_id);
const hostname = req.hostname 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 { connect as tlsConnect } from "node:tls";
import type { Db } from "@cfdm/db"; import type { Db } from "@cfdm/db";
import { repos } 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 { import {
CERT_ERROR, CERT_ERROR,
CERT_MONITOR_AUTO, CERT_MONITOR_AUTO,
@@ -11,13 +11,13 @@ import {
CERT_UNKNOWN, CERT_UNKNOWN,
certStatusFromExpiry, certStatusFromExpiry,
fqdnToDisplay, fqdnToDisplay,
parseFqdn,
shouldMonitorService, shouldMonitorService,
} from "@cfdm/shared"; } from "@cfdm/shared";
export interface CertificateTarget { export interface CertificateTarget {
domainId: number; domainId: number;
subdomainId: number | null; subdomainId: number | null;
serviceId: number;
hostname: string; hostname: string;
} }
@@ -41,6 +41,34 @@ export function getCertificate(db: Db, id: number): Certificate {
return repos.getCertificate(db, id); 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( export async function checkHostname(
hostname: string, hostname: string,
): Promise<{ expiresAt: Date | null; error: string | null }> { ): Promise<{ expiresAt: Date | null; error: string | null }> {
@@ -78,6 +106,7 @@ export async function checkAndStore(
domainId: number, domainId: number,
subdomainId: number | null, subdomainId: number | null,
hostname: string, hostname: string,
serviceId: number | null = null,
): Promise<Certificate> { ): Promise<Certificate> {
const { expiresAt, error } = await checkHostname(hostname); const { expiresAt, error } = await checkHostname(hostname);
@@ -90,6 +119,7 @@ export async function checkAndStore(
null, null,
CERT_ERROR, CERT_ERROR,
error, error,
serviceId,
); );
} }
@@ -105,6 +135,7 @@ export async function checkAndStore(
expiresAt.toISOString(), expiresAt.toISOString(),
certStatusFromExpiry(days), certStatusFromExpiry(days),
null, null,
serviceId,
); );
} }
@@ -116,23 +147,10 @@ export async function checkAndStore(
null, null,
CERT_UNKNOWN, CERT_UNKNOWN,
"unknown expiry", "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( function bindingSubdomain(
db: Db, db: Db,
domainId: number, domainId: number,
@@ -165,10 +183,9 @@ function hasSslHealthGate(
return false; return false;
} }
export function buildServiceCertificateFqdns( export function resolveCertificateTargets(db: Db): CertificateTarget[] {
db: Db, const targets: CertificateTarget[] = [];
): Map<string, CertificateTarget> { const seen = new Set<string>();
const result = new Map<string, CertificateTarget>();
for (const binding of repos.listAllBindings(db)) { for (const binding of repos.listAllBindings(db)) {
const service = repos.getService(db, binding.service_id); const service = repos.getService(db, binding.service_id);
@@ -176,98 +193,40 @@ export function buildServiceCertificateFqdns(
? repos.getServiceGroup(db, service.service_group_id) ? repos.getServiceGroup(db, service.service_group_id)
: null; : null;
if (!shouldMonitorService(service, group)) continue; 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); const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
if (subdomain && !subdomain.enabled) continue; 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); 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, domainId: binding.domain_id,
subdomainId: subdomain?.id ?? null, subdomainId: subdomain?.id ?? null,
serviceId: binding.service_id,
hostname: fqdn, hostname: fqdn,
}); });
} }
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name); return targets;
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()];
} }
export async function runAllChecks(db: Db): Promise<number> { export async function runAllChecks(db: Db): Promise<number> {
@@ -278,6 +237,7 @@ export async function runAllChecks(db: Db): Promise<number> {
target.domainId, target.domainId,
target.subdomainId, target.subdomainId,
target.hostname, target.hostname,
target.serviceId,
); );
} }
repos.deleteCertificatesNotIn( repos.deleteCertificatesNotIn(
@@ -287,6 +247,26 @@ export async function runAllChecks(db: Db): Promise<number> {
return targets.length; 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]> { export function statusSummary(db: Db): Array<[string, number]> {
pruneStaleCertificates(db); pruneStaleCertificates(db);
return repos.countCertificatesByStatus(db); return repos.countCertificatesByStatus(db);
@@ -308,10 +308,19 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
binding.health_check_provider ?? "local", binding.health_check_provider ?? "local",
], ],
health_check_aggregate: binding.health_check_aggregate ?? "majority", health_check_aggregate: binding.health_check_aggregate ?? "majority",
cert_monitoring: binding.cert_monitoring ?? "auto",
sync_status: aggregateSyncStatus(statuses), 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 { return {
id: service.id, id: service.id,
name: service.name, name: service.name,
@@ -330,6 +339,8 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
health_status: "unknown", health_status: "unknown",
health_latency_ms: null, health_latency_ms: null,
ip_health: [], ip_health: [],
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
active_ips: [...activeIps],
}; };
} }
+186 -20
View File
@@ -208,7 +208,7 @@ describe("certificates", () => {
await testApp.close(); 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({ const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null }, config: { ...loadConfig(), staticDir: null },
memory: true, memory: true,
@@ -221,10 +221,18 @@ describe("certificates", () => {
"required.example.com", "required.example.com",
"cf-zone-req", "cf-zone-req",
); );
repos.updateDomain(testApp.db, domain.id, { const service = repos.createService(testApp.db, "Req", "req");
group_id: null, repos.setServiceEnabled(testApp.db, service.id, true);
status: "active", const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"@",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED, cert_monitoring: CERT_MONITOR_REQUIRED,
health_check_enabled: false,
}); });
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
@@ -247,7 +255,7 @@ describe("certificates", () => {
await testApp.close(); 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({ const testApp = await buildApp({
config: { ...loadConfig(), staticDir: null }, config: { ...loadConfig(), staticDir: null },
memory: true, memory: true,
@@ -260,6 +268,20 @@ describe("certificates", () => {
"skipped.example.com", "skipped.example.com",
"cf-zone-skip", "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( repos.upsertCertificateCheck(
testApp.db, testApp.db,
domain.id, domain.id,
@@ -268,12 +290,8 @@ describe("certificates", () => {
null, null,
CERT_ERROR, CERT_ERROR,
"stale", "stale",
service.id,
); );
repos.updateDomain(testApp.db, domain.id, {
group_id: null,
status: "active",
cert_monitoring: CERT_MONITOR_SKIPPED,
});
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({ vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: null, expiresAt: null,
@@ -305,9 +323,16 @@ describe("certificates", () => {
"broken.example.com", "broken.example.com",
"cf-zone-broken", "cf-zone-broken",
); );
repos.updateDomain(testApp.db, domain.id, { const service = repos.createService(testApp.db, "Broken", "broken");
group_id: null, repos.setServiceEnabled(testApp.db, service.id, true);
status: "active", const binding = repos.insertBinding(
testApp.db,
domain.id,
service.id,
"@",
null,
);
repos.updateBindingLbConfig(testApp.db, binding.id, {
cert_monitoring: CERT_MONITOR_REQUIRED, cert_monitoring: CERT_MONITOR_REQUIRED,
}); });
@@ -424,7 +449,7 @@ describe("certificates", () => {
}); });
const certs = repos.listCertificates(testApp.db); 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); expect(certs.some((c) => c.hostname === "edge.ok.example.com")).toBe(true);
await testApp.close(); await testApp.close();
@@ -443,12 +468,7 @@ describe("certificates", () => {
"force.example.com", "force.example.com",
"cf-zone-force", "cf-zone-force",
); );
repos.updateDomain(testApp.db, domain.id, { const group = repos.createServiceGroup(
group_id: null,
status: "active",
cert_monitoring: CERT_MONITOR_REQUIRED,
});
repos.createServiceGroup(
testApp.db, testApp.db,
"Proxy", "Proxy",
"vpn", "vpn",
@@ -459,6 +479,19 @@ describe("certificates", () => {
health_check_verify_tls: false, 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({ vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
@@ -540,4 +573,137 @@ describe("certificates", () => {
await testApp.close(); 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();
});
}); });
+6 -5
View File
@@ -123,11 +123,12 @@ describe("create service then list groups", () => {
expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe( expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe(
true, true,
); );
expect( const listed = httpParsed.data!.groups
httpParsed.data!.groups .find((g) => g.id === group.id)
.find((g) => g.id === group.id) ?.services.find((s) => s.id === created.id);
?.services.some((s) => s.id === created.id), expect(listed).toBeDefined();
).toBe(true); expect(listed?.lb_mode).toBe("round_robin");
expect(listed?.active_ips).toEqual(["1.2.3.4"]);
await app.close(); await app.close();
}); });
@@ -1,6 +1,7 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table' 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 { Badge } from '@/components/reui/badge'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header' 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 { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils'
import type { Certificate } from '@/lib/schemas' import type { Certificate } from '@/lib/schemas'
import { formatDate, formatRelative } from '@/lib/format' import { formatDate, formatRelative } from '@/lib/format'
import { Button } from '@cfdm/ui/components/button'
export const CERT_TABS = [ export const CERT_TABS = [
{ id: 'all', label: 'Все' }, { id: 'all', label: 'Все' },
@@ -41,6 +43,7 @@ export function certTabFilter(item: Certificate, tabId: string) {
export function createDefaultCertFilters() { export function createDefaultCertFilters() {
return [ return [
createFilter('hostname', 'contains', ['']), createFilter('hostname', 'contains', ['']),
createFilter('service', 'contains', ['']),
createFilter('status', 'is', ['']), createFilter('status', 'is', ['']),
] ]
} }
@@ -56,6 +59,14 @@ export function useCertFilterFields() {
className: 'w-52', className: 'w-52',
placeholder: 'Поиск по хосту…', placeholder: 'Поиск по хосту…',
}, },
{
key: 'service',
label: 'Сервис',
icon: <ServerIcon className="size-3.5" aria-hidden />,
type: 'text',
className: 'w-52',
placeholder: 'Поиск по сервису…',
},
{ {
key: 'status', key: 'status',
label: 'Статус', label: 'Статус',
@@ -75,6 +86,8 @@ export function certFilterFieldValue(item: Certificate, field: string) {
switch (field) { switch (field) {
case 'hostname': case 'hostname':
return `${item.hostname} ${item.status}`.toLowerCase() return `${item.hostname} ${item.status}`.toLowerCase()
case 'service':
return (item.service_name ?? '').toLowerCase()
case 'status': case 'status':
return item.status return item.status
default: 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) const relative = formatRelative(expiresAt)
if (!expiresAt) { if (!expiresAt) {
return <span className="text-muted-foreground tabular-nums"></span> 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> <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', id: 'status',
header: 'Статус', header: 'Статус',
@@ -18,9 +18,9 @@ import {
} from '@cfdm/ui/components/select' } from '@cfdm/ui/components/select'
import { Switch } from '@cfdm/ui/components/switch' import { Switch } from '@cfdm/ui/components/switch'
import { FieldGroup } from '@cfdm/ui/components/field' import { FieldGroup } from '@cfdm/ui/components/field'
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group' import { Button } from '@cfdm/ui/components/button'
import { Link } from '@tanstack/react-router' import { ButtonGroup } from '@cfdm/ui/components/button-group'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert' import { CableIcon, GlobeIcon } from 'lucide-react'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
import { import {
HealthAggregateTiles, HealthAggregateTiles,
@@ -123,9 +123,6 @@ export function HealthCheckConfigFields({
const aggregate = value.aggregate ?? 'majority' const aggregate = value.aggregate ?? 'majority'
const isHttp = value.type === 'http' const isHttp = value.type === 'http'
const rowClass = 'gap-3 px-0 py-3' const rowClass = 'gap-3 px-0 py-3'
const hasCloudflare = providers.includes('cloudflare')
const hasGlobalping = providers.includes('globalping')
const hasLocal = providers.includes('local')
return ( return (
<FieldGroup className={cn('gap-0', className)}> <FieldGroup className={cn('gap-0', className)}>
@@ -163,6 +160,7 @@ export function HealthCheckConfigFields({
compact compact
stacked stacked
className={rowClass} className={rowClass}
contentClassName="min-w-0"
> >
<HealthSourceTiles <HealthSourceTiles
value={providers} value={providers}
@@ -176,46 +174,6 @@ export function HealthCheckConfigFields({
/> />
</SettingRow> </SettingRow>
{hasCloudflare ? (
<Alert>
<AlertTitle>Cloudflare Worker</AlertTitle>
<AlertDescription>
Проба с edge Cloudflare, не продукт Health Checks API (на Free его нет).
Worker создаётся автоматически и сам опрашивает IP (KV mailbox).
Статус деплоя в{' '}
<Link to="/settings/health" className="text-foreground underline">
Настройках Health-check
</Link>
. Если Worker не создан, этот источник не пробируется как Local.
</AlertDescription>
</Alert>
) : null}
{hasGlobalping ? (
<Alert>
<AlertTitle>Globalping</AlertTitle>
<AlertDescription>
Пробы из сети globalping.io (TCP ping / HTTP). Токен, локации и лимит
в{' '}
<Link to="/settings/health" className="text-foreground underline">
Настройках Health-check
</Link>
. Без токена или при 429 этот источник = fail, без fallback на Local.
</AlertDescription>
</Alert>
) : null}
{hasLocal ? (
<Alert>
<AlertTitle>Local health-check</AlertTitle>
<AlertDescription>
Проба TCP/HTTP с сервера API. Cron и пороги Slow/Down в{' '}
<Link to="/settings/health" className="text-foreground underline">
Настройках Health-check
</Link>
. Интервал в карточке не используется.
</AlertDescription>
</Alert>
) : null}
{providers.length > 1 ? ( {providers.length > 1 ? (
<SettingRow <SettingRow
title="Агрегация" title="Агрегация"
@@ -223,6 +181,7 @@ export function HealthCheckConfigFields({
compact compact
stacked stacked
className={rowClass} className={rowClass}
contentClassName="min-w-0"
> >
<HealthAggregateTiles <HealthAggregateTiles
value={aggregate} value={aggregate}
@@ -258,25 +217,30 @@ export function HealthCheckConfigFields({
<div className="flex flex-col gap-3 pt-1 pb-1"> <div className="flex flex-col gap-3 pt-1 pb-1">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}> <FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
<ToggleGroup <ButtonGroup id={`${idPrefix}-type`} className="w-full min-w-0">
id={`${idPrefix}-type`} <Button
variant="outline" type="button"
className="w-full" size="sm"
value={[value.type]} variant={value.type === 'tcp' ? 'secondary' : 'outline'}
onValueChange={(next) => { className="flex-1"
const picked = next[0] aria-pressed={value.type === 'tcp'}
if (picked === 'tcp' || picked === 'http') { onClick={() => patch({ type: 'tcp' })}
patch({ type: picked }) >
} <CableIcon data-icon="inline-start" />
}}
>
<ToggleGroupItem value="tcp" className="flex-1">
TCP TCP
</ToggleGroupItem> </Button>
<ToggleGroupItem value="http" className="flex-1"> <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 HTTP
</ToggleGroupItem> </Button>
</ToggleGroup> </ButtonGroup>
</FormFieldSimple> </FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}> <FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
@@ -29,15 +29,21 @@ export interface HealthTimelineEvent {
interface HealthTimelineProps { interface HealthTimelineProps {
events: HealthTimelineEvent[] events: HealthTimelineEvent[]
emptyTitle?: string
emptyDescription?: string
} }
export function HealthTimeline({ events }: HealthTimelineProps) { export function HealthTimeline({
events,
emptyTitle = 'Нет событий',
emptyDescription = 'Результаты проверок появятся после первого прогона',
}: HealthTimelineProps) {
if (events.length === 0) { if (events.length === 0) {
return ( return (
<EmptyState <EmptyState
icon={Link2Icon} icon={Link2Icon}
title="Нет событий" title={emptyTitle}
description="Результаты проверок появятся после первого прогона" description={emptyDescription}
centered={false} centered={false}
/> />
) )
@@ -36,6 +36,13 @@ function getBreadcrumbs(
return [{ label: 'Панель управления', href: '/' }] return [{ label: 'Панель управления', href: '/' }]
} }
if (pathname.match(/^\/services\/\d+$/)) {
return [
{ label: 'Сервисы', href: '/services' },
{ label: dynamicLabels[pathname] ?? 'Сервис', href: pathname },
]
}
if (pathname.match(/^\/groups\/\d+$/)) { if (pathname.match(/^\/groups\/\d+$/)) {
return [ return [
{ label: 'Группы доменов', href: '/groups' }, { label: 'Группы доменов', href: '/groups' },
@@ -1,17 +1,56 @@
import type { KeyboardEvent, ReactNode } from 'react' import type { KeyboardEvent, ReactNode } from 'react'
import { CheckIcon, GlobeIcon, ServerIcon, CloudIcon, LayersIcon, ShieldAlertIcon, ScaleIcon } from 'lucide-react' import { ServerIcon, LayersIcon, ShieldAlertIcon, ScaleIcon } from 'lucide-react'
import { Frame, FramePanel } from '@/components/reui/frame' import { Frame, FramePanel } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile' 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 { cn } from '@cfdm/ui/lib/utils'
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared' import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
import type { HealthLogStatus } from '@/lib/health-log'
export type HealthProvider = HealthCheckProvider export type HealthProvider = HealthCheckProvider
export type HealthAggregate = HealthCheckAggregate export type HealthAggregate = HealthCheckAggregate
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current' const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
const PROVIDER_ITEMS: Array<{ /** 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 id: HealthProvider
title: string title: string
description: string description: string
@@ -29,14 +68,14 @@ const PROVIDER_ITEMS: Array<{
id: 'cloudflare', id: 'cloudflare',
title: 'Cloudflare', title: 'Cloudflare',
description: 'Worker на edge, KV mailbox', description: 'Worker на edge, KV mailbox',
icon: <CloudIcon />, icon: <CloudflareMark />,
iconClassName: 'text-warning [&_svg]:text-current', iconClassName: 'text-warning [&_svg]:text-current',
}, },
{ {
id: 'globalping', id: 'globalping',
title: 'Globalping', title: 'Globalping',
description: 'Пробы из сети globalping.io', description: 'Пробы из сети globalping.io',
icon: <GlobeIcon />, icon: <GlobalpingMark />,
iconClassName: 'text-success [&_svg]:text-current', iconClassName: 'text-success [&_svg]:text-current',
}, },
] ]
@@ -74,13 +113,14 @@ function handleTileKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivE
} }
} }
function TilePanel({ function ChoicePanel({
selected, selected,
title, title,
description, description,
icon, icon,
iconClassName, iconClassName,
role, role,
trailing,
onActivate, onActivate,
}: { }: {
selected: boolean selected: boolean
@@ -89,47 +129,57 @@ function TilePanel({
icon: ReactNode icon: ReactNode
iconClassName?: string iconClassName?: string
role: 'checkbox' | 'radio' role: 'checkbox' | 'radio'
trailing?: ReactNode
onActivate: () => void onActivate: () => void
}) { }) {
return ( return (
<FramePanel <FramePanel
fit
role={role} role={role}
aria-checked={selected} aria-checked={selected}
aria-pressed={selected} aria-pressed={selected}
tabIndex={0} tabIndex={0}
className={cn( className={cn(
'relative isolate flex h-full cursor-pointer flex-col p-3 transition-colors', 'min-w-0 cursor-pointer transition-colors',
'hover:bg-muted/40 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none', 'hover:bg-muted/40 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
selected && 'ring-ring ring-1', selected && 'bg-muted/40',
)} )}
onClick={onActivate} onClick={onActivate}
onKeyDown={(event) => handleTileKeyDown(onActivate, event)} onKeyDown={(event) => handleTileKeyDown(onActivate, event)}
> >
<div className="relative z-10 flex h-full items-start gap-3"> <Item size="sm" className="w-full min-w-0 border-0 p-0">
<IconTile <ItemMedia>
variant="elevated" <IconTile
aria-hidden="true" variant="elevated"
className={cn('size-10.5', iconClassName ?? DEFAULT_ICON_CLASS)} aria-hidden="true"
> className={cn('size-10.5', iconClassName ?? DEFAULT_ICON_CLASS)}
{icon} >
</IconTile> {icon}
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> </IconTile>
<div className="flex items-start justify-between gap-2"> </ItemMedia>
<span className="text-foreground text-sm font-medium">{title}</span> <ItemContent className="min-w-0 gap-0.5">
{selected ? ( <ItemTitle className="w-full min-w-0">{title}</ItemTitle>
<CheckIcon className="text-foreground size-4 shrink-0" aria-hidden /> <ItemDescription>{description}</ItemDescription>
) : null} </ItemContent>
</div> {trailing ? (
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p> <ItemActions className="shrink-0">{trailing}</ItemActions>
</div> ) : null}
</div> </Item>
</FramePanel> </FramePanel>
) )
} }
function ChoiceFrame({ children }: { children: ReactNode }) {
return (
<Frame stacked spacing="sm" className="w-full min-w-0">
{children}
</Frame>
)
}
/** /**
* Мультивыбор источников проб (Local / Cloudflare / Globalping). * Мультивыбор источников проб (Local / Cloudflare / Globalping).
* Preview: https://reui.io/preview/base/card-12 * 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 * Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
*/ */
export function HealthSourceTiles({ export function HealthSourceTiles({
@@ -151,28 +201,33 @@ export function HealthSourceTiles({
} }
return ( return (
<Frame dense spacing="sm" className="@container w-full"> <ChoiceFrame>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3"> {HEALTH_PROVIDER_ITEMS.map((item) => (
{PROVIDER_ITEMS.map((item) => ( <ChoicePanel
<TilePanel key={item.id}
key={item.id} selected={selected.includes(item.id)}
selected={selected.includes(item.id)} title={item.title}
title={item.title} description={item.description}
description={item.description} icon={item.icon}
icon={item.icon} iconClassName={item.iconClassName}
iconClassName={item.iconClassName} role="checkbox"
role="checkbox" trailing={
onActivate={() => toggle(item.id)} selected.includes(item.id) ? (
/> <Badge variant="outline" size="sm">
))} Выбрано
</div> </Badge>
</Frame> ) : null
}
onActivate={() => toggle(item.id)}
/>
))}
</ChoiceFrame>
) )
} }
/** /**
* Правило агрегации (ровно одно): any / all / majority. * Правило агрегации (ровно одно): any / all / majority.
* Preview: https://reui.io/preview/base/card-12 · https://reui.io/preview/base/settings-5 * Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/settings-5
*/ */
export function HealthAggregateTiles({ export function HealthAggregateTiles({
value, value,
@@ -183,20 +238,80 @@ export function HealthAggregateTiles({
}) { }) {
const selected = value || 'majority' const selected = value || 'majority'
return ( return (
<Frame dense spacing="sm" className="@container w-full"> <ChoiceFrame>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3"> {AGGREGATE_ITEMS.map((item) => (
{AGGREGATE_ITEMS.map((item) => ( <ChoicePanel
<TilePanel key={item.id}
key={item.id} selected={selected === item.id}
selected={selected === item.id} title={item.title}
title={item.title} description={item.description}
description={item.description} icon={item.icon}
icon={item.icon} role="radio"
role="radio" trailing={
onActivate={() => onChange(item.id)} selected === item.id ? (
/> <Badge variant="outline" size="sm">
))} Выбрано
</div> </Badge>
</Frame> ) : null
}
onActivate={() => onChange(item.id)}
/>
))}
</ChoiceFrame>
)
}
/**
* 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
function toggle(id: HealthProvider) {
if (active.includes(id)) {
if (active.length === 1) return
onChange(active.filter((item) => item !== id))
return
}
onChange([...active, id])
}
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={() => toggle(item.id)}
/>
))}
</ChoiceFrame>
) )
} }
@@ -1,3 +1,5 @@
export { UptimeChart, type UptimeProbe, type UptimePeriodKey } from './uptime-chart'
export { ServiceHealthMonitor } from './service-health-monitor'
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils' export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics' export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page' export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
@@ -21,6 +23,7 @@ export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export { export {
HealthSourceTiles, HealthSourceTiles,
HealthAggregateTiles, HealthAggregateTiles,
HealthProviderStatusTiles,
type HealthProvider, type HealthProvider,
type HealthAggregate, type HealthAggregate,
} from './health-source-tiles' } from './health-source-tiles'
@@ -0,0 +1,79 @@
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 {
collapseStatusChanges,
filterByPeriod,
filterByProviders,
type HealthLogProbe,
} from '@/lib/health-log'
import type { HealthCheckProvider } from '@cfdm/shared'
/**
* Combined uptime chart + status-change timeline (solution-analytics-8 DNA).
* Preview: https://reui.io/preview/base/solution-analytics-8 · https://reui.io/preview/base/chart-17
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/timeline
*/
export function ServiceHealthMonitor({
items,
selectedProviders,
isLoading = false,
}: {
items: HealthLogProbe[]
selectedProviders: readonly HealthCheckProvider[]
isLoading?: boolean
}) {
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
const filtered = useMemo(
() => filterByProviders(filterByPeriod(items, days), selectedProviders),
[items, days, selectedProviders],
)
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
return (
<Frame stacked spacing="sm" className="min-w-0 w-full">
<UptimeChart
items={filtered}
isLoading={isLoading}
period={period}
onPeriodChange={setPeriod}
skipPeriodFilter
embedded
/>
<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,335 @@
import { useEffect, useId, useMemo, useState } from 'react'
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
import { Area, AreaChart, XAxis } 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'
/**
* 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 AreaChart
*/
export interface UptimeProbe {
id: number
status: 'up' | 'down' | 'degraded' | 'unknown'
ok: boolean
latency_ms: number | null
checked_at: string
}
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 },
]
const chartConfig = {
latency: {
label: 'Задержка',
color: 'var(--chart-1)',
},
} satisfies ChartConfig
interface ChartPoint {
period: string
latency: number
ok: boolean
at: string
status: UptimeProbe['status']
}
function toSeries(items: UptimeProbe[]): ChartPoint[] {
return [...items]
.sort((a, b) => probeTime(a.checked_at) - probeTime(b.checked_at))
.map((item) => ({
period: formatDate(item.checked_at),
latency: item.latency_ms ?? 0,
ok: item.ok && item.status !== 'down',
at: item.checked_at,
status: item.status,
}))
}
function uptimePercent(points: ChartPoint[]): number | null {
if (points.length === 0) return null
const okCount = points.filter((point) => point.ok).length
return (okCount / points.length) * 100
}
function deltaPercent(points: ChartPoint[]): 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>
</>
)
}
interface UptimeChartProps {
items: UptimeProbe[]
isLoading?: boolean
period?: UptimePeriodKey
onPeriodChange?: (period: UptimePeriodKey) => void
skipPeriodFilter?: boolean
embedded?: boolean
}
export function UptimeChart({
items,
isLoading = false,
period: periodProp,
onPeriodChange,
skipPeriodFilter = false,
embedded = false,
}: UptimeChartProps) {
const gradientId = useId().replace(/:/g, '')
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
const [tooltipPortal, setTooltipPortal] = useState<HTMLElement | null>(null)
const period = periodProp ?? internalPeriod
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
useEffect(() => {
setTooltipPortal(document.body)
}, [])
function handlePeriodChange(next: UptimePeriodKey) {
onPeriodChange?.(next)
if (periodProp == null) setInternalPeriod(next)
}
const points = useMemo(
() => toSeries(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 panel = (
<FramePanel className="flex flex-col gap-6">
<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">
{uptime == null ? '—' : `${uptime.toFixed(uptime >= 99.95 ? 2 : 1)}%`}
</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>
<div className="h-40 w-full overflow-visible">
<ChartContainer
config={chartConfig}
className="h-full w-full overflow-visible rounded-b-xl"
initialDimension={{ width: 320, height: 160 }}
>
<AreaChart
data={points}
margin={{ top: 16, left: 8, right: 8, bottom: 4 }}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-latency)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-latency)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<XAxis dataKey="period" hide />
<ChartTooltip
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
allowEscapeViewBox={{ x: true, y: true }}
portal={tooltipPortal ?? undefined}
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
content={
<ChartTooltipContent
formatter={(value, _name, item) => {
const point = item.payload as ChartPoint | undefined
const ping = Number(value)
return (
<div className="flex flex-1 items-center justify-between gap-4">
<span className="text-muted-foreground">
{point?.ok === false ? 'Down' : 'Пинг'}
</span>
<span className="text-foreground font-mono font-medium tabular-nums">
{Number.isFinite(ping) ? `${ping} мс` : '—'}
</span>
</div>
)
}}
/>
}
/>
<Area
dataKey="latency"
name="latency"
type="natural"
fill={`url(#${gradientId})`}
stroke="var(--color-latency)"
strokeWidth={2}
isAnimationActive={false}
dot={(dotProps) => {
const { cx, cy, payload, index } = dotProps
if (cx == null || cy == null) return <g key={index} />
const point = payload as ChartPoint | undefined
return (
<circle
key={index}
cx={cx}
cy={cy}
r={4}
fill={
point?.ok
? 'var(--color-latency)'
: 'var(--destructive)'
}
stroke="var(--background)"
strokeWidth={2}
pointerEvents="none"
/>
)
}}
activeDot={{
r: 6,
stroke: 'var(--background)',
strokeWidth: 2,
}}
/>
</AreaChart>
</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>
)
}
@@ -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 { toast } from 'sonner'
import { HealthCheckBadge } from '@/components/health-check-badge' import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { TruncatedText } from '@/components/truncated-text' import { TruncatedText } from '@/components/truncated-text'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { serviceDisplayFqdns } from '@/lib/service-utils' import { serviceDisplayFqdns } from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas' import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button' 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 { Switch } from '@cfdm/ui/components/switch'
import { import {
Tooltip, Tooltip,
@@ -17,6 +25,9 @@ import {
} from '@cfdm/ui/components/tooltip' } from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils' 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({ export function CopyFqdnButton({
value, value,
className, className,
@@ -126,6 +137,11 @@ interface ServiceIpListProps {
togglingIp?: string | null togglingIp?: string | null
ipToggleDisabled?: boolean ipToggleDisabled?: boolean
onToggleIp?: (ip: string, enabled: boolean) => void 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 className?: string
emptyLabel?: string emptyLabel?: string
copyable?: boolean copyable?: boolean
@@ -139,6 +155,10 @@ export function ServiceIpList({
togglingIp = null, togglingIp = null,
ipToggleDisabled = false, ipToggleDisabled = false,
onToggleIp, onToggleIp,
alignWithMenu = false,
lbMode,
activeIps = [],
ipWeights = {},
className, className,
emptyLabel = 'Нет IP', emptyLabel = 'Нет IP',
copyable = false, copyable = false,
@@ -155,49 +175,83 @@ export function ServiceIpList({
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row])) const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT) const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
const extraCount = ips.length - visible.length const extraCount = ips.length - visible.length
const showMenuSlot = Boolean(onToggleIp && alignWithMenu)
const markActive = lbMode === 'failover' || lbMode === 'weighted'
const activeSet = new Set(activeIps)
return ( return (
<div className={cn('flex min-w-0 flex-col gap-1', className)}> <ItemGroup className={cn('gap-1', className)}>
{visible.map((ip) => { {visible.map((ip) => {
const health = healthByIp.get(ip) const health = healthByIp.get(ip)
const enabled = ipEnabled[ip] !== false const enabled = ipEnabled[ip] !== false
return ( return (
<div key={ip} className="flex min-w-0 items-center gap-1.5"> <Item
<HealthCheckBadge key={ip}
status={health?.status ?? 'unknown'} size="sm"
latencyMs={health?.latency_ms} className="w-full min-w-0 flex-nowrap border-0 p-0"
lastCheckedAt={health?.last_checked_at} >
lastError={health?.last_error} <ItemMedia>
colo={health?.colo} <HealthCheckBadge
provider={health?.provider} status={health?.status ?? 'unknown'}
size="xs" latencyMs={health?.latency_ms}
/> lastCheckedAt={health?.last_checked_at}
<TruncatedText lastError={health?.last_error}
className={cn( colo={health?.colo}
'min-w-0 font-mono text-xs', provider={health?.provider}
enabled ? 'text-muted-foreground' : 'text-muted-foreground/60', size="xs"
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}`
}
/> />
</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} ) : null}
</div> </Item>
) )
})} })}
{extraCount > 0 ? ( {extraCount > 0 ? (
@@ -224,6 +278,6 @@ export function ServiceIpList({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
) : null} ) : null}
</div> </ItemGroup>
) )
} }
@@ -1,11 +1,17 @@
import { Link } from '@tanstack/react-router' 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 { Badge } from '@/components/reui/badge'
import { import {
Frame, Frame,
FrameDescription, FrameDescription,
FrameHeader,
FramePanel, FramePanel,
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
@@ -23,6 +29,12 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu' } from '@cfdm/ui/components/dropdown-menu'
import {
Item,
ItemActions,
ItemContent,
ItemMedia,
} from '@cfdm/ui/components/item'
import { Switch } from '@cfdm/ui/components/switch' import { Switch } from '@cfdm/ui/components/switch'
import { import {
Tooltip, Tooltip,
@@ -30,6 +42,63 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@cfdm/ui/components/tooltip' } 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 { interface ServiceUnitCardProps {
service: ServiceView service: ServiceView
@@ -55,27 +124,32 @@ export function ServiceUnitCard({
const extraCount = Math.max(0, fqdns.length - 1) const extraCount = Math.max(0, fqdns.length - 1)
return ( return (
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden"> <Frame stacked spacing="sm" className="h-full min-w-0">
<FrameHeader className="flex-row items-center justify-between gap-2"> <FramePanel fit className="bg-muted">
<div className="flex min-w-0 items-center gap-2"> <Item size="sm" className="w-full min-w-0 flex-nowrap border-0 p-0">
<IconTile <ItemMedia>
variant="elevated" <IconTile
size="sm" variant="elevated"
className="text-muted-foreground" size="sm"
aria-hidden="true" className="text-foreground"
> aria-hidden="true"
<ServerIcon /> >
</IconTile> <ServerIcon />
<div className="flex min-w-0 flex-col gap-px"> </IconTile>
<FrameTitle className="min-w-0 truncate text-sm"> </ItemMedia>
<Link <ItemContent className="min-w-0 gap-px">
to="/services/$serviceId" <div className="flex min-w-0 items-center gap-1.5">
params={{ serviceId: String(service.id) }} <FrameTitle className="min-w-0 truncate text-base font-semibold">
className="hover:underline" <Link
> to="/services/$serviceId"
{service.name} params={{ serviceId: String(service.id) }}
</Link> className="hover:underline"
</FrameTitle> >
{service.name}
</Link>
</FrameTitle>
<LbModeTile mode={service.lb_mode} />
</div>
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<FrameDescription className="min-w-0 truncate font-mono text-xs"> <FrameDescription className="min-w-0 truncate font-mono text-xs">
{primaryDomain} {primaryDomain}
@@ -108,66 +182,70 @@ export function ServiceUnitCard({
<CopyFqdnButton value={fqdns.join('\n')} /> <CopyFqdnButton value={fqdns.join('\n')} />
) : null} ) : null}
</div> </div>
</div> </ItemContent>
</div> <ItemActions className="ml-auto shrink-0 gap-1">
<div className="flex shrink-0 items-center gap-1"> <Switch
<Switch size="sm"
size="sm" checked={service.enabled}
checked={service.enabled} disabled={togglingId === service.id}
disabled={togglingId === service.id} onCheckedChange={(checked) =>
onCheckedChange={(checked) => onToggleService(service.id, Boolean(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}`}
/>
} }
> aria-label={
<MoreHorizontalIcon aria-hidden /> service.enabled ? 'Выключить сервис' : 'Включить сервис'
</DropdownMenuTrigger> }
<DropdownMenuContent align="end"> />
<DropdownMenuItem <DropdownMenu>
<DropdownMenuTrigger
render={ render={
<Link <Button
to="/services/$serviceId" type="button"
params={{ serviceId: String(service.id) }} variant="ghost"
size="icon-sm"
aria-label={`Действия ${service.name}`}
/> />
} }
> >
Обзор <MoreHorizontalIcon aria-hidden />
</DropdownMenuItem> </DropdownMenuTrigger>
<DropdownMenuItem onClick={() => onEditService(service)}> <DropdownMenuContent align="end">
Изменить <DropdownMenuItem
</DropdownMenuItem> render={
<DropdownMenuItem <Link
variant="destructive" to="/services/$serviceId"
onClick={() => onDeleteService(service)} params={{ serviceId: String(service.id) }}
> />
Удалить }
</DropdownMenuItem> >
</DropdownMenuContent> Обзор
</DropdownMenu> </DropdownMenuItem>
</div> <DropdownMenuItem onClick={() => onEditService(service)}>
</FrameHeader> Изменить
</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 <ServiceIpList
copyable copyable
alignWithMenu
ips={service.ips ?? []} ips={service.ips ?? []}
ipHealth={service.ip_health ?? []} ipHealth={service.ip_health ?? []}
ipEnabled={service.ip_enabled ?? {}} ipEnabled={service.ip_enabled ?? {}}
ipToggleDisabled={togglingId === service.id} ipToggleDisabled={togglingId === service.id}
togglingIp={togglingIp} togglingIp={togglingIp}
lbMode={service.lb_mode}
activeIps={service.active_ips}
ipWeights={service.domains[0]?.target_ip_weights}
onToggleIp={(ip, enabled) => onToggleIp={(ip, enabled) =>
onToggleServiceIp(service.id, ip, enabled) onToggleServiceIp(service.id, ip, enabled)
} }
+1 -1
View File
@@ -73,7 +73,7 @@ export function SettingRow({
> >
<div <div
className={cn( className={cn(
'flex w-full justify-start', 'flex w-full min-w-0 justify-start',
stacked ? 'justify-start' : '@md/field-group:justify-end', 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 { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
import type { CertMonitoring } from '@cfdm/shared'
import type { ServiceView, SubdomainRecord } from '@/lib/schemas' import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
import type { SubdomainServiceLink } from '@/hooks/use-domain-page' import type { SubdomainServiceLink } from '@/hooks/use-domain-page'
import { certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatServiceGroupLabel } from '@/lib/service-utils' import { formatServiceGroupLabel } from '@/lib/service-utils'
import { FormSheet } from '@/components/form-sheet' import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field' import { FormFieldSimple } from '@/components/form-field'
@@ -30,7 +28,6 @@ import {
const subdomainEditSchema = z.object({ const subdomainEditSchema = z.object({
name: z.string().min(1, 'Укажите имя'), name: z.string().min(1, 'Укажите имя'),
serviceId: z.string(), serviceId: z.string(),
certMonitoring: z.enum(['auto', 'required', 'skipped']),
}) })
export type SubdomainEditValues = z.infer<typeof subdomainEditSchema> export type SubdomainEditValues = z.infer<typeof subdomainEditSchema>
@@ -67,7 +64,6 @@ export function SubdomainEditSheet({
defaultValues: { defaultValues: {
name: '', name: '',
serviceId: 'none', serviceId: 'none',
certMonitoring: 'auto',
}, },
}) })
@@ -85,42 +81,27 @@ export function SubdomainEditSheet({
[services, serviceGroupById], [services, serviceGroupById],
) )
const certMonitoringItems = useMemo(
() =>
certMonitoringOptions.map((option) => ({
label: option.label,
value: option.value,
})),
[],
)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
if (mode === 'edit' && subdomain) { if (mode === 'edit' && subdomain) {
form.reset({ form.reset({
name: subdomain.name, name: subdomain.name,
serviceId: currentServiceId || 'none', serviceId: currentServiceId || 'none',
certMonitoring: subdomain.cert_monitoring,
}) })
return return
} }
form.reset({ form.reset({
name: '', name: '',
serviceId: 'none', serviceId: 'none',
certMonitoring: 'auto',
}) })
}, [open, mode, subdomain, currentServiceId, form]) }, [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 const hasMultipleServices = mode === 'edit' && serviceLinks.length > 1
function handleSubmit(values: SubdomainEditValues) { function handleSubmit(values: SubdomainEditValues) {
onSubmit({ onSubmit({
name: values.name.trim(), name: values.name.trim(),
serviceId: values.serviceId, serviceId: values.serviceId,
certMonitoring: values.certMonitoring as CertMonitoring,
}) })
} }
@@ -218,39 +199,6 @@ export function SubdomainEditSheet({
)} )}
/> />
</FormFieldSimple> </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} ) : null}
</FieldGroup> </FieldGroup>
-17
View File
@@ -13,7 +13,6 @@ import {
serviceGroupsQueryOptions, serviceGroupsQueryOptions,
servicesQueryOptions, servicesQueryOptions,
subdomainsListQueryOptions, subdomainsListQueryOptions,
updateDomain,
updateSubdomain, updateSubdomain,
} from '@/queries' } from '@/queries'
import type { CertMonitoring } from '@cfdm/shared' 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({ const deleteSubdomainMutation = useMutation({
mutationFn: (id: number) => deleteSubdomain(id), mutationFn: (id: number) => deleteSubdomain(id),
onSuccess: () => { onSuccess: () => {
@@ -249,7 +233,6 @@ export function useDomainPage(domainId: number) {
syncMutation, syncMutation,
createSubdomainMutation, createSubdomainMutation,
updateSubdomainMutation, updateSubdomainMutation,
updateDomainCertMonitoringMutation,
deleteSubdomainMutation, deleteSubdomainMutation,
linkServiceMutation, linkServiceMutation,
} }
+3 -3
View File
@@ -8,17 +8,17 @@ export const certMonitoringOptions: Array<{
{ {
value: 'auto', value: 'auto',
label: 'Авто', label: 'Авто',
description: 'Проверять, если хост обслуживается активным сервисом', description: 'Проверять, если health-check сервиса с verify TLS',
}, },
{ {
value: 'required', value: 'required',
label: 'Обязательно', label: 'Обязательно',
description: 'Всегда проверять SSL, даже без привязок', description: 'Всегда проверять SSL для этого FQDN',
}, },
{ {
value: 'skipped', value: 'skipped',
label: 'Не проверять', 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
}
+20
View File
@@ -82,6 +82,7 @@ export const serviceDomainBindingSchema = z
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'), 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_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'), 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), sync_status: z.string().nullable().default(null),
}) })
.transform((binding) => ({ .transform((binding) => ({
@@ -133,6 +134,8 @@ export const serviceViewSchema = serviceSchema.extend({
health_latency_ms: z.number().nullable().default(null), health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]), 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: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
active_ips: z.array(z.string()).default([]),
}) })
export const serviceGroupViewSchema = serviceGroupSchema.extend({ export const serviceGroupViewSchema = serviceGroupSchema.extend({
@@ -195,6 +198,7 @@ export const serviceBindingSchema = z
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'), 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_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'), 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), sync_status: z.string().nullable().default(null),
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
@@ -232,6 +236,8 @@ export const certificateSchema = z.object({
id: z.number(), id: z.number(),
domain_id: z.number(), domain_id: z.number(),
subdomain_id: z.number().nullable(), subdomain_id: z.number().nullable(),
service_id: z.number().nullable().optional().default(null),
service_name: z.string().nullable().optional().default(null),
hostname: z.string(), hostname: z.string(),
expires_at: z.string().nullable(), expires_at: z.string().nullable(),
last_checked_at: z.string().nullable(), last_checked_at: z.string().nullable(),
@@ -241,6 +247,19 @@ export const certificateSchema = z.object({
updated_at: z.string(), 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 Group = z.infer<typeof groupSchema>
export type GroupWithStats = z.infer<typeof groupWithStatsSchema> export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
export type Service = z.infer<typeof serviceSchema> export type Service = z.infer<typeof serviceSchema>
@@ -254,6 +273,7 @@ export type DomainListItem = z.infer<typeof domainListItemSchema>
export type ServiceBinding = z.infer<typeof serviceBindingSchema> export type ServiceBinding = z.infer<typeof serviceBindingSchema>
export type DnsRecord = z.infer<typeof dnsRecordSchema> export type DnsRecord = z.infer<typeof dnsRecordSchema>
export type Certificate = z.infer<typeof certificateSchema> export type Certificate = z.infer<typeof certificateSchema>
export type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>
export const createGroupSchema = z.object({ export const createGroupSchema = z.object({
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'), name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
+30 -1
View File
@@ -1,11 +1,14 @@
import { queryOptions } from '@tanstack/react-query' import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client' 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' import { z } from 'zod'
export const certKeys = { export const certKeys = {
all: ['certificates'] as const, all: ['certificates'] as const,
summary: ['certificates', 'summary'] as const, summary: ['certificates', 'summary'] as const,
byService: (serviceId: number) =>
[...certKeys.all, 'service', serviceId] as const,
} }
export const certificatesQueryOptions = () => export const certificatesQueryOptions = () =>
@@ -23,3 +26,29 @@ export const certSummaryQueryOptions = () =>
queryKey: certKeys.summary, queryKey: certKeys.summary,
queryFn: () => api.get<[string, number][]>('/api/v1/certificates/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`,
)
}
+2 -2
View File
@@ -74,7 +74,7 @@ function CertificatesPage() {
<PageShell> <PageShell>
<PageHeader <PageHeader
title="Сертификаты" title="Сертификаты"
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»" description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
actions={primaryAction} actions={primaryAction}
/> />
<CertKpiStats <CertKpiStats
@@ -86,7 +86,7 @@ function CertificatesPage() {
/> />
<ResourcePage <ResourcePage
title="Сертификаты" title="Сертификаты"
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»" description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
hideHeader hideHeader
tabs={CERT_TABS.map((tab) => ({ ...tab }))} tabs={CERT_TABS.map((tab) => ({ ...tab }))}
activeTab={activeTab} activeTab={activeTab}
@@ -6,11 +6,9 @@ import {
GlobeIcon, GlobeIcon,
Link2Icon, Link2Icon,
ServerIcon, ServerIcon,
ShieldCheckIcon,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { Filter } from '@/components/reui/filters' import type { Filter } from '@/components/reui/filters'
import type { CertMonitoring } from '@cfdm/shared'
import { import {
domainDetailQueryOptions, domainDetailQueryOptions,
domainServiceBindingsQueryOptions, domainServiceBindingsQueryOptions,
@@ -41,19 +39,11 @@ import {
import { DomainBindingsPanel } from '@/components/domain-bindings-panel' import { DomainBindingsPanel } from '@/components/domain-bindings-panel'
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel' import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
import { formatDate } from '@/lib/format' import { formatDate } from '@/lib/format'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { LoadingButton } from '@/components/loading-button' import { LoadingButton } from '@/components/loading-button'
import { TableSkeleton } from '@/components/skeletons' import { TableSkeleton } from '@/components/skeletons'
import { Button } from '@cfdm/ui/components/button' 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' import { TabsContent } from '@cfdm/ui/components/tabs'
export const Route = createFileRoute('/_auth/domains/$domainId/')({ export const Route = createFileRoute('/_auth/domains/$domainId/')({
@@ -108,7 +98,6 @@ function DomainOverviewPage() {
syncMutation, syncMutation,
createSubdomainMutation, createSubdomainMutation,
updateSubdomainMutation, updateSubdomainMutation,
updateDomainCertMonitoringMutation,
deleteSubdomainMutation, deleteSubdomainMutation,
linkServiceMutation, linkServiceMutation,
} = useDomainPage(id) } = useDomainPage(id)
@@ -167,20 +156,15 @@ function DomainOverviewPage() {
if (!editTarget) return if (!editTarget) return
const nameChanged = values.name !== editTarget.subdomain.name const nameChanged = values.name !== editTarget.subdomain.name
const certMonitoringChanged =
values.certMonitoring !== editTarget.subdomain.cert_monitoring
const currentServiceId = resolveServiceId(editTarget) const currentServiceId = resolveServiceId(editTarget)
const serviceChanged = values.serviceId !== currentServiceId const serviceChanged = values.serviceId !== currentServiceId
const targetServiceId = const targetServiceId =
values.serviceId === 'none' ? null : Number(values.serviceId) values.serviceId === 'none' ? null : Number(values.serviceId)
if (nameChanged || certMonitoringChanged) { if (nameChanged) {
await updateSubdomainMutation.mutateAsync({ await updateSubdomainMutation.mutateAsync({
id: editTarget.subdomain.id, id: editTarget.subdomain.id,
...(nameChanged ? { name: values.name } : {}), name: values.name,
...(certMonitoringChanged
? { cert_monitoring: values.certMonitoring }
: {}),
}) })
} }
@@ -209,11 +193,6 @@ function DomainOverviewPage() {
updateSubdomainMutation.isPending || updateSubdomainMutation.isPending ||
linkServiceMutation.isPending linkServiceMutation.isPending
const certMonitoringItems = certMonitoringOptions.map((option) => ({
label: option.label,
value: option.value,
}))
const metricCards = useMemo(() => { const metricCards = useMemo(() => {
if (!domain) return [] if (!domain) return []
return [ return [
@@ -344,40 +323,6 @@ function DomainOverviewPage() {
> >
<TabsContent value="overview" className="flex flex-col gap-4"> <TabsContent value="overview" className="flex flex-col gap-4">
<DetailPanel.Metrics cards={metricCards} /> <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>
<TabsContent value="availability" className="flex flex-col gap-4"> <TabsContent value="availability" className="flex flex-col gap-4">
@@ -1,104 +1,11 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, redirect } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
import { EmptyState } from '@/components/empty-state'
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
import { HealthTimeline } from '@/components/health/health-timeline'
import { HealthCheckBadge } from '@/components/health-check-badge'
import {
serviceHealthLogQueryOptions,
serviceViewQueryOptions,
} from '@/queries'
import { formatDate } from '@/lib/format'
export const Route = createFileRoute('/_auth/services/$serviceId/health')({ 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 serviceQuery = useQuery(serviceViewQueryOptions(id))
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
const service = serviceQuery.data
const items = logQuery.data?.items ?? []
const ipHealth = service?.ip_health ?? []
const kpiCards = ipHealth.map((row) => {
const variant =
row.status === 'down'
? ('destructive' as const)
: row.status === 'degraded'
? ('warning' as const)
: ('default' as const)
return {
id: row.ip,
label: row.ip,
value: row.latency_ms != null ? `${row.latency_ms} мс` : '—',
hint: row.colo ? `colo ${row.colo}` : row.provider === 'cloudflare' ? 'Worker' : 'Local',
icon: row.provider === 'cloudflare' ? <GlobeIcon /> : <ServerIcon />,
variant,
footer: (
<HealthCheckBadge
status={row.status}
latencyMs={row.latency_ms}
lastCheckedAt={row.last_checked_at}
lastError={row.last_error}
colo={row.colo}
provider={row.provider}
size="xs"
/>
),
}
})
return (
<DetailPanel>
<DetailPanel.Header
title="Health"
description="Снимок проб этого сервиса. Cloudflare = Worker с edge, не Health Checks API."
/>
<Alert>
<AlertTitle>XOR провайдеров</AlertTitle>
<AlertDescription>
Local ходит с API CFDM; Cloudflare через Worker. Cron и пороги Slow/Down общие, в{' '}
<Link to="/settings/health" className="text-foreground underline">
Настройках Health-check
</Link>
. Если Worker не задан, цель не пробируется как Local.
</AlertDescription>
</Alert>
{kpiCards.length > 0 ? (
<KpiStatGrid cards={kpiCards} />
) : (
<EmptyState
icon={ActivityIcon}
title="Нет проб"
description="Включите health-check на привязке — статус IP появится после cron."
/>
)}
<DetailPanel.Header
title="Журнал проб"
description={
items[0]?.checked_at
? `Последняя: ${formatDate(items[0].checked_at)}`
: 'Последние пробы по IP этого сервиса'
}
/>
<HealthTimeline
events={items.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,
}))}
/>
</DetailPanel>
)
}
@@ -1,94 +1,447 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useMemo, useState } from 'react'
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react' import { useForm } from 'react-hook-form'
import { DetailPanel } from '@/components/reui-kit' 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 { 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 { HealthCheckBadge } from '@/components/health-check-badge'
import { Badge } from '@/components/reui/badge' import { LoadingButton } from '@/components/loading-button'
import { serviceOverviewQueryOptions } from '@/queries' 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 {
HealthProviderStatusTiles,
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 type { HealthCheckProvider } from '@cfdm/shared'
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/')({ export const Route = createFileRoute('/_auth/services/$serviceId/')({
component: ServiceOverviewPage, component: ServiceDetailPage,
}) })
function ServiceOverviewPage() { interface OverviewPayload {
const { serviceId } = Route.useParams() routing_strategy?: string
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId))) active_addresses?: string[]
const overview = data as { nodes?: Array<{
service: { id: number
name: string address: string
enabled: boolean protocol: string
health_status: 'up' | 'down' | 'degraded' | 'unknown' port: number | null
domains: Array<{ fqdn: string; zone_name: string }> health_status: string
} weight: number
nodes: Array<{ id: number; address: string; health_status: string }> priority: number
routing_strategy: string consecutive_failures: number
active_addresses: string[] last_failure_reason: string | null
} | undefined }>
}
if (!overview) { function ServiceDetailPage() {
return ( const { serviceId } = Route.useParams()
<EmptyState const id = Number(serviceId)
title="Сервис не найден" const navigate = useNavigate()
description="Вернитесь в каталог и выберите сервис." 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 [selectedProviders, setSelectedProviders] = useState<
HealthCheckProvider[] | null
>(null)
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 updateMutation = useMutation({
const domains = overview.service.domains ?? [] 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 activeProviders =
selectedProviders?.filter((provider) => enabledProviders.includes(provider)) ??
enabledProviders
const effectiveProviders =
activeProviders.length > 0 ? activeProviders : enabledProviders
const providerStatuses = useMemo(
() => providerHealthStatuses(logItems, enabledProviders),
[logItems, enabledProviders],
)
return ( return (
<DetailPanel> <QueryState
<DetailPanel.Header isLoading={isLoading}
title={overview.service.name} isError={isError}
description={`Маршрутизация: ${overview.routing_strategy}. Активные IP: ${ error={error}
overview.active_addresses.join(', ') || '—' onRetry={() => {
}`} void viewQuery.refetch()
actions={ void overviewQuery.refetch()
<HealthCheckBadge status={overview.service.health_status} /> }}
} >
/> {!service ? (
<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 ? (
<EmptyState <EmptyState
title="Пустой сервис" title="Сервис не найден"
description="Добавьте поддомен и ноду, затем настройте health-check." description="Вернитесь в каталог и выберите сервис."
stackedIcon
/> />
) : 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(', ') || 'нет',
},
]}
/>
<HealthProviderStatusTiles
enabled={enabledProviders}
selected={effectiveProviders}
statuses={providerStatuses}
onChange={setSelectedProviders}
/>
<section
aria-label="Мониторинг"
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
>
<ServiceHealthMonitor
items={logItems}
selectedProviders={effectiveProviders}
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 { createFileRoute, redirect } 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'
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({ 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 { createFileRoute, Outlet } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowLeftIcon } from 'lucide-react'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header' import {
import { QueryState } from '@/components/query-state' serviceHealthLogQueryOptions,
import { Button } from '@cfdm/ui/components/button' serviceNodesQueryOptions,
import { serviceOverviewQueryOptions } from '@/queries' serviceOverviewQueryOptions,
import { cn } from '@cfdm/ui/lib/utils' serviceViewQueryOptions,
} from '@/queries'
export const Route = createFileRoute('/_auth/services/$serviceId')({ export const Route = createFileRoute('/_auth/services/$serviceId')({
loader: ({ context: { queryClient }, params }) => loader: async ({ context: { queryClient }, params }) => {
queryClient.ensureQueryData(serviceOverviewQueryOptions(Number(params.serviceId))), 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, 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() { 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 ( return (
<PageShell> <PageShell>
<PageHeader <Outlet />
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>
</PageShell> </PageShell>
) )
} }
@@ -1,59 +1,11 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute, redirect } 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'
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({ 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 { createFileRoute, redirect } 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'
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({ 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>
)
}
@@ -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
);
+67 -16
View File
@@ -851,6 +851,7 @@ function mapServiceBinding(
health_check_timeout_ms: row.health_check_timeout_ms, health_check_timeout_ms: row.health_check_timeout_ms,
health_check_verify_tls: row.health_check_verify_tls, health_check_verify_tls: row.health_check_verify_tls,
...mapHealthFields(row), ...mapHealthFields(row),
cert_monitoring: row.cert_monitoring ?? "auto",
routing_strategy: row.routing_strategy as LbMode, routing_strategy: row.routing_strategy as LbMode,
operation_version: row.operation_version, operation_version: row.operation_version,
created_at: row.created_at, created_at: row.created_at,
@@ -1530,6 +1531,7 @@ export interface BindingLbPatch {
health_check_provider?: HealthCheckProvider; health_check_provider?: HealthCheckProvider;
health_check_providers?: HealthCheckProvider[]; health_check_providers?: HealthCheckProvider[];
health_check_aggregate?: HealthCheckAggregate; health_check_aggregate?: HealthCheckAggregate;
cert_monitoring?: string;
} }
export function updateBindingLbConfig( export function updateBindingLbConfig(
@@ -1560,6 +1562,8 @@ export function updateBindingLbConfig(
update.health_check_timeout_ms = patch.health_check_timeout_ms; update.health_check_timeout_ms = patch.health_check_timeout_ms;
if (patch.health_check_verify_tls !== undefined) if (patch.health_check_verify_tls !== undefined)
update.health_check_verify_tls = patch.health_check_verify_tls; 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)); Object.assign(update, healthProviderColumns(patch));
db.update(serviceBindings) db.update(serviceBindings)
.set(update) .set(update)
@@ -1669,7 +1673,7 @@ const SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.h
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port, 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_path, sb.health_check_expected_status, sb.health_check_interval_sec,
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider, sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider,
sb.health_check_providers, sb.health_check_aggregate, sb.cname_target, 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, d.zone_name, d.group_id, g.name AS group_name,
s.name AS service_name, s.slug AS service_slug, s.name AS service_name, s.slug AS service_slug,
dr.content AS target_ip, dr.sync_status, dr.content AS target_ip, dr.sync_status,
@@ -1733,6 +1737,7 @@ function enrichServiceBindingView(
return { return {
...row, ...row,
cname_target: row.cname_target ?? null, cname_target: row.cname_target ?? null,
cert_monitoring: row.cert_monitoring ?? "auto",
...mapHealthFields(row), ...mapHealthFields(row),
target_ips, target_ips,
target_ip: target_ips[0] ?? null, target_ip: target_ips[0] ?? null,
@@ -1914,26 +1919,69 @@ export function deleteBinding(db: Db, id: number): void {
// --- Certificates --- // --- 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[] { export function listCertificates(db: Db, status?: string): Certificate[] {
if (status) { const rows = status
return db ? db.all<CertificateRow>(sql`
.select() SELECT ${sql.raw(CERTIFICATE_SELECT)}
.from(certificates) FROM certificates c
.where(eq(certificates.status, status)) LEFT JOIN services s ON s.id = c.service_id
.orderBy(asc(certificates.expires_at)) WHERE c.status = ${status}
.all() as Certificate[]; ORDER BY c.expires_at ASC
} `)
return db : db.all<CertificateRow>(sql`
.select() SELECT ${sql.raw(CERTIFICATE_SELECT)}
.from(certificates) FROM certificates c
.orderBy(asc(certificates.expires_at)) LEFT JOIN services s ON s.id = c.service_id
.all() as Certificate[]; ORDER BY c.expires_at ASC
`);
return rows.map(mapCertificate);
} }
export function getCertificate(db: Db, id: number): Certificate { 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}`); if (!row) throw new NotFoundError(`certificate ${id}`);
return row as Certificate; return mapCertificate(row);
} }
export function upsertCertificateCheck( export function upsertCertificateCheck(
@@ -1944,6 +1992,7 @@ export function upsertCertificateCheck(
expiresAt: string | null, expiresAt: string | null,
status: string, status: string,
lastError: string | null, lastError: string | null,
serviceId?: number | null,
): Certificate { ): Certificate {
const existing = db const existing = db
.select() .select()
@@ -1956,6 +2005,7 @@ export function upsertCertificateCheck(
.set({ .set({
domain_id: domainId, domain_id: domainId,
subdomain_id: subdomainId, subdomain_id: subdomainId,
service_id: serviceId === undefined ? existing.service_id : serviceId,
expires_at: expiresAt, expires_at: expiresAt,
last_checked_at: sql`datetime('now')`, last_checked_at: sql`datetime('now')`,
last_error: lastError, last_error: lastError,
@@ -1972,6 +2022,7 @@ export function upsertCertificateCheck(
.values({ .values({
domain_id: domainId, domain_id: domainId,
subdomain_id: subdomainId, subdomain_id: subdomainId,
service_id: serviceId ?? null,
hostname, hostname,
expires_at: expiresAt, expires_at: expiresAt,
last_checked_at: sql`datetime('now')`, last_checked_at: sql`datetime('now')`,
+4
View File
@@ -177,6 +177,7 @@ export const serviceBindings = sqliteTable(
health_check_aggregate: text("health_check_aggregate") health_check_aggregate: text("health_check_aggregate")
.notNull() .notNull()
.default("majority"), .default("majority"),
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
routing_strategy: text("routing_strategy").notNull().default("round_robin"), routing_strategy: text("routing_strategy").notNull().default("round_robin"),
operation_version: integer("operation_version").notNull().default(0), operation_version: integer("operation_version").notNull().default(0),
created_at: text("created_at") created_at: text("created_at")
@@ -324,6 +325,9 @@ export const certificates = sqliteTable("certificates", {
subdomain_id: integer("subdomain_id").references(() => subdomains.id, { subdomain_id: integer("subdomain_id").references(() => subdomains.id, {
onDelete: "set null", onDelete: "set null",
}), }),
service_id: integer("service_id").references(() => services.id, {
onDelete: "set null",
}),
hostname: text("hostname").notNull().unique(), hostname: text("hostname").notNull().unique(),
expires_at: text("expires_at"), expires_at: text("expires_at"),
last_checked_at: text("last_checked_at"), last_checked_at: text("last_checked_at"),
+26
View File
@@ -176,6 +176,8 @@ interface ServiceView$1 {
health_latency_ms: number | null; health_latency_ms: number | null;
ip_health: ServiceIpHealth$1[]; ip_health: ServiceIpHealth$1[];
ip_enabled: Record<string, boolean>; ip_enabled: Record<string, boolean>;
lb_mode: LbMode;
active_ips: string[];
} }
interface SyncJob { interface SyncJob {
id: string; id: string;
@@ -824,6 +826,12 @@ declare const serviceViewSchema: z.ZodObject<{
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>; colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>; 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 serviceGroupViewSchema: z.ZodObject<{ declare const serviceGroupViewSchema: z.ZodObject<{
id: z.ZodNumber; id: z.ZodNumber;
@@ -1013,6 +1021,12 @@ declare const serviceGroupViewSchema: z.ZodObject<{
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>; colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>; 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>>>;
health_status: z.ZodDefault<z.ZodEnum<{ health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown"; unknown: "unknown";
@@ -1211,6 +1225,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>; colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>; 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>>>;
health_status: z.ZodDefault<z.ZodEnum<{ health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown"; unknown: "unknown";
@@ -1360,6 +1380,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>; colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>; 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>>>;
}, z.core.$strip>; }, z.core.$strip>;
declare const domainSchema: z.ZodObject<{ declare const domainSchema: z.ZodObject<{
+3 -1
View File
@@ -409,7 +409,9 @@ var serviceViewSchema = serviceSchema.extend({
health_status: ipHealthStateSchema.default("unknown"), health_status: ipHealthStateSchema.default("unknown"),
health_latency_ms: z.number().nullable().default(null), health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]), 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({ var serviceGroupViewSchema = serviceGroupSchema.extend({
services: z.array(serviceViewSchema).default([]), services: z.array(serviceViewSchema).default([]),
+20
View File
@@ -178,6 +178,7 @@ export const serviceDomainBindingSchema = z
health_check_provider: healthCheckProviderSchema.catch('local'), health_check_provider: healthCheckProviderSchema.catch('local'),
health_check_providers: healthCheckProvidersSchema.catch(['local']), health_check_providers: healthCheckProvidersSchema.catch(['local']),
health_check_aggregate: healthCheckAggregateSchema.catch('majority'), health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
cert_monitoring: certMonitoringSchema.default('auto'),
sync_status: z.string().nullable().default(null), sync_status: z.string().nullable().default(null),
}) })
.transform((binding) => ({ .transform((binding) => ({
@@ -205,6 +206,8 @@ export const serviceViewSchema = serviceSchema.extend({
health_latency_ms: z.number().nullable().default(null), health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]), 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([]),
}) })
export const serviceGroupViewSchema = serviceGroupSchema.extend({ export const serviceGroupViewSchema = serviceGroupSchema.extend({
@@ -264,6 +267,7 @@ export const serviceBindingSchema = z
health_check_interval_sec: z.number().default(30), health_check_interval_sec: z.number().default(30),
health_check_timeout_ms: z.number().default(3000), health_check_timeout_ms: z.number().default(3000),
health_check_verify_tls: z.coerce.boolean().default(false), health_check_verify_tls: z.coerce.boolean().default(false),
cert_monitoring: certMonitoringSchema.default('auto'),
sync_status: z.string().nullable().default(null), sync_status: z.string().nullable().default(null),
created_at: z.string(), created_at: z.string(),
updated_at: z.string(), updated_at: z.string(),
@@ -301,6 +305,8 @@ export const certificateSchema = z.object({
id: z.number(), id: z.number(),
domain_id: z.number(), domain_id: z.number(),
subdomain_id: z.number().nullable(), subdomain_id: z.number().nullable(),
service_id: z.number().nullable().optional().default(null),
service_name: z.string().nullable().optional().default(null),
hostname: z.string(), hostname: z.string(),
expires_at: z.string().nullable(), expires_at: z.string().nullable(),
last_checked_at: z.string().nullable(), last_checked_at: z.string().nullable(),
@@ -310,6 +316,19 @@ export const certificateSchema = z.object({
updated_at: z.string(), 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 Group = z.infer<typeof groupSchema>
export type GroupWithStats = z.infer<typeof groupWithStatsSchema> export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
export type Service = z.infer<typeof serviceSchema> export type Service = z.infer<typeof serviceSchema>
@@ -322,6 +341,7 @@ export type Domain = z.infer<typeof domainSchema>
export type DomainListItem = z.infer<typeof domainListItemSchema> export type DomainListItem = z.infer<typeof domainListItemSchema>
export type DnsRecord = z.infer<typeof dnsRecordSchema> export type DnsRecord = z.infer<typeof dnsRecordSchema>
export type Certificate = z.infer<typeof certificateSchema> export type Certificate = z.infer<typeof certificateSchema>
export type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>
export const createGroupSchema = z.object({ export const createGroupSchema = z.object({
name: z.string().min(1, 'Укажите название'), name: z.string().min(1, 'Укажите название'),
+7
View File
@@ -111,6 +111,8 @@ export interface Certificate {
id: number; id: number;
domain_id: number; domain_id: number;
subdomain_id: number | null; subdomain_id: number | null;
service_id: number | null;
service_name: string | null;
hostname: string; hostname: string;
expires_at: string | null; expires_at: string | null;
last_checked_at: string | null; last_checked_at: string | null;
@@ -139,6 +141,7 @@ export interface ServiceBinding {
health_check_provider: HealthCheckProvider; health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[]; health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate; health_check_aggregate: HealthCheckAggregate;
cert_monitoring: string;
routing_strategy: LbMode; routing_strategy: LbMode;
operation_version: number; operation_version: number;
created_at: string; created_at: string;
@@ -173,6 +176,7 @@ export interface ServiceBindingView {
health_check_provider: HealthCheckProvider; health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[]; health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate; health_check_aggregate: HealthCheckAggregate;
cert_monitoring: string;
sync_status: string | null; sync_status: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -201,6 +205,7 @@ export interface ServiceDomainBindingView {
health_check_provider: HealthCheckProvider; health_check_provider: HealthCheckProvider;
health_check_providers: HealthCheckProvider[]; health_check_providers: HealthCheckProvider[];
health_check_aggregate: HealthCheckAggregate; health_check_aggregate: HealthCheckAggregate;
cert_monitoring: string;
sync_status: string | null; sync_status: string | null;
} }
@@ -222,6 +227,8 @@ export interface ServiceView {
health_latency_ms: number | null; health_latency_ms: number | null;
ip_health: ServiceIpHealth[]; ip_health: ServiceIpHealth[];
ip_enabled: Record<string, boolean>; ip_enabled: Record<string, boolean>;
lb_mode: LbMode;
active_ips: string[];
} }
export interface GroupWithStats extends Group { export interface GroupWithStats extends Group {