Compare commits

...
8 Commits
Author SHA1 Message Date
Denozordec 69119a08a4 feat(health): enhance HealthTimeline and HealthSourceTiles components
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 10s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m1s
CD / quality (push) Successful in 1m15s
CD / publish (push) Successful in 2m11s
- Added optional props `emptyTitle` and `emptyDescription` to HealthTimeline for customizable empty state messages.
- Refactored HealthSourceTiles to export `HEALTH_PROVIDER_ITEMS` and introduced a new `HealthProviderStatusTiles` component for improved health monitoring.
- Updated UptimeChart to support period selection and filtering, enhancing data visualization capabilities.
- Improved ServiceDetailPage by integrating HealthProviderStatusTiles and ServiceHealthMonitor for better service health insights.
2026-08-20 00:09:55 +07:00
Denozordec ba03d2be9d feat(services): enhance service health logging and component structure
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 59s
quality / api (push) Successful in 44s
CD / quality (push) Successful in 2m0s
CD / publish (push) Successful in 1m37s
- Updated the service health log retrieval to include a limit parameter, improving performance and control over log data.
- Refactored the ServiceUnitCard component to export the LbModeTile function, enhancing reusability across the application.
- Simplified routing for service health and nodes pages by redirecting to the service overview, improving user navigation.
- Enhanced the service detail page with additional state management and mutation hooks for better service configuration handling.
- Removed unused components and streamlined the service routing and subdomains pages for improved clarity and maintainability.
2026-08-19 23:45:53 +07:00
Denozordec d8fc4ac949 feat(services): update ServiceUnitCard with background color for FramePanel
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 8s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 52s
CD / quality (push) Successful in 1m3s
CD / publish (push) Successful in 1m36s
- Added `bg-muted` class to FramePanel in ServiceUnitCard for improved visual consistency.
- Updated documentation to reflect the new background color setting for FramePanel, enhancing clarity on component usage.
2026-08-19 23:13:58 +07:00
Denozordec d063323402 feat(services): add load balancing mode and active IPs to service view
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m15s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 2m17s
CD / publish (push) Successful in 1m41s
- Introduced `lb_mode` to specify the load balancing strategy for services, supporting options like round robin, failover, and weighted.
- Added `active_ips` to track currently active IPs for each service, enhancing visibility into service health and configuration.
- Updated relevant components to display load balancing mode and active IPs, improving user experience and service management capabilities.
2026-08-19 23:00:21 +07:00
Denozordec 6bced71037 feat(ui): refactor ServiceIpList and ServiceUnitCard components for improved layout and functionality
quality / commitlint (push) Skipped
quality / changes (push) Successful in 10s
quality / api (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / web (push) Successful in 53s
CD / quality (push) Successful in 1m9s
CD / publish (push) Successful in 1m37s
- Replaced div elements with Item components in ServiceIpList for better alignment and structure.
- Introduced alignWithMenu prop to align IP Switch with the card menu.
- Updated ServiceUnitCard to utilize Item components, enhancing the visual consistency and responsiveness of the service card layout.
- Improved overall user experience by streamlining component structure and ensuring proper alignment of UI elements.
2026-08-19 22:37:54 +07:00
DenozordecandCursor 44d0eb0114 ci: учитывать несколько коммитов в одном пуше при detect paths
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 51s
quality / api (push) Successful in 45s
CD / quality (push) Successful in 1m47s
CD / publish (push) Successful in 1m37s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 22:09:14 +07:00
DenozordecandCursor 9f00dfcf84 chore(reui): обновить ReUI agent skill до актуальной версии
CD / update-wiki (push) Successful in 4s
quality / commitlint (push) Skipped
quality / changes (push) Failing after 7s
quality / web (push) Skipped
quality / api (push) Skipped
quality / docker-check (push) Skipped
CD / quality (push) Failing after 9s
CD / publish (push) Skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 20:04:19 +07:00
DenozordecandCursor 9b9dcc3b12 fix(ui): выровнять health-check плитки и тип TCP/HTTP под ReUI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 20:04:09 +07:00
36 changed files with 2335 additions and 784 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+4 -4
View File
@@ -6,18 +6,18 @@ alwaysApply: false
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+8 -7
View File
@@ -38,14 +38,11 @@ jobs:
api: ${{ steps.detect.outputs.api }}
docker: ${{ steps.detect.outputs.docker }}
steps:
- if: ${{ inputs.is_pull_request }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
# Push may contain several commits; github.event.before is then
# more than one parent away. fetch-depth: 2 only has HEAD~1.
fetch-depth: 0
- if: ${{ inputs.is_pull_request == false }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 2
- id: detect
name: Detect changed paths per module
env:
@@ -72,12 +69,16 @@ jobs:
done
}
has_commit() {
git cat-file -e "${1}^{commit}" 2>/dev/null
}
if [ "$IS_PR" = "true" ]; then
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
else
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
before="$BEFORE_SHA"
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ] && has_commit "$before"; then
FILES="$(git diff --name-only "$before" "$after")"
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)"
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+5 -1
View File
@@ -69,7 +69,11 @@ export async function serviceRoutes(app: FastifyInstance) {
const { id } = request.params as { id: string };
repos.getService(request.server.db, Number(id));
return {
items: repos.listHealthProbeLogForService(request.server.db, Number(id)),
items: repos.listHealthProbeLogForService(
request.server.db,
Number(id),
200,
),
};
});
@@ -312,6 +312,14 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
};
});
const activeIps = new Set<string>();
for (const binding of bindings) {
const { config, rows } = getBindingLbState(db, binding.id);
for (const ip of selectActiveIpsByMode(config, rows)) {
activeIps.add(ip);
}
}
return {
id: service.id,
name: service.name,
@@ -330,6 +338,8 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
health_status: "unknown",
health_latency_ms: null,
ip_health: [],
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
active_ips: [...activeIps],
};
}
+6 -5
View File
@@ -123,11 +123,12 @@ describe("create service then list groups", () => {
expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe(
true,
);
expect(
httpParsed.data!.groups
.find((g) => g.id === group.id)
?.services.some((s) => s.id === created.id),
).toBe(true);
const listed = httpParsed.data!.groups
.find((g) => g.id === group.id)
?.services.find((s) => s.id === created.id);
expect(listed).toBeDefined();
expect(listed?.lb_mode).toBe("round_robin");
expect(listed?.active_ips).toEqual(["1.2.3.4"]);
await app.close();
});
@@ -18,7 +18,9 @@ import {
} from '@cfdm/ui/components/select'
import { Switch } from '@cfdm/ui/components/switch'
import { FieldGroup } from '@cfdm/ui/components/field'
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
import { Button } from '@cfdm/ui/components/button'
import { ButtonGroup } from '@cfdm/ui/components/button-group'
import { CableIcon, GlobeIcon } from 'lucide-react'
import { cn } from '@cfdm/ui/lib/utils'
import {
HealthAggregateTiles,
@@ -215,28 +217,30 @@ export function HealthCheckConfigFields({
<div className="flex flex-col gap-3 pt-1 pb-1">
<div className="grid grid-cols-2 gap-3">
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
<ToggleGroup
id={`${idPrefix}-type`}
variant="outline"
size="sm"
spacing={0}
multiple={false}
className="w-full min-w-0"
value={[value.type]}
onValueChange={(next) => {
const picked = next[0]
if (picked === 'tcp' || picked === 'http') {
patch({ type: picked })
}
}}
>
<ToggleGroupItem value="tcp" className="flex-1">
<ButtonGroup id={`${idPrefix}-type`} className="w-full min-w-0">
<Button
type="button"
size="sm"
variant={value.type === 'tcp' ? 'secondary' : 'outline'}
className="flex-1"
aria-pressed={value.type === 'tcp'}
onClick={() => patch({ type: 'tcp' })}
>
<CableIcon data-icon="inline-start" />
TCP
</ToggleGroupItem>
<ToggleGroupItem value="http" className="flex-1">
</Button>
<Button
type="button"
size="sm"
variant={value.type === 'http' ? 'secondary' : 'outline'}
className="flex-1"
aria-pressed={value.type === 'http'}
onClick={() => patch({ type: 'http' })}
>
<GlobeIcon data-icon="inline-start" />
HTTP
</ToggleGroupItem>
</ToggleGroup>
</Button>
</ButtonGroup>
</FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
@@ -29,15 +29,21 @@ export interface HealthTimelineEvent {
interface HealthTimelineProps {
events: HealthTimelineEvent[]
emptyTitle?: string
emptyDescription?: string
}
export function HealthTimeline({ events }: HealthTimelineProps) {
export function HealthTimeline({
events,
emptyTitle = 'Нет событий',
emptyDescription = 'Результаты проверок появятся после первого прогона',
}: HealthTimelineProps) {
if (events.length === 0) {
return (
<EmptyState
icon={Link2Icon}
title="Нет событий"
description="Результаты проверок появятся после первого прогона"
title={emptyTitle}
description={emptyDescription}
centered={false}
/>
)
@@ -36,6 +36,13 @@ function getBreadcrumbs(
return [{ label: 'Панель управления', href: '/' }]
}
if (pathname.match(/^\/services\/\d+$/)) {
return [
{ label: 'Сервисы', href: '/services' },
{ label: dynamicLabels[pathname] ?? 'Сервис', href: pathname },
]
}
if (pathname.match(/^\/groups\/\d+$/)) {
return [
{ label: 'Группы доменов', href: '/groups' },
@@ -1,11 +1,21 @@
import type { KeyboardEvent, ReactNode } from 'react'
import { CheckIcon, ServerIcon, LayersIcon, ShieldAlertIcon, ScaleIcon } from 'lucide-react'
import { ServerIcon, LayersIcon, ShieldAlertIcon, ScaleIcon } from 'lucide-react'
import { Frame, FramePanel } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile'
import { Badge } from '@/components/reui/badge'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from '@cfdm/ui/components/item'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { cn } from '@cfdm/ui/lib/utils'
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
import { kpiCols } from './kpi-cols'
import type { HealthLogStatus } from '@/lib/health-log'
export type HealthProvider = HealthCheckProvider
export type HealthAggregate = HealthCheckAggregate
@@ -40,7 +50,7 @@ function GlobalpingMark() {
)
}
const PROVIDER_ITEMS: Array<{
export const HEALTH_PROVIDER_ITEMS: Array<{
id: HealthProvider
title: string
description: string
@@ -103,13 +113,14 @@ function handleTileKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivE
}
}
function TilePanel({
function ChoicePanel({
selected,
title,
description,
icon,
iconClassName,
role,
trailing,
onActivate,
}: {
selected: boolean
@@ -118,57 +129,57 @@ function TilePanel({
icon: ReactNode
iconClassName?: string
role: 'checkbox' | 'radio'
trailing?: ReactNode
onActivate: () => void
}) {
return (
<FramePanel
fit
role={role}
aria-checked={selected}
aria-pressed={selected}
tabIndex={0}
className={cn(
'relative isolate flex min-w-0 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',
selected && 'ring-ring ring-1',
selected && 'bg-muted/40',
)}
onClick={onActivate}
onKeyDown={(event) => handleTileKeyDown(onActivate, event)}
>
<div className="relative z-10 flex min-w-0 items-start gap-3">
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5 shrink-0', iconClassName ?? DEFAULT_ICON_CLASS)}
>
{icon}
</IconTile>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex min-w-0 items-start justify-between gap-2">
<span className="text-foreground truncate text-sm font-medium">{title}</span>
{selected ? (
<CheckIcon className="text-foreground size-4 shrink-0" aria-hidden />
) : null}
</div>
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{description}
</p>
</div>
</div>
<Item size="sm" className="w-full min-w-0 border-0 p-0">
<ItemMedia>
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', iconClassName ?? DEFAULT_ICON_CLASS)}
>
{icon}
</IconTile>
</ItemMedia>
<ItemContent className="min-w-0 gap-0.5">
<ItemTitle className="w-full min-w-0">{title}</ItemTitle>
<ItemDescription>{description}</ItemDescription>
</ItemContent>
{trailing ? (
<ItemActions className="shrink-0">{trailing}</ItemActions>
) : null}
</Item>
</FramePanel>
)
}
function TileGrid({ children, count }: { children: ReactNode; count: number }) {
function ChoiceFrame({ children }: { children: ReactNode }) {
return (
<Frame dense spacing="sm" className="@container w-full min-w-0 overflow-hidden">
<div className={cn('grid min-w-0 gap-2', kpiCols(count))}>{children}</div>
<Frame stacked spacing="sm" className="w-full min-w-0">
{children}
</Frame>
)
}
/**
* Мультивыбор источников проб (Local / Cloudflare / Globalping).
* Preview: https://reui.io/preview/base/card-12 · https://reui.io/preview/base/stats-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
*/
export function HealthSourceTiles({
@@ -190,9 +201,9 @@ export function HealthSourceTiles({
}
return (
<TileGrid count={PROVIDER_ITEMS.length}>
{PROVIDER_ITEMS.map((item) => (
<TilePanel
<ChoiceFrame>
{HEALTH_PROVIDER_ITEMS.map((item) => (
<ChoicePanel
key={item.id}
selected={selected.includes(item.id)}
title={item.title}
@@ -200,16 +211,23 @@ export function HealthSourceTiles({
icon={item.icon}
iconClassName={item.iconClassName}
role="checkbox"
trailing={
selected.includes(item.id) ? (
<Badge variant="outline" size="sm">
Выбрано
</Badge>
) : null
}
onActivate={() => toggle(item.id)}
/>
))}
</TileGrid>
</ChoiceFrame>
)
}
/**
* Правило агрегации (ровно одно): 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({
value,
@@ -220,18 +238,80 @@ export function HealthAggregateTiles({
}) {
const selected = value || 'majority'
return (
<TileGrid count={AGGREGATE_ITEMS.length}>
<ChoiceFrame>
{AGGREGATE_ITEMS.map((item) => (
<TilePanel
<ChoicePanel
key={item.id}
selected={selected === item.id}
title={item.title}
description={item.description}
icon={item.icon}
role="radio"
trailing={
selected === item.id ? (
<Badge variant="outline" size="sm">
Выбрано
</Badge>
) : null
}
onActivate={() => onChange(item.id)}
/>
))}
</TileGrid>
</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 { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
@@ -21,6 +23,7 @@ export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export {
HealthSourceTiles,
HealthAggregateTiles,
HealthProviderStatusTiles,
type HealthProvider,
type HealthAggregate,
} 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,314 @@
import { 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,
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
}
interface UptimeTooltipProps {
active?: boolean
payload?: Array<{ payload: ChartPoint }>
}
function UptimeTooltip({ active, payload }: UptimeTooltipProps) {
if (!active || !payload?.[0]) return null
const point = payload[0].payload
return (
<div className="bg-popover text-popover-foreground rounded-md px-3 py-2 text-sm shadow-md">
<p className="font-medium tabular-nums">
{point.latency} мс · {point.ok ? 'OK' : 'Down'}
</p>
<p className="text-muted-foreground text-xs">{point.period}</p>
</div>
)
}
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 period = periodProp ?? internalPeriod
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
function handlePeriodChange(next: UptimePeriodKey) {
onPeriodChange?.(next)
if (periodProp == null) setInternalPeriod(next)
}
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>
) : delta >= 0 ? (
<>
<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>
</>
) : (
<>
<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>
</>
)}
</div>
</div>
<div className="h-40 w-full">
<ChartContainer
config={chartConfig}
className="h-full w-full overflow-hidden rounded-b-xl"
initialDimension={{ width: 320, height: 160 }}
>
<AreaChart
data={points}
margin={{ top: 10, left: 0, right: 0, bottom: 0 }}
>
<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 content={<UptimeTooltip />} />
<Area
dataKey="latency"
type="natural"
fill={`url(#${gradientId})`}
stroke="var(--color-latency)"
strokeWidth={2}
dot={(dotProps) => {
const { cx, cy, payload, index } = dotProps as {
cx?: number
cy?: number
index?: number
payload?: ChartPoint
}
if (cx == null || cy == null) return <g key={index} />
const fill = payload?.ok
? 'var(--color-latency)'
: 'var(--destructive)'
return (
<circle
key={index}
cx={cx}
cy={cy}
r={4}
fill={fill}
stroke="var(--background)"
strokeWidth={2}
/>
)
}}
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,572 @@
import { useMemo, useState, type ReactNode } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import {
GlobeIcon,
NetworkIcon,
PlusIcon,
SearchIcon,
ServerIcon,
} from 'lucide-react'
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 type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import { Switch } from '@cfdm/ui/components/switch'
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: 'Ноды' },
] 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 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
: 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 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 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>
}
/>
)
}
return (
<ResourcePage
title="Активы сервиса"
description="IP, FQDN и ноды этого сервиса"
{...sharedTabs}
filterFields={ipFilterFields}
filters={ipFilters}
onFiltersChange={setIpFilters}
onClearFilters={() =>
setIpFilters([
createFilter('ip', 'contains', ['']),
createFilter('status', 'is', ['']),
])
}
getFilterFieldValue={(item, field) => {
if (field === 'ip') return item.ip
if (field === 'status') return item.status
return ''
}}
columns={ipColumns}
data={ipRows}
getRowId={(row) => row.id}
isLoading={isLoading}
/>
)
}
@@ -2,12 +2,20 @@ import { CheckIcon, CopyIcon } from 'lucide-react'
import { toast } from 'sonner'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import { TruncatedText } from '@/components/truncated-text'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { serviceDisplayFqdns } from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemMedia,
} from '@cfdm/ui/components/item'
import { Switch } from '@cfdm/ui/components/switch'
import {
Tooltip,
@@ -17,6 +25,9 @@ import {
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
/** Matches `Button size="icon-sm"` so Switch columns align with the card menu. */
const MENU_SLOT_CLASS = 'size-7 shrink-0'
export function CopyFqdnButton({
value,
className,
@@ -126,6 +137,11 @@ interface ServiceIpListProps {
togglingIp?: string | null
ipToggleDisabled?: boolean
onToggleIp?: (ip: string, enabled: boolean) => void
/** Invisible icon-sm slot so IP Switch lines up with the card overflow menu. */
alignWithMenu?: boolean
lbMode?: ServiceView['lb_mode']
activeIps?: string[]
ipWeights?: Record<string, number>
className?: string
emptyLabel?: string
copyable?: boolean
@@ -139,6 +155,10 @@ export function ServiceIpList({
togglingIp = null,
ipToggleDisabled = false,
onToggleIp,
alignWithMenu = false,
lbMode,
activeIps = [],
ipWeights = {},
className,
emptyLabel = 'Нет IP',
copyable = false,
@@ -155,49 +175,83 @@ export function ServiceIpList({
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
const extraCount = ips.length - visible.length
const showMenuSlot = Boolean(onToggleIp && alignWithMenu)
const markActive = lbMode === 'failover' || lbMode === 'weighted'
const activeSet = new Set(activeIps)
return (
<div className={cn('flex min-w-0 flex-col gap-1', className)}>
<ItemGroup className={cn('gap-1', className)}>
{visible.map((ip) => {
const health = healthByIp.get(ip)
const enabled = ipEnabled[ip] !== false
return (
<div key={ip} className="flex min-w-0 items-center gap-1.5">
<HealthCheckBadge
status={health?.status ?? 'unknown'}
latencyMs={health?.latency_ms}
lastCheckedAt={health?.last_checked_at}
lastError={health?.last_error}
colo={health?.colo}
provider={health?.provider}
size="xs"
/>
<TruncatedText
className={cn(
'min-w-0 font-mono text-xs',
enabled ? 'text-muted-foreground' : 'text-muted-foreground/60',
textClassName,
)}
>
{ip}
</TruncatedText>
{copyable ? <CopyFqdnButton value={ip} /> : null}
{onToggleIp ? (
<Switch
size="sm"
className="ml-auto shrink-0"
checked={enabled}
disabled={ipToggleDisabled || togglingIp === ip}
onClick={(event) => {
event.stopPropagation()
}}
onCheckedChange={(checked) => onToggleIp(ip, Boolean(checked))}
aria-label={
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
}
<Item
key={ip}
size="sm"
className="w-full min-w-0 flex-nowrap border-0 p-0"
>
<ItemMedia>
<HealthCheckBadge
status={health?.status ?? 'unknown'}
latencyMs={health?.latency_ms}
lastCheckedAt={health?.last_checked_at}
lastError={health?.last_error}
colo={health?.colo}
provider={health?.provider}
size="xs"
/>
</ItemMedia>
<ItemContent className="min-w-0 gap-0">
<div className="flex min-w-0 items-center gap-1.5">
<TruncatedText
className={cn(
'min-w-0 font-mono text-xs',
enabled
? 'text-muted-foreground'
: 'text-muted-foreground/60',
textClassName,
)}
>
{ip}
</TruncatedText>
{copyable ? <CopyFqdnButton value={ip} /> : null}
{markActive && activeSet.has(ip) ? (
<StatusBadge status="active" className="shrink-0" />
) : null}
{lbMode === 'weighted' ? (
<Badge
variant="outline"
size="xs"
className="shrink-0 tabular-nums"
>
w{ipWeights[ip] ?? 1}
</Badge>
) : null}
</div>
</ItemContent>
{onToggleIp ? (
<ItemActions className="ml-auto shrink-0 gap-1">
<Switch
size="sm"
className="shrink-0"
checked={enabled}
disabled={ipToggleDisabled || togglingIp === ip}
onClick={(event) => {
event.stopPropagation()
}}
onCheckedChange={(checked) =>
onToggleIp(ip, Boolean(checked))
}
aria-label={
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
}
/>
{showMenuSlot ? (
<span className={MENU_SLOT_CLASS} aria-hidden="true" />
) : null}
</ItemActions>
) : null}
</div>
</Item>
)
})}
{extraCount > 0 ? (
@@ -224,6 +278,6 @@ export function ServiceIpList({
</Tooltip>
</TooltipProvider>
) : null}
</div>
</ItemGroup>
)
}
@@ -1,11 +1,17 @@
import { Link } from '@tanstack/react-router'
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
import {
GitForkIcon,
MoreHorizontalIcon,
Repeat2Icon,
ScaleIcon,
ServerIcon,
type LucideIcon,
} from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
@@ -23,6 +29,12 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import {
Item,
ItemActions,
ItemContent,
ItemMedia,
} from '@cfdm/ui/components/item'
import { Switch } from '@cfdm/ui/components/switch'
import {
Tooltip,
@@ -30,6 +42,63 @@ import {
TooltipProvider,
TooltipTrigger,
} from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
/**
* Compact service card — settings-8 DNA (Badge + copy + Switch + menu).
* Preview: https://reui.io/preview/base/settings-8
* Frame: https://reui.io/docs/components/base/frame
* IconTile: https://reui.io/docs/components/base/icon-tile
* Header fill: FramePanel `bg-muted` (overrides `--frame-panel-bg`; see frame.tsx).
*/
type LbMode = ServiceView['lb_mode']
const LB_MODE_META: Record<
LbMode,
{ icon: LucideIcon; className: string; label: string }
> = {
round_robin: {
icon: Repeat2Icon,
className: 'text-info',
label: 'Round Robin',
},
failover: {
icon: GitForkIcon,
className: 'text-warning',
label: 'Failover (приоритет)',
},
weighted: {
icon: ScaleIcon,
className: 'text-info',
label: 'Weighted (веса)',
},
}
export function LbModeTile({ mode }: { mode: LbMode }) {
const meta = LB_MODE_META[mode]
const Icon = meta.icon
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<IconTile
variant="elevated"
size="xs"
className={cn('shrink-0', meta.className)}
aria-label={meta.label}
/>
}
>
<Icon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{meta.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
interface ServiceUnitCardProps {
service: ServiceView
@@ -55,27 +124,32 @@ export function ServiceUnitCard({
const extraCount = Math.max(0, fqdns.length - 1)
return (
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden">
<FrameHeader className="flex-row items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<IconTile
variant="elevated"
size="sm"
className="text-muted-foreground"
aria-hidden="true"
>
<ServerIcon />
</IconTile>
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle className="min-w-0 truncate text-sm">
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
className="hover:underline"
>
{service.name}
</Link>
</FrameTitle>
<Frame stacked spacing="sm" className="h-full min-w-0">
<FramePanel fit className="bg-muted">
<Item size="sm" className="w-full min-w-0 flex-nowrap border-0 p-0">
<ItemMedia>
<IconTile
variant="elevated"
size="sm"
className="text-foreground"
aria-hidden="true"
>
<ServerIcon />
</IconTile>
</ItemMedia>
<ItemContent className="min-w-0 gap-px">
<div className="flex min-w-0 items-center gap-1.5">
<FrameTitle className="min-w-0 truncate text-base font-semibold">
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
className="hover:underline"
>
{service.name}
</Link>
</FrameTitle>
<LbModeTile mode={service.lb_mode} />
</div>
<div className="flex min-w-0 items-center gap-1">
<FrameDescription className="min-w-0 truncate font-mono text-xs">
{primaryDomain}
@@ -108,66 +182,70 @@ export function ServiceUnitCard({
<CopyFqdnButton value={fqdns.join('\n')} />
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Switch
size="sm"
checked={service.enabled}
disabled={togglingId === service.id}
onCheckedChange={(checked) =>
onToggleService(service.id, Boolean(checked))
}
aria-label={
service.enabled ? 'Выключить сервис' : 'Включить сервис'
}
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия ${service.name}`}
/>
</ItemContent>
<ItemActions className="ml-auto shrink-0 gap-1">
<Switch
size="sm"
checked={service.enabled}
disabled={togglingId === service.id}
onCheckedChange={(checked) =>
onToggleService(service.id, Boolean(checked))
}
>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
aria-label={
service.enabled ? 'Выключить сервис' : 'Включить сервис'
}
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия ${service.name}`}
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditService(service)}>
Изменить
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteService(service)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</FrameHeader>
<MoreHorizontalIcon aria-hidden />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
render={
<Link
to="/services/$serviceId"
params={{ serviceId: String(service.id) }}
/>
}
>
Обзор
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEditService(service)}>
Изменить
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteService(service)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ItemActions>
</Item>
</FramePanel>
<FramePanel className="flex min-w-0 flex-col gap-1 pt-0 shadow-none!">
<FramePanel className="flex min-w-0 flex-col">
<ServiceIpList
copyable
alignWithMenu
ips={service.ips ?? []}
ipHealth={service.ip_health ?? []}
ipEnabled={service.ip_enabled ?? {}}
ipToggleDisabled={togglingId === service.id}
togglingIp={togglingIp}
lbMode={service.lb_mode}
activeIps={service.active_ips}
ipWeights={service.domains[0]?.target_ip_weights}
onToggleIp={(ip, enabled) =>
onToggleServiceIp(service.id, ip, enabled)
}
+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
}
+2
View File
@@ -133,6 +133,8 @@ export const serviceViewSchema = serviceSchema.extend({
health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]),
ip_enabled: z.record(z.string(), z.boolean()).default({}),
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
active_ips: z.array(z.string()).default([]),
})
export const serviceGroupViewSchema = serviceGroupSchema.extend({
@@ -1,104 +1,11 @@
import { createFileRoute, Link } 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'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
component: ServiceHealthPage,
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
})
export function ServiceHealthPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const 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 { useQuery } from '@tanstack/react-query'
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
import { DetailPanel } from '@/components/reui-kit'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
ActivityIcon,
GlobeIcon,
NetworkIcon,
PencilIcon,
ServerIcon,
} from 'lucide-react'
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
import { ChangeIpSheet } from '@/components/change-ip-sheet'
import { EmptyState } from '@/components/empty-state'
import { FailoverTimeline } from '@/components/failover-timeline'
import { FormFieldSimple } from '@/components/form-field'
import { FormSheet } from '@/components/form-sheet'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { Badge } from '@/components/reui/badge'
import { serviceOverviewQueryOptions } from '@/queries'
import { LoadingButton } from '@/components/loading-button'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { ServiceEditSheet } from '@/components/service-edit-sheet'
import {
ServiceDetailGrid,
type ServiceFqdnRow,
} from '@/components/services/service-detail-grid'
import { LbModeTile } from '@/components/services/service-unit-card'
import {
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/')({
component: ServiceOverviewPage,
component: ServiceDetailPage,
})
function ServiceOverviewPage() {
const { serviceId } = Route.useParams()
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
const overview = data as {
service: {
name: string
enabled: boolean
health_status: 'up' | 'down' | 'degraded' | 'unknown'
domains: Array<{ fqdn: string; zone_name: string }>
}
nodes: Array<{ id: number; address: string; health_status: string }>
routing_strategy: string
active_addresses: string[]
} | undefined
interface OverviewPayload {
routing_strategy?: string
active_addresses?: string[]
nodes?: Array<{
id: number
address: string
protocol: string
port: number | null
health_status: string
weight: number
priority: number
consecutive_failures: number
last_failure_reason: string | null
}>
}
if (!overview) {
return (
<EmptyState
title="Сервис не найден"
description="Вернитесь в каталог и выберите сервис."
/>
)
function ServiceDetailPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const navigate = useNavigate()
const queryClient = useQueryClient()
const viewQuery = useQuery(serviceViewQueryOptions(id))
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
const groupsQuery = useQuery(serviceGroupsQueryOptions())
const domainsQuery = useQuery(domainsListQueryOptions())
const service = viewQuery.data
const overview = overviewQuery.data as OverviewPayload | undefined
const logItems = useMemo(
() => logQuery.data?.items ?? [],
[logQuery.data?.items],
)
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
const [editOpen, setEditOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [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 domains = overview.service.domains ?? []
const updateMutation = useMutation({
mutationFn: ({ body }: { body: UpdateServiceConfigInput }) =>
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
onSuccess: async () => {
await invalidateService()
setEditOpen(false)
toast.success('Сервис сохранён')
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
},
onSettled: () => setSaving(false),
})
const deleteMutation = useMutation({
mutationFn: () => api.delete(`/api/v1/services/${id}`),
onSuccess: async () => {
await invalidateService()
toast.success('Сервис удалён')
await navigate({ to: '/services' })
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
},
})
const toggleIpMutation = useMutation({
mutationFn: ({ ip, enabled }: { ip: string; enabled: boolean }) =>
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, { ip, enabled }),
onSuccess: async (_data, { enabled }) => {
await invalidateService()
toast.success(
enabled
? 'IP включён и добавлен в DNS-привязки'
: 'IP выключен и снят с DNS-привязок',
)
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : 'Не удалось переключить IP')
},
onSettled: () => setTogglingIp(null),
})
const createNodeMut = useMutation({
mutationFn: (values: { address: string; port: string }) =>
createServiceNode(id, {
address: values.address.trim(),
port: values.port ? Number(values.port) : null,
}),
onSuccess: async () => {
toast.success('Нода добавлена, статус CHECKING')
await invalidateService()
setAddNodeOpen(false)
nodeForm.reset()
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
})
const deleteNodeMut = useMutation({
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
onSuccess: async () => {
toast.success('Нода удалена')
await invalidateService()
},
})
const isLoading = viewQuery.isLoading || overviewQuery.isLoading
const isError = viewQuery.isError || overviewQuery.isError
const error = viewQuery.error ?? overviewQuery.error
const failoverEvents =
(nodes.length > 0 ? nodes : (overview?.nodes ?? []))
.filter(
(node) =>
node.health_status === 'unhealthy' ||
node.health_status === 'down' ||
node.health_status === 'checking',
)
.map((node) => ({
id: node.address,
title: `${node.address}: ${node.health_status}`,
detail: node.last_failure_reason
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
: `fail ${node.consecutive_failures}`,
}))
const enabledProviders = useMemo(
() => enabledHealthProviders(service?.domains ?? []),
[service],
)
const activeProviders =
selectedProviders?.filter((provider) => enabledProviders.includes(provider)) ??
enabledProviders
const effectiveProviders =
activeProviders.length > 0 ? activeProviders : enabledProviders
const providerStatuses = useMemo(
() => providerHealthStatuses(logItems, enabledProviders),
[logItems, enabledProviders],
)
return (
<DetailPanel>
<DetailPanel.Header
title={overview.service.name}
description={`Маршрутизация: ${overview.routing_strategy}. Активные IP: ${
overview.active_addresses.join(', ') || '—'
}`}
actions={
<HealthCheckBadge status={overview.service.health_status} />
}
/>
<DetailPanel.Metrics
cards={[
{
id: 'subdomains',
icon: <GlobeIcon />,
label: 'Поддомены',
description:
domains.length > 0
? domains.map((d) => d.fqdn).join(', ')
: 'Нет привязанных FQDN',
footer: <Badge variant="outline">{domains.length}</Badge>,
},
{
id: 'nodes',
icon: <ServerIcon />,
label: 'Ноды',
description:
nodes.length > 0
? nodes.map((n) => n.address).join(', ')
: 'Добавьте ноду, чтобы публиковать DNS',
footer: <Badge variant="outline">{nodes.length}</Badge>,
},
{
id: 'health',
icon: <ActivityIcon />,
label: 'Пул',
description:
overview.active_addresses.length > 0
? 'Здоровые адреса участвуют в DNS'
: 'unknown не попадает в пул, пока не станет healthy',
},
]}
/>
{domains.length === 0 && nodes.length === 0 ? (
<QueryState
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => {
void viewQuery.refetch()
void overviewQuery.refetch()
}}
>
{!service ? (
<EmptyState
title="Пустой сервис"
description="Добавьте поддомен и ноду, затем настройте health-check."
stackedIcon
title="Сервис не найден"
description="Вернитесь в каталог и выберите сервис."
/>
) : null}
</DetailPanel>
) : (
<div className="@container flex w-full flex-col gap-4 md:gap-6">
<PageHeader
title={service.name}
description="Domain → Service → Node → Health → Failover"
actions={
<>
<LbModeTile mode={service.lb_mode} />
<HealthCheckBadge status={service.health_status} />
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Изменить"
onClick={() => setEditOpen(true)}
/>
}
>
<PencilIcon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>Изменить</TooltipContent>
</Tooltip>
</>
}
/>
<KpiStatGrid
cards={[
{
id: 'status',
icon: <ActivityIcon />,
label: 'Статус',
value: service.health_status === 'up' ? 'OK' : service.health_status,
variant:
service.health_status === 'down'
? 'destructive'
: service.health_status === 'degraded'
? 'warning'
: 'default',
iconClassName:
service.health_status === 'down'
? 'text-destructive'
: service.health_status === 'degraded'
? 'text-warning'
: 'text-success',
hint: <HealthCheckBadge status={service.health_status} size="xs" />,
},
{
id: 'fqdn',
icon: <GlobeIcon />,
label: 'FQDN',
value: String(service.domains.length),
hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN',
},
{
id: 'ip',
icon: <NetworkIcon />,
label: 'IP',
value: String(service.ips.length),
hint: `${service.active_ips.length} в пуле`,
},
{
id: 'pool',
icon: <ServerIcon />,
label: 'Активный пул',
value: String((overview?.active_addresses ?? service.active_ips).length),
hint: (overview?.active_addresses ?? service.active_ips).join(', ') || 'нет',
},
]}
/>
<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 { useState } from 'react'
import { useForm } from 'react-hook-form'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { PlusIcon } from 'lucide-react'
import { DetailPanel } from '@/components/reui-kit'
import { EmptyState } from '@/components/empty-state'
import { FormSheet } from '@/components/form-sheet'
import { FormFieldSimple } from '@/components/form-field'
import { LoadingButton } from '@/components/loading-button'
import { HealthCheckBadge } from '@/components/health-check-badge'
import { Button } from '@cfdm/ui/components/button'
import { Input } from '@cfdm/ui/components/input'
import { createServiceNode, deleteServiceNode, serviceNodesQueryOptions } from '@/queries'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({
component: ServiceNodesPage,
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
})
interface NodeRow {
id: number
address: string
port: number | null
protocol: string
health_status: 'up' | 'down' | 'degraded' | 'unknown' | 'healthy' | 'unhealthy' | 'checking' | 'disabled'
weight: number
priority: number
}
function mapHealth(
status: NodeRow['health_status'],
): 'up' | 'down' | 'degraded' | 'unknown' {
if (status === 'healthy' || status === 'up') return 'up'
if (status === 'unhealthy' || status === 'down') return 'down'
if (status === 'degraded') return 'degraded'
return 'unknown'
}
export function ServiceNodesPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const queryClient = useQueryClient()
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
const nodes = (nodesQuery.data ?? []) as NodeRow[]
const [open, setOpen] = useState(false)
const form = useForm<{ address: string; port: string }>({
defaultValues: { address: '', port: '' },
})
const createMut = useMutation({
mutationFn: (values: { address: string; port: string }) =>
createServiceNode(id, {
address: values.address.trim(),
port: values.port ? Number(values.port) : null,
}),
onSuccess: async () => {
toast.success('Нода добавлена, статус CHECKING')
await queryClient.invalidateQueries({ queryKey: ['services'] })
setOpen(false)
form.reset()
},
onError: (e: unknown) =>
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
})
const deleteMut = useMutation({
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
onSuccess: async () => {
toast.success('Нода удалена')
await queryClient.invalidateQueries({ queryKey: ['services'] })
},
})
return (
<DetailPanel>
<DetailPanel.Header
title="Ноды"
description="Адреса происхождения сервиса."
actions={
<Button size="sm" onClick={() => setOpen(true)}>
<PlusIcon className="size-4" aria-hidden />
Добавить ноду
</Button>
}
/>
{nodes.length === 0 ? (
<EmptyState
title="Нет нод"
description="Добавьте IP, затем настройте health-check."
/>
) : (
<div className="flex flex-col gap-2">
{nodes.map((node) => (
<div
key={node.id}
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
>
<div className="flex flex-col gap-1">
<span className="font-medium">{node.address}</span>
<span className="text-muted-foreground text-xs">
{node.protocol}
{node.port ? `:${node.port}` : ''} · вес {node.weight} · приоритет{' '}
{node.priority}
</span>
</div>
<div className="flex items-center gap-2">
<HealthCheckBadge status={mapHealth(node.health_status)} />
<Button
size="sm"
variant="outline"
onClick={() => deleteMut.mutate(node.id)}
>
Удалить
</Button>
</div>
</div>
))}
</div>
)}
<FormSheet
open={open}
onOpenChange={setOpen}
title="Добавить ноду"
description="IP станет CHECKING до порога успешных проверок."
form={form}
onSubmit={(values) => createMut.mutate(values)}
footer={
<LoadingButton type="submit" isLoading={createMut.isPending}>
Добавить
</LoadingButton>
}
>
<FormFieldSimple label="IP" htmlFor="address">
<Input id="address" {...form.register('address')} placeholder="10.0.0.10" />
</FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
<Input id="port" {...form.register('port')} placeholder="443" />
</FormFieldSimple>
</FormSheet>
</DetailPanel>
)
}
@@ -1,79 +1,30 @@
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowLeftIcon } from 'lucide-react'
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { Button } from '@cfdm/ui/components/button'
import { serviceOverviewQueryOptions } from '@/queries'
import { cn } from '@cfdm/ui/lib/utils'
import {
serviceHealthLogQueryOptions,
serviceNodesQueryOptions,
serviceOverviewQueryOptions,
serviceViewQueryOptions,
} from '@/queries'
export const Route = createFileRoute('/_auth/services/$serviceId')({
loader: ({ context: { queryClient }, params }) =>
queryClient.ensureQueryData(serviceOverviewQueryOptions(Number(params.serviceId))),
loader: async ({ context: { queryClient }, params }) => {
const id = Number(params.serviceId)
const [view] = await Promise.all([
queryClient.ensureQueryData(serviceViewQueryOptions(id)),
queryClient.ensureQueryData(serviceOverviewQueryOptions(id)),
queryClient.ensureQueryData(serviceHealthLogQueryOptions(id)),
queryClient.ensureQueryData(serviceNodesQueryOptions(id)),
])
return { breadcrumb: view.name }
},
component: ServiceLayout,
})
const tabs = [
{ to: '/services/$serviceId', label: 'Обзор', exact: true },
{ to: '/services/$serviceId/subdomains', label: 'Поддомены', exact: false },
{ to: '/services/$serviceId/nodes', label: 'Ноды', exact: false },
{ to: '/services/$serviceId/health', label: 'Health', exact: false },
{ to: '/services/$serviceId/routing', label: 'Маршрутизация', exact: false },
] as const
function ServiceLayout() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const pathname = useRouterState({ select: (s) => s.location.pathname })
const overview = useQuery(serviceOverviewQueryOptions(id))
const name = (overview.data as { service?: { name?: string } } | undefined)?.service?.name
return (
<PageShell>
<PageHeader
title={name ?? 'Сервис'}
description="Domain → Service → Node → Health → Failover"
actions={
<Button
variant="outline"
size="sm"
render={<Link to="/services" />}
>
<ArrowLeftIcon className="size-4" aria-hidden />
К каталогу
</Button>
}
/>
<nav className="flex flex-wrap gap-4 border-b">
{tabs.map((tab) => {
const href = tab.to.replace('$serviceId', serviceId)
const active = tab.exact
? pathname === `/services/${serviceId}` || pathname === `/services/${serviceId}/`
: pathname.startsWith(href)
return (
<Link
key={tab.to}
to={tab.to}
params={{ serviceId }}
className={cn(
'text-muted-foreground hover:text-foreground pb-3 text-sm font-medium',
active && 'text-foreground border-b-2 border-primary',
)}
>
{tab.label}
</Link>
)
})}
</nav>
<QueryState
isLoading={overview.isLoading}
isError={overview.isError}
error={overview.error}
onRetry={() => void overview.refetch()}
>
<Outlet />
</QueryState>
<Outlet />
</PageShell>
)
}
@@ -1,59 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { DetailPanel } from '@/components/reui-kit'
import { FailoverTimeline } from '@/components/failover-timeline'
import { Badge } from '@/components/reui/badge'
import { serviceOverviewQueryOptions } from '@/queries'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({
component: ServiceRoutingPage,
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
})
export function ServiceRoutingPage() {
const { serviceId } = Route.useParams()
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
const overview = data as {
routing_strategy: string
active_addresses: string[]
nodes: Array<{
address: string
health_status: string
consecutive_failures: number
last_failure_reason: string | null
}>
} | undefined
const events =
overview?.nodes
.filter(
(node) =>
node.health_status === 'unhealthy' ||
node.health_status === 'down' ||
node.health_status === 'checking',
)
.map((node) => ({
id: node.address,
title: `${node.address}: ${node.health_status}`,
detail: node.last_failure_reason
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
: `fail ${node.consecutive_failures}`,
})) ?? []
return (
<DetailPanel>
<DetailPanel.Header
title="Маршрутизация"
description="Round Robin / Failover. Weighted на DNS = alias Round Robin."
actions={<Badge variant="outline">{overview?.routing_strategy ?? 'round_robin'}</Badge>}
/>
<p className="text-sm">
Активные адреса:{' '}
{overview?.active_addresses.join(', ') || 'нет (unknown не в пуле)'}
</p>
<p className="text-muted-foreground text-xs">
Запись обновляется в Cloudflare. Распространение зависит от TTL.
</p>
<FailoverTimeline events={events} />
</DetailPanel>
)
}
@@ -1,114 +1,11 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { ArrowRightLeftIcon } from 'lucide-react'
import { DetailPanel } from '@/components/reui-kit'
import { EmptyState } from '@/components/empty-state'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@/components/reui/badge'
import { ChangeIpSheet } from '@/components/change-ip-sheet'
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
import { serviceOverviewQueryOptions } from '@/queries'
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({
component: ServiceSubdomainsPage,
beforeLoad: ({ params }) => {
throw redirect({
to: '/services/$serviceId',
params,
})
},
component: () => null,
})
export function ServiceSubdomainsPage() {
const { serviceId } = Route.useParams()
const id = Number(serviceId)
const { data } = useQuery(serviceOverviewQueryOptions(id))
const overview = data as {
service: {
domains: Array<{
binding_id: number
domain_id: number
fqdn: string
zone_name: string
target_ips: string[]
}>
}
} | undefined
const rows = overview?.service.domains ?? []
const [changeIp, setChangeIp] = useState<{
bindingId: number
ip?: string
} | null>(null)
const [changeDomain, setChangeDomain] = useState(false)
const fromDomainId = useMemo(
() => rows[0]?.domain_id ?? null,
[rows],
)
return (
<DetailPanel>
<DetailPanel.Header
title="Поддомены"
description="FQDN сервиса в одной или нескольких зонах Cloudflare."
actions={
<Button
variant="outline"
size="sm"
onClick={() => setChangeDomain(true)}
disabled={rows.length === 0}
>
<ArrowRightLeftIcon className="size-4" aria-hidden />
Сменить домен
</Button>
}
/>
{rows.length === 0 ? (
<EmptyState
title="Нет поддоменов"
description="Привяжите FQDN к сервису из карточки редактирования."
/>
) : (
<div className="flex flex-col gap-2">
{rows.map((row) => (
<div
key={row.binding_id}
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
>
<div className="flex min-w-0 flex-col gap-1">
<span className="font-medium">{row.fqdn}</span>
<span className="text-muted-foreground text-xs">
{row.zone_name} · {row.target_ips.join(', ') || 'нет IP'}
</span>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{row.target_ips.length} IP</Badge>
<Button
size="sm"
variant="outline"
onClick={() =>
setChangeIp({
bindingId: row.binding_id,
ip: row.target_ips[0],
})
}
>
Сменить IP
</Button>
</div>
</div>
))}
</div>
)}
<ChangeIpSheet
open={changeIp != null}
onOpenChange={(open) => {
if (!open) setChangeIp(null)
}}
bindingId={changeIp?.bindingId ?? null}
serviceId={id}
currentIp={changeIp?.ip}
/>
<ChangeDomainSheet
open={changeDomain}
onOpenChange={setChangeDomain}
serviceId={id}
fromDomainId={fromDomainId}
/>
</DetailPanel>
)
}
+26
View File
@@ -176,6 +176,8 @@ interface ServiceView$1 {
health_latency_ms: number | null;
ip_health: ServiceIpHealth$1[];
ip_enabled: Record<string, boolean>;
lb_mode: LbMode;
active_ips: string[];
}
interface SyncJob {
id: string;
@@ -824,6 +826,12 @@ declare const serviceViewSchema: z.ZodObject<{
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>;
declare const serviceGroupViewSchema: z.ZodObject<{
id: z.ZodNumber;
@@ -1013,6 +1021,12 @@ declare const serviceGroupViewSchema: z.ZodObject<{
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>>;
health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown";
@@ -1211,6 +1225,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>>;
health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown";
@@ -1360,6 +1380,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>>;
}, z.core.$strip>;
declare const domainSchema: z.ZodObject<{
+3 -1
View File
@@ -409,7 +409,9 @@ var serviceViewSchema = serviceSchema.extend({
health_status: ipHealthStateSchema.default("unknown"),
health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]),
ip_enabled: z.record(z.string(), z.boolean()).default({})
ip_enabled: z.record(z.string(), z.boolean()).default({}),
lb_mode: lbModeSchema.catch("round_robin"),
active_ips: z.array(z.string()).default([])
});
var serviceGroupViewSchema = serviceGroupSchema.extend({
services: z.array(serviceViewSchema).default([]),
+2
View File
@@ -205,6 +205,8 @@ export const serviceViewSchema = serviceSchema.extend({
health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]),
ip_enabled: z.record(z.string(), z.boolean()).default({}),
lb_mode: lbModeSchema.catch('round_robin'),
active_ips: z.array(z.string()).default([]),
})
export const serviceGroupViewSchema = serviceGroupSchema.extend({
+2
View File
@@ -222,6 +222,8 @@ export interface ServiceView {
health_latency_ms: number | null;
ip_health: ServiceIpHealth[];
ip_enabled: Record<string, boolean>;
lb_mode: LbMode;
active_ips: string[];
}
export interface GroupWithStats extends Group {