Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba03d2be9d | ||
|
|
d8fc4ac949 | ||
|
|
d063323402 | ||
|
|
6bced71037 | ||
|
|
44d0eb0114 | ||
|
|
9f00dfcf84 | ||
|
|
9b9dcc3b12 | ||
|
|
6008cd763a |
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
@@ -106,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
@@ -106,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ alwaysApply: false
|
|||||||
|
|
||||||
---
|
---
|
||||||
name: reui
|
name: reui
|
||||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||||
user-invocable: false
|
user-invocable: false
|
||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||||
|
|
||||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||||
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
|||||||
|
|
||||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
@@ -106,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -38,14 +38,11 @@ jobs:
|
|||||||
api: ${{ steps.detect.outputs.api }}
|
api: ${{ steps.detect.outputs.api }}
|
||||||
docker: ${{ steps.detect.outputs.docker }}
|
docker: ${{ steps.detect.outputs.docker }}
|
||||||
steps:
|
steps:
|
||||||
- if: ${{ inputs.is_pull_request }}
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
with:
|
||||||
|
# Push may contain several commits; github.event.before is then
|
||||||
|
# more than one parent away. fetch-depth: 2 only has HEAD~1.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- if: ${{ inputs.is_pull_request == false }}
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
- id: detect
|
- id: detect
|
||||||
name: Detect changed paths per module
|
name: Detect changed paths per module
|
||||||
env:
|
env:
|
||||||
@@ -72,12 +69,16 @@ jobs:
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
has_commit() {
|
||||||
|
git cat-file -e "${1}^{commit}" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
if [ "$IS_PR" = "true" ]; then
|
if [ "$IS_PR" = "true" ]; then
|
||||||
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
|
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
|
||||||
else
|
else
|
||||||
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||||
before="$BEFORE_SHA"
|
before="$BEFORE_SHA"
|
||||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ] && has_commit "$before"; then
|
||||||
FILES="$(git diff --name-only "$before" "$after")"
|
FILES="$(git diff --name-only "$before" "$after")"
|
||||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ user-invocable: false
|
|||||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||||
---
|
---
|
||||||
|
|
||||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||||
|
|
||||||
# ReUI for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||||
|
|
||||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||||
|
|
||||||
@@ -106,22 +106,60 @@ Common mistakes:
|
|||||||
|
|
||||||
## filters
|
## filters
|
||||||
|
|
||||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||||
**Shape:**
|
**Shape:**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const [filters, setFilters] = useState<Filter[]>([
|
const fields: FilterField[] = [
|
||||||
createFilter("priority", "is_any_of", ["low"]),
|
{ id: "title", label: "Title", type: "text" },
|
||||||
])
|
{
|
||||||
const fields: FilterFieldConfig[] = [
|
id: "status",
|
||||||
{ key: "priority", label: "Priority", type: "multiselect",
|
label: "Status",
|
||||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
type: "select",
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: "Active" },
|
||||||
|
{ value: "archived", label: "Archived" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||||
|
|
||||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||||
|
|
||||||
|
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||||
|
|
||||||
|
## cascader
|
||||||
|
|
||||||
|
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||||
|
<CascaderTrigger render={<Button variant="outline" />}>
|
||||||
|
<CascaderValue placeholder="Select an attribute" />
|
||||||
|
</CascaderTrigger>
|
||||||
|
<CascaderContent className="w-80">
|
||||||
|
<CascaderPanel>
|
||||||
|
<CascaderNav>
|
||||||
|
<CascaderBreadcrumb />
|
||||||
|
<CascaderInput />
|
||||||
|
</CascaderNav>
|
||||||
|
<CascaderEmpty />
|
||||||
|
<CascaderList maxHeight={288}>
|
||||||
|
<CascaderItems />
|
||||||
|
</CascaderList>
|
||||||
|
<CascaderStatus />
|
||||||
|
</CascaderPanel>
|
||||||
|
</CascaderContent>
|
||||||
|
</Cascader>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||||
|
|
||||||
|
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||||
|
|
||||||
## date-selector
|
## date-selector
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,11 @@ export async function serviceRoutes(app: FastifyInstance) {
|
|||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
repos.getService(request.server.db, Number(id));
|
repos.getService(request.server.db, Number(id));
|
||||||
return {
|
return {
|
||||||
items: repos.listHealthProbeLogForService(request.server.db, Number(id)),
|
items: repos.listHealthProbeLogForService(
|
||||||
|
request.server.db,
|
||||||
|
Number(id),
|
||||||
|
200,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
id: service.id,
|
id: service.id,
|
||||||
name: service.name,
|
name: service.name,
|
||||||
@@ -330,6 +338,8 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
|||||||
health_status: "unknown",
|
health_status: "unknown",
|
||||||
health_latency_ms: null,
|
health_latency_ms: null,
|
||||||
ip_health: [],
|
ip_health: [],
|
||||||
|
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
|
||||||
|
active_ips: [...activeIps],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,11 +123,12 @@ describe("create service then list groups", () => {
|
|||||||
expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe(
|
expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(
|
const listed = httpParsed.data!.groups
|
||||||
httpParsed.data!.groups
|
.find((g) => g.id === group.id)
|
||||||
.find((g) => g.id === group.id)
|
?.services.find((s) => s.id === created.id);
|
||||||
?.services.some((s) => s.id === created.id),
|
expect(listed).toBeDefined();
|
||||||
).toBe(true);
|
expect(listed?.lb_mode).toBe("round_robin");
|
||||||
|
expect(listed?.active_ips).toEqual(["1.2.3.4"]);
|
||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import {
|
|||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { ButtonGroup } from '@cfdm/ui/components/button-group'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
import { CableIcon, GlobeIcon } from 'lucide-react'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
import {
|
import {
|
||||||
HealthAggregateTiles,
|
HealthAggregateTiles,
|
||||||
@@ -123,9 +123,6 @@ export function HealthCheckConfigFields({
|
|||||||
const aggregate = value.aggregate ?? 'majority'
|
const aggregate = value.aggregate ?? 'majority'
|
||||||
const isHttp = value.type === 'http'
|
const isHttp = value.type === 'http'
|
||||||
const rowClass = 'gap-3 px-0 py-3'
|
const rowClass = 'gap-3 px-0 py-3'
|
||||||
const hasCloudflare = providers.includes('cloudflare')
|
|
||||||
const hasGlobalping = providers.includes('globalping')
|
|
||||||
const hasLocal = providers.includes('local')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldGroup className={cn('gap-0', className)}>
|
<FieldGroup className={cn('gap-0', className)}>
|
||||||
@@ -163,6 +160,7 @@ export function HealthCheckConfigFields({
|
|||||||
compact
|
compact
|
||||||
stacked
|
stacked
|
||||||
className={rowClass}
|
className={rowClass}
|
||||||
|
contentClassName="min-w-0"
|
||||||
>
|
>
|
||||||
<HealthSourceTiles
|
<HealthSourceTiles
|
||||||
value={providers}
|
value={providers}
|
||||||
@@ -176,46 +174,6 @@ export function HealthCheckConfigFields({
|
|||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
{hasCloudflare ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertTitle>Cloudflare Worker</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Проба с edge Cloudflare, не продукт Health Checks API (на Free его нет).
|
|
||||||
Worker создаётся автоматически и сам опрашивает IP (KV mailbox).
|
|
||||||
Статус деплоя — в{' '}
|
|
||||||
<Link to="/settings/health" className="text-foreground underline">
|
|
||||||
Настройках → Health-check
|
|
||||||
</Link>
|
|
||||||
. Если Worker не создан, этот источник не пробируется как Local.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
{hasGlobalping ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertTitle>Globalping</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Пробы из сети globalping.io (TCP ping / HTTP). Токен, локации и лимит —
|
|
||||||
в{' '}
|
|
||||||
<Link to="/settings/health" className="text-foreground underline">
|
|
||||||
Настройках → Health-check
|
|
||||||
</Link>
|
|
||||||
. Без токена или при 429 этот источник = fail, без fallback на Local.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
{hasLocal ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertTitle>Local health-check</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Проба TCP/HTTP с сервера API. Cron и пороги Slow/Down — в{' '}
|
|
||||||
<Link to="/settings/health" className="text-foreground underline">
|
|
||||||
Настройках → Health-check
|
|
||||||
</Link>
|
|
||||||
. Интервал в карточке не используется.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{providers.length > 1 ? (
|
{providers.length > 1 ? (
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Агрегация"
|
title="Агрегация"
|
||||||
@@ -223,6 +181,7 @@ export function HealthCheckConfigFields({
|
|||||||
compact
|
compact
|
||||||
stacked
|
stacked
|
||||||
className={rowClass}
|
className={rowClass}
|
||||||
|
contentClassName="min-w-0"
|
||||||
>
|
>
|
||||||
<HealthAggregateTiles
|
<HealthAggregateTiles
|
||||||
value={aggregate}
|
value={aggregate}
|
||||||
@@ -258,25 +217,30 @@ export function HealthCheckConfigFields({
|
|||||||
<div className="flex flex-col gap-3 pt-1 pb-1">
|
<div className="flex flex-col gap-3 pt-1 pb-1">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
|
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
|
||||||
<ToggleGroup
|
<ButtonGroup id={`${idPrefix}-type`} className="w-full min-w-0">
|
||||||
id={`${idPrefix}-type`}
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
className="w-full"
|
size="sm"
|
||||||
value={[value.type]}
|
variant={value.type === 'tcp' ? 'secondary' : 'outline'}
|
||||||
onValueChange={(next) => {
|
className="flex-1"
|
||||||
const picked = next[0]
|
aria-pressed={value.type === 'tcp'}
|
||||||
if (picked === 'tcp' || picked === 'http') {
|
onClick={() => patch({ type: 'tcp' })}
|
||||||
patch({ type: picked })
|
>
|
||||||
}
|
<CableIcon data-icon="inline-start" />
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ToggleGroupItem value="tcp" className="flex-1">
|
|
||||||
TCP
|
TCP
|
||||||
</ToggleGroupItem>
|
</Button>
|
||||||
<ToggleGroupItem value="http" className="flex-1">
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={value.type === 'http' ? 'secondary' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
aria-pressed={value.type === 'http'}
|
||||||
|
onClick={() => patch({ type: 'http' })}
|
||||||
|
>
|
||||||
|
<GlobeIcon data-icon="inline-start" />
|
||||||
HTTP
|
HTTP
|
||||||
</ToggleGroupItem>
|
</Button>
|
||||||
</ToggleGroup>
|
</ButtonGroup>
|
||||||
</FormFieldSimple>
|
</FormFieldSimple>
|
||||||
|
|
||||||
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
|
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import type { KeyboardEvent, ReactNode } from 'react'
|
import type { KeyboardEvent, ReactNode } from 'react'
|
||||||
import { CheckIcon, GlobeIcon, ServerIcon, CloudIcon, LayersIcon, ShieldAlertIcon, ScaleIcon } from 'lucide-react'
|
import { ServerIcon, LayersIcon, ShieldAlertIcon, ScaleIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
import { IconTile } from '@/components/reui/icon-tile'
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@cfdm/ui/components/item'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
|
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
|
||||||
@@ -11,6 +20,34 @@ export type HealthAggregate = HealthCheckAggregate
|
|||||||
|
|
||||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||||
|
|
||||||
|
/** Official Cloudflare mark (Simple Icons). */
|
||||||
|
function CloudflareMark() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Official Globalping mark (globalping.io favicon). */
|
||||||
|
function GlobalpingMark() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 26 26" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M10.354 6.081a2.636 2.636 0 0 1 4.32.257 31.5 31.5 0 0 1 8.55-1.349A12.97 12.97 0 0 0 3.247 4.42a33.6 33.6 0 0 1 7.107 1.661m13.647.003-.764.02a30.6 30.6 0 0 0-8.235 1.3q.005.08 0 .16c0 .6-.222 1.178-.624 1.624a42 42 0 0 1 2.382 3.695q.478.835.884 1.645h.134c.348-.003.689.102.975.302q3.286-2.798 5.83-7.517l.052-.094m-11.16 2.616a2.65 2.65 0 0 1-2.997-.644c-3.276 1.658-6.702 4.284-9.022 8.45 1.579.6 3.232.982 4.914 1.135a1.566 1.566 0 0 1 2.811.026h.14a16 16 0 0 0 6.935-2.188 1.5 1.5 0 0 1-.097-.52 1.53 1.53 0 0 1 .475-1.105 39 39 0 0 0-3.16-5.154Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M18.866 24.463c.28-2.216-.22-4.582-1.06-6.825h-.019a1.66 1.66 0 0 1-.812-.211 17 17 0 0 1-7.622 2.467h-.14a1.563 1.563 0 0 1-2.902-.026 19 19 0 0 1-4.94-1.086 13 13 0 0 0 17.479 5.801zm6.324-15.97a26.5 26.5 0 0 1-5.82 7.218 1.4 1.4 0 0 1 .049.374 1.55 1.55 0 0 1-.56 1.18c.907 2.417 1.3 4.644 1.174 6.659a12.98 12.98 0 0 0 5.158-15.431Zm-15.258-.28a2.35 2.35 0 0 1-.045-1.108 33 33 0 0 0-7.46-1.658 12.97 12.97 0 0 0-1.829 11.45c2.106-3.682 5.268-6.627 9.334-8.684"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const PROVIDER_ITEMS: Array<{
|
const PROVIDER_ITEMS: Array<{
|
||||||
id: HealthProvider
|
id: HealthProvider
|
||||||
title: string
|
title: string
|
||||||
@@ -29,14 +66,14 @@ const PROVIDER_ITEMS: Array<{
|
|||||||
id: 'cloudflare',
|
id: 'cloudflare',
|
||||||
title: 'Cloudflare',
|
title: 'Cloudflare',
|
||||||
description: 'Worker на edge, KV mailbox',
|
description: 'Worker на edge, KV mailbox',
|
||||||
icon: <CloudIcon />,
|
icon: <CloudflareMark />,
|
||||||
iconClassName: 'text-warning [&_svg]:text-current',
|
iconClassName: 'text-warning [&_svg]:text-current',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'globalping',
|
id: 'globalping',
|
||||||
title: 'Globalping',
|
title: 'Globalping',
|
||||||
description: 'Пробы из сети globalping.io',
|
description: 'Пробы из сети globalping.io',
|
||||||
icon: <GlobeIcon />,
|
icon: <GlobalpingMark />,
|
||||||
iconClassName: 'text-success [&_svg]:text-current',
|
iconClassName: 'text-success [&_svg]:text-current',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -74,7 +111,7 @@ function handleTileKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function TilePanel({
|
function ChoicePanel({
|
||||||
selected,
|
selected,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -93,43 +130,56 @@ function TilePanel({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<FramePanel
|
<FramePanel
|
||||||
|
fit
|
||||||
role={role}
|
role={role}
|
||||||
aria-checked={selected}
|
aria-checked={selected}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative isolate flex h-full cursor-pointer flex-col p-3 transition-colors',
|
'min-w-0 cursor-pointer transition-colors',
|
||||||
'hover:bg-muted/40 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
|
'hover:bg-muted/40 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
|
||||||
selected && 'ring-ring ring-1',
|
selected && 'bg-muted/40',
|
||||||
)}
|
)}
|
||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
onKeyDown={(event) => handleTileKeyDown(onActivate, event)}
|
onKeyDown={(event) => handleTileKeyDown(onActivate, event)}
|
||||||
>
|
>
|
||||||
<div className="relative z-10 flex h-full items-start gap-3">
|
<Item size="sm" className="w-full min-w-0 border-0 p-0">
|
||||||
<IconTile
|
<ItemMedia>
|
||||||
variant="elevated"
|
<IconTile
|
||||||
aria-hidden="true"
|
variant="elevated"
|
||||||
className={cn('size-10.5', iconClassName ?? DEFAULT_ICON_CLASS)}
|
aria-hidden="true"
|
||||||
>
|
className={cn('size-10.5', iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
{icon}
|
>
|
||||||
</IconTile>
|
{icon}
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
</IconTile>
|
||||||
<div className="flex items-start justify-between gap-2">
|
</ItemMedia>
|
||||||
<span className="text-foreground text-sm font-medium">{title}</span>
|
<ItemContent className="min-w-0 gap-0.5">
|
||||||
{selected ? (
|
<ItemTitle className="w-full min-w-0">{title}</ItemTitle>
|
||||||
<CheckIcon className="text-foreground size-4 shrink-0" aria-hidden />
|
<ItemDescription>{description}</ItemDescription>
|
||||||
) : null}
|
</ItemContent>
|
||||||
</div>
|
{selected ? (
|
||||||
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
|
<ItemActions className="shrink-0">
|
||||||
</div>
|
<Badge variant="outline" size="sm">
|
||||||
</div>
|
Выбрано
|
||||||
|
</Badge>
|
||||||
|
</ItemActions>
|
||||||
|
) : null}
|
||||||
|
</Item>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChoiceFrame({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Frame stacked spacing="sm" className="w-full min-w-0">
|
||||||
|
{children}
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Мультивыбор источников проб (Local / Cloudflare / Globalping).
|
* Мультивыбор источников проб (Local / Cloudflare / Globalping).
|
||||||
* Preview: https://reui.io/preview/base/card-12
|
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12
|
||||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
||||||
*/
|
*/
|
||||||
export function HealthSourceTiles({
|
export function HealthSourceTiles({
|
||||||
@@ -151,28 +201,26 @@ export function HealthSourceTiles({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className="@container w-full">
|
<ChoiceFrame>
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
{PROVIDER_ITEMS.map((item) => (
|
||||||
{PROVIDER_ITEMS.map((item) => (
|
<ChoicePanel
|
||||||
<TilePanel
|
key={item.id}
|
||||||
key={item.id}
|
selected={selected.includes(item.id)}
|
||||||
selected={selected.includes(item.id)}
|
title={item.title}
|
||||||
title={item.title}
|
description={item.description}
|
||||||
description={item.description}
|
icon={item.icon}
|
||||||
icon={item.icon}
|
iconClassName={item.iconClassName}
|
||||||
iconClassName={item.iconClassName}
|
role="checkbox"
|
||||||
role="checkbox"
|
onActivate={() => toggle(item.id)}
|
||||||
onActivate={() => toggle(item.id)}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
</ChoiceFrame>
|
||||||
</div>
|
|
||||||
</Frame>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Правило агрегации (ровно одно): any / all / majority.
|
* Правило агрегации (ровно одно): any / all / majority.
|
||||||
* Preview: https://reui.io/preview/base/card-12 · https://reui.io/preview/base/settings-5
|
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/settings-5
|
||||||
*/
|
*/
|
||||||
export function HealthAggregateTiles({
|
export function HealthAggregateTiles({
|
||||||
value,
|
value,
|
||||||
@@ -183,20 +231,18 @@ export function HealthAggregateTiles({
|
|||||||
}) {
|
}) {
|
||||||
const selected = value || 'majority'
|
const selected = value || 'majority'
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className="@container w-full">
|
<ChoiceFrame>
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
{AGGREGATE_ITEMS.map((item) => (
|
||||||
{AGGREGATE_ITEMS.map((item) => (
|
<ChoicePanel
|
||||||
<TilePanel
|
key={item.id}
|
||||||
key={item.id}
|
selected={selected === item.id}
|
||||||
selected={selected === item.id}
|
title={item.title}
|
||||||
title={item.title}
|
description={item.description}
|
||||||
description={item.description}
|
icon={item.icon}
|
||||||
icon={item.icon}
|
role="radio"
|
||||||
role="radio"
|
onActivate={() => onChange(item.id)}
|
||||||
onActivate={() => onChange(item.id)}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
</ChoiceFrame>
|
||||||
</div>
|
|
||||||
</Frame>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { UptimeChart, type UptimeProbe } from './uptime-chart'
|
||||||
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
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 { formatDate, sqliteUtcToIso } 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'
|
||||||
|
|
||||||
|
const 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 probeTime(checkedAt: string): number {
|
||||||
|
const iso = sqliteUtcToIso(checkedAt) ?? checkedAt
|
||||||
|
const time = new Date(iso).getTime()
|
||||||
|
return Number.isNaN(time) ? 0 : time
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByPeriod(items: UptimeProbe[], days: number): UptimeProbe[] {
|
||||||
|
const cutoff = Date.now() - days * 86_400_000
|
||||||
|
return items.filter((item) => probeTime(item.checked_at) >= cutoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UptimeChart({ items, isLoading = false }: UptimeChartProps) {
|
||||||
|
const gradientId = useId().replace(/:/g, '')
|
||||||
|
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
|
||||||
|
const days = PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||||
|
|
||||||
|
const points = useMemo(
|
||||||
|
() => toSeries(filterByPeriod(items, days)),
|
||||||
|
[items, days],
|
||||||
|
)
|
||||||
|
const uptime = uptimePercent(points)
|
||||||
|
const delta = deltaPercent(points)
|
||||||
|
const lastOk = points.at(-1)?.ok ?? true
|
||||||
|
const tileClass = lastOk ? 'text-success' : 'text-destructive'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame spacing="sm" className="min-w-0 w-full">
|
||||||
|
<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) => setPeriod(value as UptimePeriodKey)}
|
||||||
|
>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
{PERIODS.map((entry) => (
|
||||||
|
<TabsTrigger key={entry.key} value={entry.key} className="flex-1">
|
||||||
|
{entry.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</FramePanel>
|
||||||
|
</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 { toast } from 'sonner'
|
||||||
|
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { TruncatedText } from '@/components/truncated-text'
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
||||||
import type { ServiceView } from '@/lib/schemas'
|
import type { ServiceView } from '@/lib/schemas'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemGroup,
|
||||||
|
ItemMedia,
|
||||||
|
} from '@cfdm/ui/components/item'
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -17,6 +25,9 @@ import {
|
|||||||
} from '@cfdm/ui/components/tooltip'
|
} from '@cfdm/ui/components/tooltip'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
/** Matches `Button size="icon-sm"` so Switch columns align with the card menu. */
|
||||||
|
const MENU_SLOT_CLASS = 'size-7 shrink-0'
|
||||||
|
|
||||||
export function CopyFqdnButton({
|
export function CopyFqdnButton({
|
||||||
value,
|
value,
|
||||||
className,
|
className,
|
||||||
@@ -126,6 +137,11 @@ interface ServiceIpListProps {
|
|||||||
togglingIp?: string | null
|
togglingIp?: string | null
|
||||||
ipToggleDisabled?: boolean
|
ipToggleDisabled?: boolean
|
||||||
onToggleIp?: (ip: string, enabled: boolean) => void
|
onToggleIp?: (ip: string, enabled: boolean) => void
|
||||||
|
/** Invisible icon-sm slot so IP Switch lines up with the card overflow menu. */
|
||||||
|
alignWithMenu?: boolean
|
||||||
|
lbMode?: ServiceView['lb_mode']
|
||||||
|
activeIps?: string[]
|
||||||
|
ipWeights?: Record<string, number>
|
||||||
className?: string
|
className?: string
|
||||||
emptyLabel?: string
|
emptyLabel?: string
|
||||||
copyable?: boolean
|
copyable?: boolean
|
||||||
@@ -139,6 +155,10 @@ export function ServiceIpList({
|
|||||||
togglingIp = null,
|
togglingIp = null,
|
||||||
ipToggleDisabled = false,
|
ipToggleDisabled = false,
|
||||||
onToggleIp,
|
onToggleIp,
|
||||||
|
alignWithMenu = false,
|
||||||
|
lbMode,
|
||||||
|
activeIps = [],
|
||||||
|
ipWeights = {},
|
||||||
className,
|
className,
|
||||||
emptyLabel = 'Нет IP',
|
emptyLabel = 'Нет IP',
|
||||||
copyable = false,
|
copyable = false,
|
||||||
@@ -155,49 +175,83 @@ export function ServiceIpList({
|
|||||||
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
|
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
|
||||||
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
|
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
|
||||||
const extraCount = ips.length - visible.length
|
const extraCount = ips.length - visible.length
|
||||||
|
const showMenuSlot = Boolean(onToggleIp && alignWithMenu)
|
||||||
|
const markActive = lbMode === 'failover' || lbMode === 'weighted'
|
||||||
|
const activeSet = new Set(activeIps)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex min-w-0 flex-col gap-1', className)}>
|
<ItemGroup className={cn('gap-1', className)}>
|
||||||
{visible.map((ip) => {
|
{visible.map((ip) => {
|
||||||
const health = healthByIp.get(ip)
|
const health = healthByIp.get(ip)
|
||||||
const enabled = ipEnabled[ip] !== false
|
const enabled = ipEnabled[ip] !== false
|
||||||
return (
|
return (
|
||||||
<div key={ip} className="flex min-w-0 items-center gap-1.5">
|
<Item
|
||||||
<HealthCheckBadge
|
key={ip}
|
||||||
status={health?.status ?? 'unknown'}
|
size="sm"
|
||||||
latencyMs={health?.latency_ms}
|
className="w-full min-w-0 flex-nowrap border-0 p-0"
|
||||||
lastCheckedAt={health?.last_checked_at}
|
>
|
||||||
lastError={health?.last_error}
|
<ItemMedia>
|
||||||
colo={health?.colo}
|
<HealthCheckBadge
|
||||||
provider={health?.provider}
|
status={health?.status ?? 'unknown'}
|
||||||
size="xs"
|
latencyMs={health?.latency_ms}
|
||||||
/>
|
lastCheckedAt={health?.last_checked_at}
|
||||||
<TruncatedText
|
lastError={health?.last_error}
|
||||||
className={cn(
|
colo={health?.colo}
|
||||||
'min-w-0 font-mono text-xs',
|
provider={health?.provider}
|
||||||
enabled ? 'text-muted-foreground' : 'text-muted-foreground/60',
|
size="xs"
|
||||||
textClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{ip}
|
|
||||||
</TruncatedText>
|
|
||||||
{copyable ? <CopyFqdnButton value={ip} /> : null}
|
|
||||||
{onToggleIp ? (
|
|
||||||
<Switch
|
|
||||||
size="sm"
|
|
||||||
className="ml-auto shrink-0"
|
|
||||||
checked={enabled}
|
|
||||||
disabled={ipToggleDisabled || togglingIp === ip}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onCheckedChange={(checked) => onToggleIp(ip, Boolean(checked))}
|
|
||||||
aria-label={
|
|
||||||
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent className="min-w-0 gap-0">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<TruncatedText
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 font-mono text-xs',
|
||||||
|
enabled
|
||||||
|
? 'text-muted-foreground'
|
||||||
|
: 'text-muted-foreground/60',
|
||||||
|
textClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ip}
|
||||||
|
</TruncatedText>
|
||||||
|
{copyable ? <CopyFqdnButton value={ip} /> : null}
|
||||||
|
{markActive && activeSet.has(ip) ? (
|
||||||
|
<StatusBadge status="active" className="shrink-0" />
|
||||||
|
) : null}
|
||||||
|
{lbMode === 'weighted' ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="shrink-0 tabular-nums"
|
||||||
|
>
|
||||||
|
w{ipWeights[ip] ?? 1}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ItemContent>
|
||||||
|
{onToggleIp ? (
|
||||||
|
<ItemActions className="ml-auto shrink-0 gap-1">
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
checked={enabled}
|
||||||
|
disabled={ipToggleDisabled || togglingIp === ip}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onToggleIp(ip, Boolean(checked))
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{showMenuSlot ? (
|
||||||
|
<span className={MENU_SLOT_CLASS} aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
</ItemActions>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</Item>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{extraCount > 0 ? (
|
{extraCount > 0 ? (
|
||||||
@@ -224,6 +278,6 @@ export function ServiceIpList({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</ItemGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
|
import {
|
||||||
|
GitForkIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
Repeat2Icon,
|
||||||
|
ScaleIcon,
|
||||||
|
ServerIcon,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
@@ -23,6 +29,12 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@cfdm/ui/components/dropdown-menu'
|
} from '@cfdm/ui/components/dropdown-menu'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemMedia,
|
||||||
|
} from '@cfdm/ui/components/item'
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -30,6 +42,63 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@cfdm/ui/components/tooltip'
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact service card — settings-8 DNA (Badge + copy + Switch + menu).
|
||||||
|
* Preview: https://reui.io/preview/base/settings-8
|
||||||
|
* Frame: https://reui.io/docs/components/base/frame
|
||||||
|
* IconTile: https://reui.io/docs/components/base/icon-tile
|
||||||
|
* Header fill: FramePanel `bg-muted` (overrides `--frame-panel-bg`; see frame.tsx).
|
||||||
|
*/
|
||||||
|
|
||||||
|
type LbMode = ServiceView['lb_mode']
|
||||||
|
|
||||||
|
const LB_MODE_META: Record<
|
||||||
|
LbMode,
|
||||||
|
{ icon: LucideIcon; className: string; label: string }
|
||||||
|
> = {
|
||||||
|
round_robin: {
|
||||||
|
icon: Repeat2Icon,
|
||||||
|
className: 'text-info',
|
||||||
|
label: 'Round Robin',
|
||||||
|
},
|
||||||
|
failover: {
|
||||||
|
icon: GitForkIcon,
|
||||||
|
className: 'text-warning',
|
||||||
|
label: 'Failover (приоритет)',
|
||||||
|
},
|
||||||
|
weighted: {
|
||||||
|
icon: ScaleIcon,
|
||||||
|
className: 'text-info',
|
||||||
|
label: 'Weighted (веса)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LbModeTile({ mode }: { mode: LbMode }) {
|
||||||
|
const meta = LB_MODE_META[mode]
|
||||||
|
const Icon = meta.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="xs"
|
||||||
|
className={cn('shrink-0', meta.className)}
|
||||||
|
aria-label={meta.label}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{meta.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface ServiceUnitCardProps {
|
interface ServiceUnitCardProps {
|
||||||
service: ServiceView
|
service: ServiceView
|
||||||
@@ -55,27 +124,32 @@ export function ServiceUnitCard({
|
|||||||
const extraCount = Math.max(0, fqdns.length - 1)
|
const extraCount = Math.max(0, fqdns.length - 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden">
|
<Frame stacked spacing="sm" className="h-full min-w-0">
|
||||||
<FrameHeader className="flex-row items-center justify-between gap-2">
|
<FramePanel fit className="bg-muted">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<Item size="sm" className="w-full min-w-0 flex-nowrap border-0 p-0">
|
||||||
<IconTile
|
<ItemMedia>
|
||||||
variant="elevated"
|
<IconTile
|
||||||
size="sm"
|
variant="elevated"
|
||||||
className="text-muted-foreground"
|
size="sm"
|
||||||
aria-hidden="true"
|
className="text-foreground"
|
||||||
>
|
aria-hidden="true"
|
||||||
<ServerIcon />
|
>
|
||||||
</IconTile>
|
<ServerIcon />
|
||||||
<div className="flex min-w-0 flex-col gap-px">
|
</IconTile>
|
||||||
<FrameTitle className="min-w-0 truncate text-sm">
|
</ItemMedia>
|
||||||
<Link
|
<ItemContent className="min-w-0 gap-px">
|
||||||
to="/services/$serviceId"
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
params={{ serviceId: String(service.id) }}
|
<FrameTitle className="min-w-0 truncate text-base font-semibold">
|
||||||
className="hover:underline"
|
<Link
|
||||||
>
|
to="/services/$serviceId"
|
||||||
{service.name}
|
params={{ serviceId: String(service.id) }}
|
||||||
</Link>
|
className="hover:underline"
|
||||||
</FrameTitle>
|
>
|
||||||
|
{service.name}
|
||||||
|
</Link>
|
||||||
|
</FrameTitle>
|
||||||
|
<LbModeTile mode={service.lb_mode} />
|
||||||
|
</div>
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
||||||
{primaryDomain}
|
{primaryDomain}
|
||||||
@@ -108,66 +182,70 @@ export function ServiceUnitCard({
|
|||||||
<CopyFqdnButton value={fqdns.join('\n')} />
|
<CopyFqdnButton value={fqdns.join('\n')} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ItemContent>
|
||||||
</div>
|
<ItemActions className="ml-auto shrink-0 gap-1">
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<Switch
|
||||||
<Switch
|
size="sm"
|
||||||
size="sm"
|
checked={service.enabled}
|
||||||
checked={service.enabled}
|
disabled={togglingId === service.id}
|
||||||
disabled={togglingId === service.id}
|
onCheckedChange={(checked) =>
|
||||||
onCheckedChange={(checked) =>
|
onToggleService(service.id, Boolean(checked))
|
||||||
onToggleService(service.id, Boolean(checked))
|
|
||||||
}
|
|
||||||
aria-label={
|
|
||||||
service.enabled ? 'Выключить сервис' : 'Включить сервис'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Действия ${service.name}`}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
>
|
aria-label={
|
||||||
<MoreHorizontalIcon aria-hidden />
|
service.enabled ? 'Выключить сервис' : 'Включить сервис'
|
||||||
</DropdownMenuTrigger>
|
}
|
||||||
<DropdownMenuContent align="end">
|
/>
|
||||||
<DropdownMenuItem
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
render={
|
render={
|
||||||
<Link
|
<Button
|
||||||
to="/services/$serviceId"
|
type="button"
|
||||||
params={{ serviceId: String(service.id) }}
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Действия ${service.name}`}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Обзор
|
<MoreHorizontalIcon aria-hidden />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem onClick={() => onEditService(service)}>
|
<DropdownMenuContent align="end">
|
||||||
Изменить
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
render={
|
||||||
<DropdownMenuItem
|
<Link
|
||||||
variant="destructive"
|
to="/services/$serviceId"
|
||||||
onClick={() => onDeleteService(service)}
|
params={{ serviceId: String(service.id) }}
|
||||||
>
|
/>
|
||||||
Удалить
|
}
|
||||||
</DropdownMenuItem>
|
>
|
||||||
</DropdownMenuContent>
|
Обзор
|
||||||
</DropdownMenu>
|
</DropdownMenuItem>
|
||||||
</div>
|
<DropdownMenuItem onClick={() => onEditService(service)}>
|
||||||
</FrameHeader>
|
Изменить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onDeleteService(service)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
</FramePanel>
|
||||||
|
|
||||||
<FramePanel className="flex min-w-0 flex-col gap-1 pt-0 shadow-none!">
|
<FramePanel className="flex min-w-0 flex-col">
|
||||||
<ServiceIpList
|
<ServiceIpList
|
||||||
copyable
|
copyable
|
||||||
|
alignWithMenu
|
||||||
ips={service.ips ?? []}
|
ips={service.ips ?? []}
|
||||||
ipHealth={service.ip_health ?? []}
|
ipHealth={service.ip_health ?? []}
|
||||||
ipEnabled={service.ip_enabled ?? {}}
|
ipEnabled={service.ip_enabled ?? {}}
|
||||||
ipToggleDisabled={togglingId === service.id}
|
ipToggleDisabled={togglingId === service.id}
|
||||||
togglingIp={togglingIp}
|
togglingIp={togglingIp}
|
||||||
|
lbMode={service.lb_mode}
|
||||||
|
activeIps={service.active_ips}
|
||||||
|
ipWeights={service.domains[0]?.target_ip_weights}
|
||||||
onToggleIp={(ip, enabled) =>
|
onToggleIp={(ip, enabled) =>
|
||||||
onToggleServiceIp(service.id, ip, enabled)
|
onToggleServiceIp(service.id, ip, enabled)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function SettingRow({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full justify-start',
|
'flex w-full min-w-0 justify-start',
|
||||||
stacked ? 'justify-start' : '@md/field-group:justify-end',
|
stacked ? 'justify-start' : '@md/field-group:justify-end',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ export const serviceViewSchema = serviceSchema.extend({
|
|||||||
health_latency_ms: z.number().nullable().default(null),
|
health_latency_ms: z.number().nullable().default(null),
|
||||||
ip_health: z.array(serviceIpHealthSchema).default([]),
|
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||||
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
||||||
|
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
|
||||||
|
active_ips: z.array(z.string()).default([]),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||||
|
|||||||
@@ -1,104 +1,11 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
|
|
||||||
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
|
||||||
import { HealthTimeline } from '@/components/health/health-timeline'
|
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
|
||||||
import {
|
|
||||||
serviceHealthLogQueryOptions,
|
|
||||||
serviceViewQueryOptions,
|
|
||||||
} from '@/queries'
|
|
||||||
import { formatDate } from '@/lib/format'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
|
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
|
||||||
component: ServiceHealthPage,
|
beforeLoad: ({ params }) => {
|
||||||
|
throw redirect({
|
||||||
|
to: '/services/$serviceId',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
component: () => null,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function ServiceHealthPage() {
|
|
||||||
const { serviceId } = Route.useParams()
|
|
||||||
const id = Number(serviceId)
|
|
||||||
const serviceQuery = useQuery(serviceViewQueryOptions(id))
|
|
||||||
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
|
||||||
const service = serviceQuery.data
|
|
||||||
const items = logQuery.data?.items ?? []
|
|
||||||
const ipHealth = service?.ip_health ?? []
|
|
||||||
|
|
||||||
const kpiCards = ipHealth.map((row) => {
|
|
||||||
const variant =
|
|
||||||
row.status === 'down'
|
|
||||||
? ('destructive' as const)
|
|
||||||
: row.status === 'degraded'
|
|
||||||
? ('warning' as const)
|
|
||||||
: ('default' as const)
|
|
||||||
return {
|
|
||||||
id: row.ip,
|
|
||||||
label: row.ip,
|
|
||||||
value: row.latency_ms != null ? `${row.latency_ms} мс` : '—',
|
|
||||||
hint: row.colo ? `colo ${row.colo}` : row.provider === 'cloudflare' ? 'Worker' : 'Local',
|
|
||||||
icon: row.provider === 'cloudflare' ? <GlobeIcon /> : <ServerIcon />,
|
|
||||||
variant,
|
|
||||||
footer: (
|
|
||||||
<HealthCheckBadge
|
|
||||||
status={row.status}
|
|
||||||
latencyMs={row.latency_ms}
|
|
||||||
lastCheckedAt={row.last_checked_at}
|
|
||||||
lastError={row.last_error}
|
|
||||||
colo={row.colo}
|
|
||||||
provider={row.provider}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DetailPanel>
|
|
||||||
<DetailPanel.Header
|
|
||||||
title="Health"
|
|
||||||
description="Снимок проб этого сервиса. Cloudflare = Worker с edge, не Health Checks API."
|
|
||||||
/>
|
|
||||||
<Alert>
|
|
||||||
<AlertTitle>XOR провайдеров</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Local ходит с API CFDM; Cloudflare — через Worker. Cron и пороги Slow/Down общие, в{' '}
|
|
||||||
<Link to="/settings/health" className="text-foreground underline">
|
|
||||||
Настройках → Health-check
|
|
||||||
</Link>
|
|
||||||
. Если Worker не задан, цель не пробируется как Local.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
{kpiCards.length > 0 ? (
|
|
||||||
<KpiStatGrid cards={kpiCards} />
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon={ActivityIcon}
|
|
||||||
title="Нет проб"
|
|
||||||
description="Включите health-check на привязке — статус IP появится после cron."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<DetailPanel.Header
|
|
||||||
title="Журнал проб"
|
|
||||||
description={
|
|
||||||
items[0]?.checked_at
|
|
||||||
? `Последняя: ${formatDate(items[0].checked_at)}`
|
|
||||||
: 'Последние пробы по IP этого сервиса'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<HealthTimeline
|
|
||||||
events={items.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
hostname: row.ip,
|
|
||||||
type: row.provider,
|
|
||||||
status: row.status,
|
|
||||||
latency_ms: row.latency_ms,
|
|
||||||
error: row.error,
|
|
||||||
checked_at: row.checked_at,
|
|
||||||
colo: row.colo,
|
|
||||||
provider: row.provider,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</DetailPanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,94 +1,420 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useState } from 'react'
|
||||||
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
|
import { useForm } from 'react-hook-form'
|
||||||
import { DetailPanel } from '@/components/reui-kit'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
ArrowLeftIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
NetworkIcon,
|
||||||
|
PencilIcon,
|
||||||
|
ServerIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
|
||||||
|
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { FailoverTimeline } from '@/components/failover-timeline'
|
||||||
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { HealthTimeline } from '@/components/health/health-timeline'
|
||||||
import { serviceOverviewQueryOptions } from '@/queries'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||||
|
import {
|
||||||
|
ServiceDetailGrid,
|
||||||
|
type ServiceFqdnRow,
|
||||||
|
} from '@/components/services/service-detail-grid'
|
||||||
|
import { LbModeTile } from '@/components/services/service-unit-card'
|
||||||
|
import { KpiStatGrid, UptimeChart } from '@/components/reui-kit'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
||||||
|
import {
|
||||||
|
createServiceNode,
|
||||||
|
deleteServiceNode,
|
||||||
|
domainKeys,
|
||||||
|
domainsListQueryOptions,
|
||||||
|
serviceBindingKeys,
|
||||||
|
serviceDetailKeys,
|
||||||
|
serviceGroupKeys,
|
||||||
|
serviceGroupsQueryOptions,
|
||||||
|
serviceHealthLogQueryOptions,
|
||||||
|
serviceKeys,
|
||||||
|
serviceNodesQueryOptions,
|
||||||
|
serviceOverviewQueryOptions,
|
||||||
|
serviceViewQueryOptions,
|
||||||
|
} from '@/queries'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/')({
|
export const Route = createFileRoute('/_auth/services/$serviceId/')({
|
||||||
component: ServiceOverviewPage,
|
component: ServiceDetailPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function ServiceOverviewPage() {
|
interface OverviewPayload {
|
||||||
const { serviceId } = Route.useParams()
|
routing_strategy?: string
|
||||||
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
|
active_addresses?: string[]
|
||||||
const overview = data as {
|
nodes?: Array<{
|
||||||
service: {
|
id: number
|
||||||
name: string
|
address: string
|
||||||
enabled: boolean
|
protocol: string
|
||||||
health_status: 'up' | 'down' | 'degraded' | 'unknown'
|
port: number | null
|
||||||
domains: Array<{ fqdn: string; zone_name: string }>
|
health_status: string
|
||||||
}
|
weight: number
|
||||||
nodes: Array<{ id: number; address: string; health_status: string }>
|
priority: number
|
||||||
routing_strategy: string
|
consecutive_failures: number
|
||||||
active_addresses: string[]
|
last_failure_reason: string | null
|
||||||
} | undefined
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
if (!overview) {
|
function ServiceDetailPage() {
|
||||||
return (
|
const { serviceId } = Route.useParams()
|
||||||
<EmptyState
|
const id = Number(serviceId)
|
||||||
title="Сервис не найден"
|
const navigate = useNavigate()
|
||||||
description="Вернитесь в каталог и выберите сервис."
|
const queryClient = useQueryClient()
|
||||||
/>
|
|
||||||
)
|
const viewQuery = useQuery(serviceViewQueryOptions(id))
|
||||||
|
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
|
||||||
|
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
||||||
|
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
|
||||||
|
const groupsQuery = useQuery(serviceGroupsQueryOptions())
|
||||||
|
const domainsQuery = useQuery(domainsListQueryOptions())
|
||||||
|
|
||||||
|
const service = viewQuery.data
|
||||||
|
const overview = overviewQuery.data as OverviewPayload | undefined
|
||||||
|
const logItems = logQuery.data?.items ?? []
|
||||||
|
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [togglingIp, setTogglingIp] = useState<string | null>(null)
|
||||||
|
const [changeIp, setChangeIp] = useState<{
|
||||||
|
bindingId: number
|
||||||
|
ip?: string
|
||||||
|
} | null>(null)
|
||||||
|
const [changeDomain, setChangeDomain] = useState(false)
|
||||||
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
|
const nodeForm = useForm<{ address: string; port: string }>({
|
||||||
|
defaultValues: { address: '', port: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const groups = groupsQuery.data
|
||||||
|
? [...groupsQuery.data.groups]
|
||||||
|
: []
|
||||||
|
|
||||||
|
async function invalidateService() {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes = overview.nodes ?? []
|
const updateMutation = useMutation({
|
||||||
const domains = overview.service.domains ?? []
|
mutationFn: ({ body }: { body: UpdateServiceConfigInput }) =>
|
||||||
|
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateService()
|
||||||
|
setEditOpen(false)
|
||||||
|
toast.success('Сервис сохранён')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
|
||||||
|
},
|
||||||
|
onSettled: () => setSaving(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => api.delete(`/api/v1/services/${id}`),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateService()
|
||||||
|
toast.success('Сервис удалён')
|
||||||
|
await navigate({ to: '/services' })
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleIpMutation = useMutation({
|
||||||
|
mutationFn: ({ ip, enabled }: { ip: string; enabled: boolean }) =>
|
||||||
|
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, { ip, enabled }),
|
||||||
|
onSuccess: async (_data, { enabled }) => {
|
||||||
|
await invalidateService()
|
||||||
|
toast.success(
|
||||||
|
enabled
|
||||||
|
? 'IP включён и добавлен в DNS-привязки'
|
||||||
|
: 'IP выключен и снят с DNS-привязок',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Не удалось переключить IP')
|
||||||
|
},
|
||||||
|
onSettled: () => setTogglingIp(null),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createNodeMut = useMutation({
|
||||||
|
mutationFn: (values: { address: string; port: string }) =>
|
||||||
|
createServiceNode(id, {
|
||||||
|
address: values.address.trim(),
|
||||||
|
port: values.port ? Number(values.port) : null,
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('Нода добавлена, статус CHECKING')
|
||||||
|
await invalidateService()
|
||||||
|
setAddNodeOpen(false)
|
||||||
|
nodeForm.reset()
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteNodeMut = useMutation({
|
||||||
|
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('Нода удалена')
|
||||||
|
await invalidateService()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = viewQuery.isLoading || overviewQuery.isLoading
|
||||||
|
const isError = viewQuery.isError || overviewQuery.isError
|
||||||
|
const error = viewQuery.error ?? overviewQuery.error
|
||||||
|
|
||||||
|
const failoverEvents =
|
||||||
|
(nodes.length > 0 ? nodes : (overview?.nodes ?? []))
|
||||||
|
.filter(
|
||||||
|
(node) =>
|
||||||
|
node.health_status === 'unhealthy' ||
|
||||||
|
node.health_status === 'down' ||
|
||||||
|
node.health_status === 'checking',
|
||||||
|
)
|
||||||
|
.map((node) => ({
|
||||||
|
id: node.address,
|
||||||
|
title: `${node.address}: ${node.health_status}`,
|
||||||
|
detail: node.last_failure_reason
|
||||||
|
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
|
||||||
|
: `fail ${node.consecutive_failures}`,
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailPanel>
|
<QueryState
|
||||||
<DetailPanel.Header
|
isLoading={isLoading}
|
||||||
title={overview.service.name}
|
isError={isError}
|
||||||
description={`Маршрутизация: ${overview.routing_strategy}. Активные IP: ${
|
error={error}
|
||||||
overview.active_addresses.join(', ') || '—'
|
onRetry={() => {
|
||||||
}`}
|
void viewQuery.refetch()
|
||||||
actions={
|
void overviewQuery.refetch()
|
||||||
<HealthCheckBadge status={overview.service.health_status} />
|
}}
|
||||||
}
|
>
|
||||||
/>
|
{!service ? (
|
||||||
<DetailPanel.Metrics
|
|
||||||
cards={[
|
|
||||||
{
|
|
||||||
id: 'subdomains',
|
|
||||||
icon: <GlobeIcon />,
|
|
||||||
label: 'Поддомены',
|
|
||||||
description:
|
|
||||||
domains.length > 0
|
|
||||||
? domains.map((d) => d.fqdn).join(', ')
|
|
||||||
: 'Нет привязанных FQDN',
|
|
||||||
footer: <Badge variant="outline">{domains.length}</Badge>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'nodes',
|
|
||||||
icon: <ServerIcon />,
|
|
||||||
label: 'Ноды',
|
|
||||||
description:
|
|
||||||
nodes.length > 0
|
|
||||||
? nodes.map((n) => n.address).join(', ')
|
|
||||||
: 'Добавьте ноду, чтобы публиковать DNS',
|
|
||||||
footer: <Badge variant="outline">{nodes.length}</Badge>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'health',
|
|
||||||
icon: <ActivityIcon />,
|
|
||||||
label: 'Пул',
|
|
||||||
description:
|
|
||||||
overview.active_addresses.length > 0
|
|
||||||
? 'Здоровые адреса участвуют в DNS'
|
|
||||||
: 'unknown не попадает в пул, пока не станет healthy',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{domains.length === 0 && nodes.length === 0 ? (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Пустой сервис"
|
title="Сервис не найден"
|
||||||
description="Добавьте поддомен и ноду, затем настройте health-check."
|
description="Вернитесь в каталог и выберите сервис."
|
||||||
stackedIcon
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : (
|
||||||
</DetailPanel>
|
<div className="@container flex w-full flex-col gap-4 md:gap-6">
|
||||||
|
<PageHeader
|
||||||
|
title={service.name}
|
||||||
|
description="Domain → Service → Node → Health → Failover"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<LbModeTile mode={service.lb_mode} />
|
||||||
|
<HealthCheckBadge status={service.health_status} />
|
||||||
|
<Button size="sm" onClick={() => setEditOpen(true)}>
|
||||||
|
<PencilIcon className="size-4" aria-hidden />
|
||||||
|
Изменить
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" render={<Link to="/services" />}>
|
||||||
|
<ArrowLeftIcon className="size-4" aria-hidden />
|
||||||
|
К каталогу
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KpiStatGrid
|
||||||
|
cards={[
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
icon: <ActivityIcon />,
|
||||||
|
label: 'Статус',
|
||||||
|
value: service.health_status === 'up' ? 'OK' : service.health_status,
|
||||||
|
variant:
|
||||||
|
service.health_status === 'down'
|
||||||
|
? 'destructive'
|
||||||
|
: service.health_status === 'degraded'
|
||||||
|
? 'warning'
|
||||||
|
: 'default',
|
||||||
|
iconClassName:
|
||||||
|
service.health_status === 'down'
|
||||||
|
? 'text-destructive'
|
||||||
|
: service.health_status === 'degraded'
|
||||||
|
? 'text-warning'
|
||||||
|
: 'text-success',
|
||||||
|
hint: <HealthCheckBadge status={service.health_status} size="xs" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fqdn',
|
||||||
|
icon: <GlobeIcon />,
|
||||||
|
label: 'FQDN',
|
||||||
|
value: String(service.domains.length),
|
||||||
|
hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ip',
|
||||||
|
icon: <NetworkIcon />,
|
||||||
|
label: 'IP',
|
||||||
|
value: String(service.ips.length),
|
||||||
|
hint: `${service.active_ips.length} в пуле`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pool',
|
||||||
|
icon: <ServerIcon />,
|
||||||
|
label: 'Активный пул',
|
||||||
|
value: String((overview?.active_addresses ?? service.active_ips).length),
|
||||||
|
hint: (overview?.active_addresses ?? service.active_ips).join(', ') || 'нет',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-label="Мониторинг"
|
||||||
|
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||||
|
>
|
||||||
|
<UptimeChart items={logItems} 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>
|
||||||
|
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Журнал проб</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Cloudflare = Worker с edge, не Health Checks API
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel>
|
||||||
|
<HealthTimeline
|
||||||
|
events={logItems.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,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
{service.ips.length === 0 && service.domains.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Пустой сервис"
|
||||||
|
description="Добавьте поддомен и ноду, затем настройте health-check."
|
||||||
|
stackedIcon
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ServiceDetailGrid
|
||||||
|
service={service}
|
||||||
|
nodes={nodes}
|
||||||
|
togglingIp={togglingIp}
|
||||||
|
onToggleIp={(ip, enabled) => {
|
||||||
|
setTogglingIp(ip)
|
||||||
|
toggleIpMutation.mutate({ ip, enabled })
|
||||||
|
}}
|
||||||
|
onChangeIp={(row: ServiceFqdnRow) =>
|
||||||
|
setChangeIp({
|
||||||
|
bindingId: row.binding_id,
|
||||||
|
ip: row.target_ips[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onChangeDomain={() => setChangeDomain(true)}
|
||||||
|
onAddNode={() => setAddNodeOpen(true)}
|
||||||
|
onDeleteNode={(nodeId) => deleteNodeMut.mutate(nodeId)}
|
||||||
|
isLoading={nodesQuery.isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ServiceEditSheet
|
||||||
|
mode="edit"
|
||||||
|
service={service}
|
||||||
|
groups={groups}
|
||||||
|
open={editOpen}
|
||||||
|
knownDomains={domainsQuery.data ?? []}
|
||||||
|
isSaving={saving}
|
||||||
|
isDeleting={deleteMutation.isPending}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
onSave={(_serviceId, body) => {
|
||||||
|
setSaving(true)
|
||||||
|
updateMutation.mutate({ body })
|
||||||
|
}}
|
||||||
|
onDelete={() => deleteMutation.mutate()}
|
||||||
|
/>
|
||||||
|
<ChangeIpSheet
|
||||||
|
open={changeIp != null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setChangeIp(null)
|
||||||
|
}}
|
||||||
|
bindingId={changeIp?.bindingId ?? null}
|
||||||
|
serviceId={id}
|
||||||
|
currentIp={changeIp?.ip}
|
||||||
|
/>
|
||||||
|
<ChangeDomainSheet
|
||||||
|
open={changeDomain}
|
||||||
|
onOpenChange={setChangeDomain}
|
||||||
|
serviceId={id}
|
||||||
|
fromDomainId={service.domains[0]?.domain_id ?? null}
|
||||||
|
/>
|
||||||
|
<FormSheet
|
||||||
|
open={addNodeOpen}
|
||||||
|
onOpenChange={setAddNodeOpen}
|
||||||
|
title="Добавить ноду"
|
||||||
|
description="IP станет CHECKING до порога успешных проверок."
|
||||||
|
form={nodeForm}
|
||||||
|
onSubmit={(values) => createNodeMut.mutate(values)}
|
||||||
|
footer={
|
||||||
|
<LoadingButton type="submit" isLoading={createNodeMut.isPending}>
|
||||||
|
Добавить
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormFieldSimple label="IP" htmlFor="address">
|
||||||
|
<Input id="address" {...nodeForm.register('address')} placeholder="10.0.0.10" />
|
||||||
|
</FormFieldSimple>
|
||||||
|
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
|
||||||
|
<Input id="port" {...nodeForm.register('port')} placeholder="443" />
|
||||||
|
</FormFieldSimple>
|
||||||
|
</FormSheet>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,143 +1,11 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { PlusIcon } from 'lucide-react'
|
|
||||||
import { DetailPanel } from '@/components/reui-kit'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
|
||||||
import { FormFieldSimple } from '@/components/form-field'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
|
||||||
import { createServiceNode, deleteServiceNode, serviceNodesQueryOptions } from '@/queries'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({
|
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({
|
||||||
component: ServiceNodesPage,
|
beforeLoad: ({ params }) => {
|
||||||
|
throw redirect({
|
||||||
|
to: '/services/$serviceId',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
component: () => null,
|
||||||
})
|
})
|
||||||
|
|
||||||
interface NodeRow {
|
|
||||||
id: number
|
|
||||||
address: string
|
|
||||||
port: number | null
|
|
||||||
protocol: string
|
|
||||||
health_status: 'up' | 'down' | 'degraded' | 'unknown' | 'healthy' | 'unhealthy' | 'checking' | 'disabled'
|
|
||||||
weight: number
|
|
||||||
priority: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapHealth(
|
|
||||||
status: NodeRow['health_status'],
|
|
||||||
): 'up' | 'down' | 'degraded' | 'unknown' {
|
|
||||||
if (status === 'healthy' || status === 'up') return 'up'
|
|
||||||
if (status === 'unhealthy' || status === 'down') return 'down'
|
|
||||||
if (status === 'degraded') return 'degraded'
|
|
||||||
return 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ServiceNodesPage() {
|
|
||||||
const { serviceId } = Route.useParams()
|
|
||||||
const id = Number(serviceId)
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
|
|
||||||
const nodes = (nodesQuery.data ?? []) as NodeRow[]
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const form = useForm<{ address: string; port: string }>({
|
|
||||||
defaultValues: { address: '', port: '' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const createMut = useMutation({
|
|
||||||
mutationFn: (values: { address: string; port: string }) =>
|
|
||||||
createServiceNode(id, {
|
|
||||||
address: values.address.trim(),
|
|
||||||
port: values.port ? Number(values.port) : null,
|
|
||||||
}),
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast.success('Нода добавлена, статус CHECKING')
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['services'] })
|
|
||||||
setOpen(false)
|
|
||||||
form.reset()
|
|
||||||
},
|
|
||||||
onError: (e: unknown) =>
|
|
||||||
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteMut = useMutation({
|
|
||||||
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast.success('Нода удалена')
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['services'] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DetailPanel>
|
|
||||||
<DetailPanel.Header
|
|
||||||
title="Ноды"
|
|
||||||
description="Адреса происхождения сервиса."
|
|
||||||
actions={
|
|
||||||
<Button size="sm" onClick={() => setOpen(true)}>
|
|
||||||
<PlusIcon className="size-4" aria-hidden />
|
|
||||||
Добавить ноду
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{nodes.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title="Нет нод"
|
|
||||||
description="Добавьте IP, затем настройте health-check."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{nodes.map((node) => (
|
|
||||||
<div
|
|
||||||
key={node.id}
|
|
||||||
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">{node.address}</span>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{node.protocol}
|
|
||||||
{node.port ? `:${node.port}` : ''} · вес {node.weight} · приоритет{' '}
|
|
||||||
{node.priority}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<HealthCheckBadge status={mapHealth(node.health_status)} />
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => deleteMut.mutate(node.id)}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FormSheet
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
title="Добавить ноду"
|
|
||||||
description="IP станет CHECKING до порога успешных проверок."
|
|
||||||
form={form}
|
|
||||||
onSubmit={(values) => createMut.mutate(values)}
|
|
||||||
footer={
|
|
||||||
<LoadingButton type="submit" isLoading={createMut.isPending}>
|
|
||||||
Добавить
|
|
||||||
</LoadingButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FormFieldSimple label="IP" htmlFor="address">
|
|
||||||
<Input id="address" {...form.register('address')} placeholder="10.0.0.10" />
|
|
||||||
</FormFieldSimple>
|
|
||||||
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
|
|
||||||
<Input id="port" {...form.register('port')} placeholder="443" />
|
|
||||||
</FormFieldSimple>
|
|
||||||
</FormSheet>
|
|
||||||
</DetailPanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,79 +1,29 @@
|
|||||||
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router'
|
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { ArrowLeftIcon } from 'lucide-react'
|
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import {
|
||||||
import { QueryState } from '@/components/query-state'
|
serviceHealthLogQueryOptions,
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
serviceNodesQueryOptions,
|
||||||
import { serviceOverviewQueryOptions } from '@/queries'
|
serviceOverviewQueryOptions,
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
serviceViewQueryOptions,
|
||||||
|
} from '@/queries'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId')({
|
export const Route = createFileRoute('/_auth/services/$serviceId')({
|
||||||
loader: ({ context: { queryClient }, params }) =>
|
loader: ({ context: { queryClient }, params }) => {
|
||||||
queryClient.ensureQueryData(serviceOverviewQueryOptions(Number(params.serviceId))),
|
const id = Number(params.serviceId)
|
||||||
|
return Promise.all([
|
||||||
|
queryClient.ensureQueryData(serviceViewQueryOptions(id)),
|
||||||
|
queryClient.ensureQueryData(serviceOverviewQueryOptions(id)),
|
||||||
|
queryClient.ensureQueryData(serviceHealthLogQueryOptions(id)),
|
||||||
|
queryClient.ensureQueryData(serviceNodesQueryOptions(id)),
|
||||||
|
])
|
||||||
|
},
|
||||||
component: ServiceLayout,
|
component: ServiceLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ to: '/services/$serviceId', label: 'Обзор', exact: true },
|
|
||||||
{ to: '/services/$serviceId/subdomains', label: 'Поддомены', exact: false },
|
|
||||||
{ to: '/services/$serviceId/nodes', label: 'Ноды', exact: false },
|
|
||||||
{ to: '/services/$serviceId/health', label: 'Health', exact: false },
|
|
||||||
{ to: '/services/$serviceId/routing', label: 'Маршрутизация', exact: false },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
function ServiceLayout() {
|
function ServiceLayout() {
|
||||||
const { serviceId } = Route.useParams()
|
|
||||||
const id = Number(serviceId)
|
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
||||||
const overview = useQuery(serviceOverviewQueryOptions(id))
|
|
||||||
const name = (overview.data as { service?: { name?: string } } | undefined)?.service?.name
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<Outlet />
|
||||||
title={name ?? 'Сервис'}
|
|
||||||
description="Domain → Service → Node → Health → Failover"
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
render={<Link to="/services" />}
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="size-4" aria-hidden />
|
|
||||||
К каталогу
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<nav className="flex flex-wrap gap-4 border-b">
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const href = tab.to.replace('$serviceId', serviceId)
|
|
||||||
const active = tab.exact
|
|
||||||
? pathname === `/services/${serviceId}` || pathname === `/services/${serviceId}/`
|
|
||||||
: pathname.startsWith(href)
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={tab.to}
|
|
||||||
to={tab.to}
|
|
||||||
params={{ serviceId }}
|
|
||||||
className={cn(
|
|
||||||
'text-muted-foreground hover:text-foreground pb-3 text-sm font-medium',
|
|
||||||
active && 'text-foreground border-b-2 border-primary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
<QueryState
|
|
||||||
isLoading={overview.isLoading}
|
|
||||||
isError={overview.isError}
|
|
||||||
error={overview.error}
|
|
||||||
onRetry={() => void overview.refetch()}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</QueryState>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,11 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { DetailPanel } from '@/components/reui-kit'
|
|
||||||
import { FailoverTimeline } from '@/components/failover-timeline'
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { serviceOverviewQueryOptions } from '@/queries'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({
|
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({
|
||||||
component: ServiceRoutingPage,
|
beforeLoad: ({ params }) => {
|
||||||
|
throw redirect({
|
||||||
|
to: '/services/$serviceId',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
component: () => null,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function ServiceRoutingPage() {
|
|
||||||
const { serviceId } = Route.useParams()
|
|
||||||
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
|
|
||||||
const overview = data as {
|
|
||||||
routing_strategy: string
|
|
||||||
active_addresses: string[]
|
|
||||||
nodes: Array<{
|
|
||||||
address: string
|
|
||||||
health_status: string
|
|
||||||
consecutive_failures: number
|
|
||||||
last_failure_reason: string | null
|
|
||||||
}>
|
|
||||||
} | undefined
|
|
||||||
|
|
||||||
const events =
|
|
||||||
overview?.nodes
|
|
||||||
.filter(
|
|
||||||
(node) =>
|
|
||||||
node.health_status === 'unhealthy' ||
|
|
||||||
node.health_status === 'down' ||
|
|
||||||
node.health_status === 'checking',
|
|
||||||
)
|
|
||||||
.map((node) => ({
|
|
||||||
id: node.address,
|
|
||||||
title: `${node.address}: ${node.health_status}`,
|
|
||||||
detail: node.last_failure_reason
|
|
||||||
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
|
|
||||||
: `fail ${node.consecutive_failures}`,
|
|
||||||
})) ?? []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DetailPanel>
|
|
||||||
<DetailPanel.Header
|
|
||||||
title="Маршрутизация"
|
|
||||||
description="Round Robin / Failover. Weighted на DNS = alias Round Robin."
|
|
||||||
actions={<Badge variant="outline">{overview?.routing_strategy ?? 'round_robin'}</Badge>}
|
|
||||||
/>
|
|
||||||
<p className="text-sm">
|
|
||||||
Активные адреса:{' '}
|
|
||||||
{overview?.active_addresses.join(', ') || 'нет (unknown не в пуле)'}
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Запись обновляется в Cloudflare. Распространение зависит от TTL.
|
|
||||||
</p>
|
|
||||||
<FailoverTimeline events={events} />
|
|
||||||
</DetailPanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,114 +1,11 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { ArrowRightLeftIcon } from 'lucide-react'
|
|
||||||
import { DetailPanel } from '@/components/reui-kit'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
|
||||||
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
|
|
||||||
import { serviceOverviewQueryOptions } from '@/queries'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({
|
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({
|
||||||
component: ServiceSubdomainsPage,
|
beforeLoad: ({ params }) => {
|
||||||
|
throw redirect({
|
||||||
|
to: '/services/$serviceId',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
component: () => null,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function ServiceSubdomainsPage() {
|
|
||||||
const { serviceId } = Route.useParams()
|
|
||||||
const id = Number(serviceId)
|
|
||||||
const { data } = useQuery(serviceOverviewQueryOptions(id))
|
|
||||||
const overview = data as {
|
|
||||||
service: {
|
|
||||||
domains: Array<{
|
|
||||||
binding_id: number
|
|
||||||
domain_id: number
|
|
||||||
fqdn: string
|
|
||||||
zone_name: string
|
|
||||||
target_ips: string[]
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
} | undefined
|
|
||||||
const rows = overview?.service.domains ?? []
|
|
||||||
const [changeIp, setChangeIp] = useState<{
|
|
||||||
bindingId: number
|
|
||||||
ip?: string
|
|
||||||
} | null>(null)
|
|
||||||
const [changeDomain, setChangeDomain] = useState(false)
|
|
||||||
const fromDomainId = useMemo(
|
|
||||||
() => rows[0]?.domain_id ?? null,
|
|
||||||
[rows],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DetailPanel>
|
|
||||||
<DetailPanel.Header
|
|
||||||
title="Поддомены"
|
|
||||||
description="FQDN сервиса в одной или нескольких зонах Cloudflare."
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setChangeDomain(true)}
|
|
||||||
disabled={rows.length === 0}
|
|
||||||
>
|
|
||||||
<ArrowRightLeftIcon className="size-4" aria-hidden />
|
|
||||||
Сменить домен
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title="Нет поддоменов"
|
|
||||||
description="Привяжите FQDN к сервису из карточки редактирования."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{rows.map((row) => (
|
|
||||||
<div
|
|
||||||
key={row.binding_id}
|
|
||||||
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
|
||||||
<span className="font-medium">{row.fqdn}</span>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{row.zone_name} · {row.target_ips.join(', ') || 'нет IP'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline">{row.target_ips.length} IP</Badge>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
setChangeIp({
|
|
||||||
bindingId: row.binding_id,
|
|
||||||
ip: row.target_ips[0],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Сменить IP
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ChangeIpSheet
|
|
||||||
open={changeIp != null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setChangeIp(null)
|
|
||||||
}}
|
|
||||||
bindingId={changeIp?.bindingId ?? null}
|
|
||||||
serviceId={id}
|
|
||||||
currentIp={changeIp?.ip}
|
|
||||||
/>
|
|
||||||
<ChangeDomainSheet
|
|
||||||
open={changeDomain}
|
|
||||||
onOpenChange={setChangeDomain}
|
|
||||||
serviceId={id}
|
|
||||||
fromDomainId={fromDomainId}
|
|
||||||
/>
|
|
||||||
</DetailPanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
Vendored
+26
@@ -176,6 +176,8 @@ interface ServiceView$1 {
|
|||||||
health_latency_ms: number | null;
|
health_latency_ms: number | null;
|
||||||
ip_health: ServiceIpHealth$1[];
|
ip_health: ServiceIpHealth$1[];
|
||||||
ip_enabled: Record<string, boolean>;
|
ip_enabled: Record<string, boolean>;
|
||||||
|
lb_mode: LbMode;
|
||||||
|
active_ips: string[];
|
||||||
}
|
}
|
||||||
interface SyncJob {
|
interface SyncJob {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -824,6 +826,12 @@ declare const serviceViewSchema: z.ZodObject<{
|
|||||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||||
|
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||||
|
round_robin: "round_robin";
|
||||||
|
failover: "failover";
|
||||||
|
weighted: "weighted";
|
||||||
|
}>>;
|
||||||
|
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
declare const serviceGroupViewSchema: z.ZodObject<{
|
declare const serviceGroupViewSchema: z.ZodObject<{
|
||||||
id: z.ZodNumber;
|
id: z.ZodNumber;
|
||||||
@@ -1013,6 +1021,12 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
|||||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||||
|
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||||
|
round_robin: "round_robin";
|
||||||
|
failover: "failover";
|
||||||
|
weighted: "weighted";
|
||||||
|
}>>;
|
||||||
|
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
health_status: z.ZodDefault<z.ZodEnum<{
|
health_status: z.ZodDefault<z.ZodEnum<{
|
||||||
unknown: "unknown";
|
unknown: "unknown";
|
||||||
@@ -1211,6 +1225,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
|||||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||||
|
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||||
|
round_robin: "round_robin";
|
||||||
|
failover: "failover";
|
||||||
|
weighted: "weighted";
|
||||||
|
}>>;
|
||||||
|
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
health_status: z.ZodDefault<z.ZodEnum<{
|
health_status: z.ZodDefault<z.ZodEnum<{
|
||||||
unknown: "unknown";
|
unknown: "unknown";
|
||||||
@@ -1360,6 +1380,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
|||||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||||
|
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||||
|
round_robin: "round_robin";
|
||||||
|
failover: "failover";
|
||||||
|
weighted: "weighted";
|
||||||
|
}>>;
|
||||||
|
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
declare const domainSchema: z.ZodObject<{
|
declare const domainSchema: z.ZodObject<{
|
||||||
|
|||||||
Vendored
+3
-1
@@ -409,7 +409,9 @@ var serviceViewSchema = serviceSchema.extend({
|
|||||||
health_status: ipHealthStateSchema.default("unknown"),
|
health_status: ipHealthStateSchema.default("unknown"),
|
||||||
health_latency_ms: z.number().nullable().default(null),
|
health_latency_ms: z.number().nullable().default(null),
|
||||||
ip_health: z.array(serviceIpHealthSchema).default([]),
|
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||||
ip_enabled: z.record(z.string(), z.boolean()).default({})
|
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
||||||
|
lb_mode: lbModeSchema.catch("round_robin"),
|
||||||
|
active_ips: z.array(z.string()).default([])
|
||||||
});
|
});
|
||||||
var serviceGroupViewSchema = serviceGroupSchema.extend({
|
var serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||||
services: z.array(serviceViewSchema).default([]),
|
services: z.array(serviceViewSchema).default([]),
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ export const serviceViewSchema = serviceSchema.extend({
|
|||||||
health_latency_ms: z.number().nullable().default(null),
|
health_latency_ms: z.number().nullable().default(null),
|
||||||
ip_health: z.array(serviceIpHealthSchema).default([]),
|
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||||
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
||||||
|
lb_mode: lbModeSchema.catch('round_robin'),
|
||||||
|
active_ips: z.array(z.string()).default([]),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||||
|
|||||||
@@ -222,6 +222,8 @@ export interface ServiceView {
|
|||||||
health_latency_ms: number | null;
|
health_latency_ms: number | null;
|
||||||
ip_health: ServiceIpHealth[];
|
ip_health: ServiceIpHealth[];
|
||||||
ip_enabled: Record<string, boolean>;
|
ip_enabled: Record<string, boolean>;
|
||||||
|
lb_mode: LbMode;
|
||||||
|
active_ips: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupWithStats extends Group {
|
export interface GroupWithStats extends Group {
|
||||||
|
|||||||
Reference in New Issue
Block a user