Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78811bc9b1 | ||
|
|
a458465153 | ||
|
|
69119a08a4 | ||
|
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { changeIpSchema } from "@cfdm/shared";
|
import { certMonitoringSchema, changeIpSchema } from "@cfdm/shared";
|
||||||
import * as bindingService from "../services/binding-service.js";
|
import * as bindingService from "../services/binding-service.js";
|
||||||
import * as changeIp from "../services/change-ip-service.js";
|
import * as changeIp from "../services/change-ip-service.js";
|
||||||
import { recordAudit } from "../lib/audit.js";
|
import { recordAudit } from "../lib/audit.js";
|
||||||
@@ -17,6 +17,7 @@ export async function serviceBindingRoutes(app: FastifyInstance) {
|
|||||||
service_id: z.number().optional(),
|
service_id: z.number().optional(),
|
||||||
hostname: z.string().optional(),
|
hostname: z.string().optional(),
|
||||||
target_ip: z.string().optional(),
|
target_ip: z.string().optional(),
|
||||||
|
cert_monitoring: certMonitoringSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/service-bindings", async (request) => {
|
app.get("/service-bindings", async (request) => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { repos } from "@cfdm/db";
|
|||||||
import * as serviceConfig from "../services/service-config-service.js";
|
import * as serviceConfig from "../services/service-config-service.js";
|
||||||
import * as nodeService from "../services/node-service.js";
|
import * as nodeService from "../services/node-service.js";
|
||||||
import * as changeDomain from "../services/change-domain-service.js";
|
import * as changeDomain from "../services/change-domain-service.js";
|
||||||
|
import * as certificateService from "../services/certificate-service.js";
|
||||||
import { recordAudit } from "../lib/audit.js";
|
import { recordAudit } from "../lib/audit.js";
|
||||||
|
|
||||||
export async function serviceRoutes(app: FastifyInstance) {
|
export async function serviceRoutes(app: FastifyInstance) {
|
||||||
@@ -69,10 +70,31 @@ export async function serviceRoutes(app: FastifyInstance) {
|
|||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
repos.getService(request.server.db, Number(id));
|
repos.getService(request.server.db, Number(id));
|
||||||
return {
|
return {
|
||||||
items: repos.listHealthProbeLogForService(request.server.db, Number(id)),
|
items: repos.listHealthProbeLogForService(
|
||||||
|
request.server.db,
|
||||||
|
Number(id),
|
||||||
|
200,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/services/:id/certificates", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return certificateService.listServiceCertificates(
|
||||||
|
request.server.db,
|
||||||
|
Number(id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/services/:id/certificates/check", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const checked = await certificateService.runServiceChecks(
|
||||||
|
request.server.db,
|
||||||
|
Number(id),
|
||||||
|
);
|
||||||
|
return { checked };
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/services/:id/overview", async (request) => {
|
app.get("/services/:id/overview", async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
return nodeService.getOverview(request.server.db, Number(id));
|
return nodeService.getOverview(request.server.db, Number(id));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface UpdateBindingRequest {
|
|||||||
service_id?: number;
|
service_id?: number;
|
||||||
hostname?: string;
|
hostname?: string;
|
||||||
target_ip?: string;
|
target_ip?: string;
|
||||||
|
cert_monitoring?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHostname(hostname?: string): string {
|
function normalizeHostname(hostname?: string): string {
|
||||||
@@ -92,6 +93,20 @@ export async function update(
|
|||||||
req: UpdateBindingRequest,
|
req: UpdateBindingRequest,
|
||||||
): Promise<ServiceBindingView> {
|
): Promise<ServiceBindingView> {
|
||||||
const existing = repos.getBinding(db, id);
|
const existing = repos.getBinding(db, id);
|
||||||
|
if (req.cert_monitoring !== undefined) {
|
||||||
|
repos.updateBindingLbConfig(db, id, {
|
||||||
|
cert_monitoring: req.cert_monitoring,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasIdentityPatch =
|
||||||
|
req.service_id !== undefined ||
|
||||||
|
req.hostname !== undefined ||
|
||||||
|
req.target_ip !== undefined;
|
||||||
|
if (!hasIdentityPatch) {
|
||||||
|
return repos.getBindingView(db, id);
|
||||||
|
}
|
||||||
|
|
||||||
const serviceId = req.service_id ?? existing.service_id;
|
const serviceId = req.service_id ?? existing.service_id;
|
||||||
if (req.service_id) repos.getService(db, req.service_id);
|
if (req.service_id) repos.getService(db, req.service_id);
|
||||||
const hostname = req.hostname
|
const hostname = req.hostname
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { connect } from "node:net";
|
|||||||
import { connect as tlsConnect } from "node:tls";
|
import { connect as tlsConnect } from "node:tls";
|
||||||
import type { Db } from "@cfdm/db";
|
import type { Db } from "@cfdm/db";
|
||||||
import { repos } from "@cfdm/db";
|
import { repos } from "@cfdm/db";
|
||||||
import type { Certificate, Domain, Subdomain } from "@cfdm/shared";
|
import type { Certificate, ServiceCertificateRow, Subdomain } from "@cfdm/shared";
|
||||||
import {
|
import {
|
||||||
CERT_ERROR,
|
CERT_ERROR,
|
||||||
CERT_MONITOR_AUTO,
|
CERT_MONITOR_AUTO,
|
||||||
@@ -11,13 +11,13 @@ import {
|
|||||||
CERT_UNKNOWN,
|
CERT_UNKNOWN,
|
||||||
certStatusFromExpiry,
|
certStatusFromExpiry,
|
||||||
fqdnToDisplay,
|
fqdnToDisplay,
|
||||||
parseFqdn,
|
|
||||||
shouldMonitorService,
|
shouldMonitorService,
|
||||||
} from "@cfdm/shared";
|
} from "@cfdm/shared";
|
||||||
|
|
||||||
export interface CertificateTarget {
|
export interface CertificateTarget {
|
||||||
domainId: number;
|
domainId: number;
|
||||||
subdomainId: number | null;
|
subdomainId: number | null;
|
||||||
|
serviceId: number;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +41,34 @@ export function getCertificate(db: Db, id: number): Certificate {
|
|||||||
return repos.getCertificate(db, id);
|
return repos.getCertificate(db, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listServiceCertificates(
|
||||||
|
db: Db,
|
||||||
|
serviceId: number,
|
||||||
|
): ServiceCertificateRow[] {
|
||||||
|
repos.getService(db, serviceId);
|
||||||
|
const certsByHost = new Map(
|
||||||
|
repos.listCertificates(db).map((cert) => [cert.hostname, cert]),
|
||||||
|
);
|
||||||
|
return repos.listBindingsByService(db, serviceId).map((binding) => {
|
||||||
|
const hostname = fqdnToDisplay(binding.hostname, binding.zone_name);
|
||||||
|
const cert = certsByHost.get(hostname);
|
||||||
|
return {
|
||||||
|
binding_id: binding.id,
|
||||||
|
domain_id: binding.domain_id,
|
||||||
|
service_id: binding.service_id,
|
||||||
|
hostname,
|
||||||
|
cert_monitoring:
|
||||||
|
(binding.cert_monitoring as ServiceCertificateRow["cert_monitoring"]) ??
|
||||||
|
"auto",
|
||||||
|
id: cert?.id ?? null,
|
||||||
|
status: cert?.status ?? "unknown",
|
||||||
|
expires_at: cert?.expires_at ?? null,
|
||||||
|
last_checked_at: cert?.last_checked_at ?? null,
|
||||||
|
last_error: cert?.last_error ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkHostname(
|
export async function checkHostname(
|
||||||
hostname: string,
|
hostname: string,
|
||||||
): Promise<{ expiresAt: Date | null; error: string | null }> {
|
): Promise<{ expiresAt: Date | null; error: string | null }> {
|
||||||
@@ -78,6 +106,7 @@ export async function checkAndStore(
|
|||||||
domainId: number,
|
domainId: number,
|
||||||
subdomainId: number | null,
|
subdomainId: number | null,
|
||||||
hostname: string,
|
hostname: string,
|
||||||
|
serviceId: number | null = null,
|
||||||
): Promise<Certificate> {
|
): Promise<Certificate> {
|
||||||
const { expiresAt, error } = await checkHostname(hostname);
|
const { expiresAt, error } = await checkHostname(hostname);
|
||||||
|
|
||||||
@@ -90,6 +119,7 @@ export async function checkAndStore(
|
|||||||
null,
|
null,
|
||||||
CERT_ERROR,
|
CERT_ERROR,
|
||||||
error,
|
error,
|
||||||
|
serviceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +135,7 @@ export async function checkAndStore(
|
|||||||
expiresAt.toISOString(),
|
expiresAt.toISOString(),
|
||||||
certStatusFromExpiry(days),
|
certStatusFromExpiry(days),
|
||||||
null,
|
null,
|
||||||
|
serviceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,23 +147,10 @@ export async function checkAndStore(
|
|||||||
null,
|
null,
|
||||||
CERT_UNKNOWN,
|
CERT_UNKNOWN,
|
||||||
"unknown expiry",
|
"unknown expiry",
|
||||||
|
serviceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMonitoringMode(
|
|
||||||
domain: Domain,
|
|
||||||
subdomain: Subdomain | null,
|
|
||||||
fqdn: string,
|
|
||||||
): string {
|
|
||||||
if (subdomain) {
|
|
||||||
return subdomain.cert_monitoring;
|
|
||||||
}
|
|
||||||
if (fqdn === domain.zone_name) {
|
|
||||||
return domain.cert_monitoring;
|
|
||||||
}
|
|
||||||
return CERT_MONITOR_AUTO;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindingSubdomain(
|
function bindingSubdomain(
|
||||||
db: Db,
|
db: Db,
|
||||||
domainId: number,
|
domainId: number,
|
||||||
@@ -165,10 +183,9 @@ function hasSslHealthGate(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildServiceCertificateFqdns(
|
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
|
||||||
db: Db,
|
const targets: CertificateTarget[] = [];
|
||||||
): Map<string, CertificateTarget> {
|
const seen = new Set<string>();
|
||||||
const result = new Map<string, CertificateTarget>();
|
|
||||||
|
|
||||||
for (const binding of repos.listAllBindings(db)) {
|
for (const binding of repos.listAllBindings(db)) {
|
||||||
const service = repos.getService(db, binding.service_id);
|
const service = repos.getService(db, binding.service_id);
|
||||||
@@ -176,98 +193,40 @@ export function buildServiceCertificateFqdns(
|
|||||||
? repos.getServiceGroup(db, service.service_group_id)
|
? repos.getServiceGroup(db, service.service_group_id)
|
||||||
: null;
|
: null;
|
||||||
if (!shouldMonitorService(service, group)) continue;
|
if (!shouldMonitorService(service, group)) continue;
|
||||||
if (
|
|
||||||
!hasSslHealthGate(
|
|
||||||
{
|
|
||||||
health_check_enabled: binding.health_check_enabled,
|
|
||||||
health_check_verify_tls: binding.health_check_verify_tls,
|
|
||||||
},
|
|
||||||
group,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
|
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
|
||||||
if (subdomain && !subdomain.enabled) continue;
|
if (subdomain && !subdomain.enabled) continue;
|
||||||
|
|
||||||
|
const mode = binding.cert_monitoring ?? CERT_MONITOR_AUTO;
|
||||||
|
if (mode === CERT_MONITOR_SKIPPED) continue;
|
||||||
|
if (mode === CERT_MONITOR_AUTO) {
|
||||||
|
if (
|
||||||
|
!hasSslHealthGate(
|
||||||
|
{
|
||||||
|
health_check_enabled: binding.health_check_enabled,
|
||||||
|
health_check_verify_tls: binding.health_check_verify_tls,
|
||||||
|
},
|
||||||
|
group,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if (mode !== CERT_MONITOR_REQUIRED) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const fqdn = fqdnToDisplay(binding.hostname, binding.zone_name);
|
const fqdn = fqdnToDisplay(binding.hostname, binding.zone_name);
|
||||||
result.set(fqdn, {
|
if (seen.has(fqdn)) continue;
|
||||||
|
seen.add(fqdn);
|
||||||
|
targets.push({
|
||||||
domainId: binding.domain_id,
|
domainId: binding.domain_id,
|
||||||
subdomainId: subdomain?.id ?? null,
|
subdomainId: subdomain?.id ?? null,
|
||||||
|
serviceId: binding.service_id,
|
||||||
hostname: fqdn,
|
hostname: fqdn,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name);
|
return targets;
|
||||||
for (const group of repos.listServiceGroups(db)) {
|
|
||||||
if (!group.enabled || !group.domain?.trim()) continue;
|
|
||||||
if (!group.health_check_enabled || !group.health_check_verify_tls) continue;
|
|
||||||
|
|
||||||
const parsed = parseFqdn(group.domain, knownZones);
|
|
||||||
if (!parsed) continue;
|
|
||||||
|
|
||||||
const domain = repos.findDomainByZoneName(db, parsed.zoneName);
|
|
||||||
if (!domain) continue;
|
|
||||||
|
|
||||||
const subdomain =
|
|
||||||
parsed.hostname === "@"
|
|
||||||
? null
|
|
||||||
: bindingSubdomain(db, domain.id, parsed.hostname);
|
|
||||||
if (subdomain && !subdomain.enabled) continue;
|
|
||||||
|
|
||||||
result.set(parsed.fqdn, {
|
|
||||||
domainId: domain.id,
|
|
||||||
subdomainId: subdomain?.id ?? null,
|
|
||||||
hostname: parsed.fqdn,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
|
|
||||||
const serviceFqdns = buildServiceCertificateFqdns(db);
|
|
||||||
const targets = new Map<string, CertificateTarget>();
|
|
||||||
|
|
||||||
for (const domain of repos.listAllDomains(db)) {
|
|
||||||
if (domain.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
|
|
||||||
if (domain.cert_monitoring === CERT_MONITOR_REQUIRED) {
|
|
||||||
targets.set(domain.zone_name, {
|
|
||||||
domainId: domain.id,
|
|
||||||
subdomainId: null,
|
|
||||||
hostname: domain.zone_name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const sub of repos.listAllSubdomains(db)) {
|
|
||||||
if (sub.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
|
|
||||||
if (sub.cert_monitoring === CERT_MONITOR_REQUIRED) {
|
|
||||||
targets.set(sub.fqdn, {
|
|
||||||
domainId: sub.domain_id,
|
|
||||||
subdomainId: sub.id,
|
|
||||||
hostname: sub.fqdn,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [fqdn, meta] of serviceFqdns) {
|
|
||||||
const domain = repos.getDomain(db, meta.domainId);
|
|
||||||
const subdomain = meta.subdomainId
|
|
||||||
? repos.getSubdomain(db, meta.subdomainId)
|
|
||||||
: null;
|
|
||||||
const monitoring = resolveMonitoringMode(domain, subdomain, fqdn);
|
|
||||||
if (monitoring === CERT_MONITOR_SKIPPED) continue;
|
|
||||||
if (
|
|
||||||
monitoring === CERT_MONITOR_AUTO ||
|
|
||||||
monitoring === CERT_MONITOR_REQUIRED
|
|
||||||
) {
|
|
||||||
targets.set(fqdn, meta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...targets.values()];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runAllChecks(db: Db): Promise<number> {
|
export async function runAllChecks(db: Db): Promise<number> {
|
||||||
@@ -278,6 +237,7 @@ export async function runAllChecks(db: Db): Promise<number> {
|
|||||||
target.domainId,
|
target.domainId,
|
||||||
target.subdomainId,
|
target.subdomainId,
|
||||||
target.hostname,
|
target.hostname,
|
||||||
|
target.serviceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
repos.deleteCertificatesNotIn(
|
repos.deleteCertificatesNotIn(
|
||||||
@@ -287,6 +247,26 @@ export async function runAllChecks(db: Db): Promise<number> {
|
|||||||
return targets.length;
|
return targets.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function runServiceChecks(
|
||||||
|
db: Db,
|
||||||
|
serviceId: number,
|
||||||
|
): Promise<number> {
|
||||||
|
repos.getService(db, serviceId);
|
||||||
|
const targets = resolveCertificateTargets(db).filter(
|
||||||
|
(target) => target.serviceId === serviceId,
|
||||||
|
);
|
||||||
|
for (const target of targets) {
|
||||||
|
await checkAndStore(
|
||||||
|
db,
|
||||||
|
target.domainId,
|
||||||
|
target.subdomainId,
|
||||||
|
target.hostname,
|
||||||
|
target.serviceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return targets.length;
|
||||||
|
}
|
||||||
|
|
||||||
export function statusSummary(db: Db): Array<[string, number]> {
|
export function statusSummary(db: Db): Array<[string, number]> {
|
||||||
pruneStaleCertificates(db);
|
pruneStaleCertificates(db);
|
||||||
return repos.countCertificatesByStatus(db);
|
return repos.countCertificatesByStatus(db);
|
||||||
|
|||||||
@@ -308,10 +308,19 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
|||||||
binding.health_check_provider ?? "local",
|
binding.health_check_provider ?? "local",
|
||||||
],
|
],
|
||||||
health_check_aggregate: binding.health_check_aggregate ?? "majority",
|
health_check_aggregate: binding.health_check_aggregate ?? "majority",
|
||||||
|
cert_monitoring: binding.cert_monitoring ?? "auto",
|
||||||
sync_status: aggregateSyncStatus(statuses),
|
sync_status: aggregateSyncStatus(statuses),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activeIps = new Set<string>();
|
||||||
|
for (const binding of bindings) {
|
||||||
|
const { config, rows } = getBindingLbState(db, binding.id);
|
||||||
|
for (const ip of selectActiveIpsByMode(config, rows)) {
|
||||||
|
activeIps.add(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: service.id,
|
id: service.id,
|
||||||
name: service.name,
|
name: service.name,
|
||||||
@@ -330,6 +339,8 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
|||||||
health_status: "unknown",
|
health_status: "unknown",
|
||||||
health_latency_ms: null,
|
health_latency_ms: null,
|
||||||
ip_health: [],
|
ip_health: [],
|
||||||
|
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
|
||||||
|
active_ips: [...activeIps],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ describe("certificates", () => {
|
|||||||
await testApp.close();
|
await testApp.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("required apex is monitored without bindings", async () => {
|
it("required binding is monitored without TLS health gate", async () => {
|
||||||
const testApp = await buildApp({
|
const testApp = await buildApp({
|
||||||
config: { ...loadConfig(), staticDir: null },
|
config: { ...loadConfig(), staticDir: null },
|
||||||
memory: true,
|
memory: true,
|
||||||
@@ -221,10 +221,18 @@ describe("certificates", () => {
|
|||||||
"required.example.com",
|
"required.example.com",
|
||||||
"cf-zone-req",
|
"cf-zone-req",
|
||||||
);
|
);
|
||||||
repos.updateDomain(testApp.db, domain.id, {
|
const service = repos.createService(testApp.db, "Req", "req");
|
||||||
group_id: null,
|
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||||
status: "active",
|
const binding = repos.insertBinding(
|
||||||
|
testApp.db,
|
||||||
|
domain.id,
|
||||||
|
service.id,
|
||||||
|
"@",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||||
|
health_check_enabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||||
@@ -247,7 +255,7 @@ describe("certificates", () => {
|
|||||||
await testApp.close();
|
await testApp.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skipped apex removes stale certificate on check", async () => {
|
it("skipped binding removes stale certificate on check", async () => {
|
||||||
const testApp = await buildApp({
|
const testApp = await buildApp({
|
||||||
config: { ...loadConfig(), staticDir: null },
|
config: { ...loadConfig(), staticDir: null },
|
||||||
memory: true,
|
memory: true,
|
||||||
@@ -260,6 +268,20 @@ describe("certificates", () => {
|
|||||||
"skipped.example.com",
|
"skipped.example.com",
|
||||||
"cf-zone-skip",
|
"cf-zone-skip",
|
||||||
);
|
);
|
||||||
|
const service = repos.createService(testApp.db, "Skip", "skip");
|
||||||
|
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||||
|
const binding = repos.insertBinding(
|
||||||
|
testApp.db,
|
||||||
|
domain.id,
|
||||||
|
service.id,
|
||||||
|
"@",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||||
|
cert_monitoring: CERT_MONITOR_SKIPPED,
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_verify_tls: true,
|
||||||
|
});
|
||||||
repos.upsertCertificateCheck(
|
repos.upsertCertificateCheck(
|
||||||
testApp.db,
|
testApp.db,
|
||||||
domain.id,
|
domain.id,
|
||||||
@@ -268,12 +290,8 @@ describe("certificates", () => {
|
|||||||
null,
|
null,
|
||||||
CERT_ERROR,
|
CERT_ERROR,
|
||||||
"stale",
|
"stale",
|
||||||
|
service.id,
|
||||||
);
|
);
|
||||||
repos.updateDomain(testApp.db, domain.id, {
|
|
||||||
group_id: null,
|
|
||||||
status: "active",
|
|
||||||
cert_monitoring: CERT_MONITOR_SKIPPED,
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||||
expiresAt: null,
|
expiresAt: null,
|
||||||
@@ -305,9 +323,16 @@ describe("certificates", () => {
|
|||||||
"broken.example.com",
|
"broken.example.com",
|
||||||
"cf-zone-broken",
|
"cf-zone-broken",
|
||||||
);
|
);
|
||||||
repos.updateDomain(testApp.db, domain.id, {
|
const service = repos.createService(testApp.db, "Broken", "broken");
|
||||||
group_id: null,
|
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||||
status: "active",
|
const binding = repos.insertBinding(
|
||||||
|
testApp.db,
|
||||||
|
domain.id,
|
||||||
|
service.id,
|
||||||
|
"@",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,7 +449,7 @@ describe("certificates", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const certs = repos.listCertificates(testApp.db);
|
const certs = repos.listCertificates(testApp.db);
|
||||||
expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(true);
|
expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(false);
|
||||||
expect(certs.some((c) => c.hostname === "edge.ok.example.com")).toBe(true);
|
expect(certs.some((c) => c.hostname === "edge.ok.example.com")).toBe(true);
|
||||||
|
|
||||||
await testApp.close();
|
await testApp.close();
|
||||||
@@ -443,12 +468,7 @@ describe("certificates", () => {
|
|||||||
"force.example.com",
|
"force.example.com",
|
||||||
"cf-zone-force",
|
"cf-zone-force",
|
||||||
);
|
);
|
||||||
repos.updateDomain(testApp.db, domain.id, {
|
const group = repos.createServiceGroup(
|
||||||
group_id: null,
|
|
||||||
status: "active",
|
|
||||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
|
||||||
});
|
|
||||||
repos.createServiceGroup(
|
|
||||||
testApp.db,
|
testApp.db,
|
||||||
"Proxy",
|
"Proxy",
|
||||||
"vpn",
|
"vpn",
|
||||||
@@ -459,6 +479,19 @@ describe("certificates", () => {
|
|||||||
health_check_verify_tls: false,
|
health_check_verify_tls: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const service = repos.createService(testApp.db, "Force", "force");
|
||||||
|
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||||
|
repos.setServiceGroup(testApp.db, service.id, group.id);
|
||||||
|
const binding = repos.insertBinding(
|
||||||
|
testApp.db,
|
||||||
|
domain.id,
|
||||||
|
service.id,
|
||||||
|
"@",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||||
|
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||||
|
});
|
||||||
|
|
||||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||||
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||||
@@ -540,4 +573,137 @@ describe("certificates", () => {
|
|||||||
|
|
||||||
await testApp.close();
|
await testApp.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("GET /services/:id/certificates lists binding FQDNs", async () => {
|
||||||
|
const testApp = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(testApp);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(
|
||||||
|
testApp.db,
|
||||||
|
null,
|
||||||
|
"svc.example.com",
|
||||||
|
"cf-zone-svc",
|
||||||
|
);
|
||||||
|
const service = repos.createService(testApp.db, "Api", "api");
|
||||||
|
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||||
|
repos.insertBinding(testApp.db, domain.id, service.id, "www", null);
|
||||||
|
|
||||||
|
const res = await testApp.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v1/services/${service.id}/certificates`,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const rows = res.json() as Array<{
|
||||||
|
hostname: string;
|
||||||
|
cert_monitoring: string;
|
||||||
|
status: string;
|
||||||
|
}>;
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]?.hostname).toBe("www.svc.example.com");
|
||||||
|
expect(rows[0]?.cert_monitoring).toBe("auto");
|
||||||
|
expect(rows[0]?.status).toBe("unknown");
|
||||||
|
|
||||||
|
await testApp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH /service-bindings/:id updates cert_monitoring", async () => {
|
||||||
|
const testApp = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(testApp);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(
|
||||||
|
testApp.db,
|
||||||
|
null,
|
||||||
|
"patch.example.com",
|
||||||
|
"cf-zone-patch",
|
||||||
|
);
|
||||||
|
const service = repos.createService(testApp.db, "Patch", "patch");
|
||||||
|
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||||
|
const binding = repos.insertBinding(
|
||||||
|
testApp.db,
|
||||||
|
domain.id,
|
||||||
|
service.id,
|
||||||
|
"api",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await testApp.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/v1/service-bindings/${binding.id}`,
|
||||||
|
headers,
|
||||||
|
payload: { cert_monitoring: CERT_MONITOR_REQUIRED },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect((res.json() as { cert_monitoring: string }).cert_monitoring).toBe(
|
||||||
|
CERT_MONITOR_REQUIRED,
|
||||||
|
);
|
||||||
|
expect(repos.getBinding(testApp.db, binding.id).cert_monitoring).toBe(
|
||||||
|
CERT_MONITOR_REQUIRED,
|
||||||
|
);
|
||||||
|
|
||||||
|
await testApp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /services/:id/certificates/check only checks that service", async () => {
|
||||||
|
const testApp = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(testApp);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(
|
||||||
|
testApp.db,
|
||||||
|
null,
|
||||||
|
"check.example.com",
|
||||||
|
"cf-zone-check",
|
||||||
|
);
|
||||||
|
const service = repos.createService(testApp.db, "One", "one");
|
||||||
|
const other = repos.createService(testApp.db, "Two", "two");
|
||||||
|
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||||
|
repos.setServiceEnabled(testApp.db, other.id, true);
|
||||||
|
const binding = repos.insertBinding(
|
||||||
|
testApp.db,
|
||||||
|
domain.id,
|
||||||
|
service.id,
|
||||||
|
"one",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const otherBinding = repos.insertBinding(
|
||||||
|
testApp.db,
|
||||||
|
domain.id,
|
||||||
|
other.id,
|
||||||
|
"two",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||||
|
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||||
|
});
|
||||||
|
repos.updateBindingLbConfig(testApp.db, otherBinding.id, {
|
||||||
|
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||||
|
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await testApp.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/v1/services/${service.id}/certificates/check`,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect((res.json() as { checked: number }).checked).toBe(1);
|
||||||
|
const certs = repos.listCertificates(testApp.db);
|
||||||
|
expect(certs.some((c) => c.hostname === "one.check.example.com")).toBe(true);
|
||||||
|
expect(certs.some((c) => c.hostname === "two.check.example.com")).toBe(false);
|
||||||
|
|
||||||
|
await testApp.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -123,11 +123,12 @@ describe("create service then list groups", () => {
|
|||||||
expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe(
|
expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe(
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
expect(
|
const listed = httpParsed.data!.groups
|
||||||
httpParsed.data!.groups
|
.find((g) => g.id === group.id)
|
||||||
.find((g) => g.id === group.id)
|
?.services.find((s) => s.id === created.id);
|
||||||
?.services.some((s) => s.id === created.id),
|
expect(listed).toBeDefined();
|
||||||
).toBe(true);
|
expect(listed?.lb_mode).toBe("round_robin");
|
||||||
|
expect(listed?.active_ips).toEqual(["1.2.3.4"]);
|
||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import { GlobeIcon, SearchIcon } from 'lucide-react'
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { GlobeIcon, SearchIcon, ServerIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
@@ -9,6 +10,7 @@ import { StatusBadge } from '@/components/status-badge'
|
|||||||
import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils'
|
import { renderSingleSelectedLabel } from '@/components/reui-kit/filter-utils'
|
||||||
import type { Certificate } from '@/lib/schemas'
|
import type { Certificate } from '@/lib/schemas'
|
||||||
import { formatDate, formatRelative } from '@/lib/format'
|
import { formatDate, formatRelative } from '@/lib/format'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
|
||||||
export const CERT_TABS = [
|
export const CERT_TABS = [
|
||||||
{ id: 'all', label: 'Все' },
|
{ id: 'all', label: 'Все' },
|
||||||
@@ -41,6 +43,7 @@ export function certTabFilter(item: Certificate, tabId: string) {
|
|||||||
export function createDefaultCertFilters() {
|
export function createDefaultCertFilters() {
|
||||||
return [
|
return [
|
||||||
createFilter('hostname', 'contains', ['']),
|
createFilter('hostname', 'contains', ['']),
|
||||||
|
createFilter('service', 'contains', ['']),
|
||||||
createFilter('status', 'is', ['']),
|
createFilter('status', 'is', ['']),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -56,6 +59,14 @@ export function useCertFilterFields() {
|
|||||||
className: 'w-52',
|
className: 'w-52',
|
||||||
placeholder: 'Поиск по хосту…',
|
placeholder: 'Поиск по хосту…',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'service',
|
||||||
|
label: 'Сервис',
|
||||||
|
icon: <ServerIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
className: 'w-52',
|
||||||
|
placeholder: 'Поиск по сервису…',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
label: 'Статус',
|
label: 'Статус',
|
||||||
@@ -75,6 +86,8 @@ export function certFilterFieldValue(item: Certificate, field: string) {
|
|||||||
switch (field) {
|
switch (field) {
|
||||||
case 'hostname':
|
case 'hostname':
|
||||||
return `${item.hostname} ${item.status}`.toLowerCase()
|
return `${item.hostname} ${item.status}`.toLowerCase()
|
||||||
|
case 'service':
|
||||||
|
return (item.service_name ?? '').toLowerCase()
|
||||||
case 'status':
|
case 'status':
|
||||||
return item.status
|
return item.status
|
||||||
default:
|
default:
|
||||||
@@ -82,7 +95,7 @@ export function certFilterFieldValue(item: Certificate, field: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function certRelativeBadge(status: string, expiresAt: string | null) {
|
export function certRelativeBadge(status: string, expiresAt: string | null) {
|
||||||
const relative = formatRelative(expiresAt)
|
const relative = formatRelative(expiresAt)
|
||||||
if (!expiresAt) {
|
if (!expiresAt) {
|
||||||
return <span className="text-muted-foreground tabular-nums">—</span>
|
return <span className="text-muted-foreground tabular-nums">—</span>
|
||||||
@@ -112,6 +125,39 @@ export function useCertificateColumns() {
|
|||||||
<span className="truncate font-medium">{row.original.hostname}</span>
|
<span className="truncate font-medium">{row.original.hostname}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'service',
|
||||||
|
accessorFn: (row) => row.service_name ?? '',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader
|
||||||
|
column={column}
|
||||||
|
title="Сервис"
|
||||||
|
icon={<ServerIcon className="size-3.5" />}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const serviceId = row.original.service_id
|
||||||
|
const name = row.original.service_name
|
||||||
|
if (serviceId == null || !name) {
|
||||||
|
return <span className="text-muted-foreground">—</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="h-auto max-w-full truncate p-0 font-medium"
|
||||||
|
nativeButton={false}
|
||||||
|
render={
|
||||||
|
<Link
|
||||||
|
to="/services/$serviceId"
|
||||||
|
params={{ serviceId: String(serviceId) }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'status',
|
id: 'status',
|
||||||
header: 'Статус',
|
header: 'Статус',
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import {
|
|||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { ButtonGroup } from '@cfdm/ui/components/button-group'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
import { CableIcon, GlobeIcon } from 'lucide-react'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
import {
|
import {
|
||||||
HealthAggregateTiles,
|
HealthAggregateTiles,
|
||||||
@@ -123,9 +123,6 @@ export function HealthCheckConfigFields({
|
|||||||
const aggregate = value.aggregate ?? 'majority'
|
const aggregate = value.aggregate ?? 'majority'
|
||||||
const isHttp = value.type === 'http'
|
const isHttp = value.type === 'http'
|
||||||
const rowClass = 'gap-3 px-0 py-3'
|
const rowClass = 'gap-3 px-0 py-3'
|
||||||
const hasCloudflare = providers.includes('cloudflare')
|
|
||||||
const hasGlobalping = providers.includes('globalping')
|
|
||||||
const hasLocal = providers.includes('local')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FieldGroup className={cn('gap-0', className)}>
|
<FieldGroup className={cn('gap-0', className)}>
|
||||||
@@ -163,6 +160,7 @@ export function HealthCheckConfigFields({
|
|||||||
compact
|
compact
|
||||||
stacked
|
stacked
|
||||||
className={rowClass}
|
className={rowClass}
|
||||||
|
contentClassName="min-w-0"
|
||||||
>
|
>
|
||||||
<HealthSourceTiles
|
<HealthSourceTiles
|
||||||
value={providers}
|
value={providers}
|
||||||
@@ -176,46 +174,6 @@ export function HealthCheckConfigFields({
|
|||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
||||||
{hasCloudflare ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertTitle>Cloudflare Worker</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Проба с edge Cloudflare, не продукт Health Checks API (на Free его нет).
|
|
||||||
Worker создаётся автоматически и сам опрашивает IP (KV mailbox).
|
|
||||||
Статус деплоя — в{' '}
|
|
||||||
<Link to="/settings/health" className="text-foreground underline">
|
|
||||||
Настройках → Health-check
|
|
||||||
</Link>
|
|
||||||
. Если Worker не создан, этот источник не пробируется как Local.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
{hasGlobalping ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertTitle>Globalping</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Пробы из сети globalping.io (TCP ping / HTTP). Токен, локации и лимит —
|
|
||||||
в{' '}
|
|
||||||
<Link to="/settings/health" className="text-foreground underline">
|
|
||||||
Настройках → Health-check
|
|
||||||
</Link>
|
|
||||||
. Без токена или при 429 этот источник = fail, без fallback на Local.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
{hasLocal ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertTitle>Local health-check</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Проба TCP/HTTP с сервера API. Cron и пороги Slow/Down — в{' '}
|
|
||||||
<Link to="/settings/health" className="text-foreground underline">
|
|
||||||
Настройках → Health-check
|
|
||||||
</Link>
|
|
||||||
. Интервал в карточке не используется.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{providers.length > 1 ? (
|
{providers.length > 1 ? (
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Агрегация"
|
title="Агрегация"
|
||||||
@@ -223,6 +181,7 @@ export function HealthCheckConfigFields({
|
|||||||
compact
|
compact
|
||||||
stacked
|
stacked
|
||||||
className={rowClass}
|
className={rowClass}
|
||||||
|
contentClassName="min-w-0"
|
||||||
>
|
>
|
||||||
<HealthAggregateTiles
|
<HealthAggregateTiles
|
||||||
value={aggregate}
|
value={aggregate}
|
||||||
@@ -258,25 +217,30 @@ export function HealthCheckConfigFields({
|
|||||||
<div className="flex flex-col gap-3 pt-1 pb-1">
|
<div className="flex flex-col gap-3 pt-1 pb-1">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
|
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
|
||||||
<ToggleGroup
|
<ButtonGroup id={`${idPrefix}-type`} className="w-full min-w-0">
|
||||||
id={`${idPrefix}-type`}
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
className="w-full"
|
size="sm"
|
||||||
value={[value.type]}
|
variant={value.type === 'tcp' ? 'secondary' : 'outline'}
|
||||||
onValueChange={(next) => {
|
className="flex-1"
|
||||||
const picked = next[0]
|
aria-pressed={value.type === 'tcp'}
|
||||||
if (picked === 'tcp' || picked === 'http') {
|
onClick={() => patch({ type: 'tcp' })}
|
||||||
patch({ type: picked })
|
>
|
||||||
}
|
<CableIcon data-icon="inline-start" />
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ToggleGroupItem value="tcp" className="flex-1">
|
|
||||||
TCP
|
TCP
|
||||||
</ToggleGroupItem>
|
</Button>
|
||||||
<ToggleGroupItem value="http" className="flex-1">
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={value.type === 'http' ? 'secondary' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
aria-pressed={value.type === 'http'}
|
||||||
|
onClick={() => patch({ type: 'http' })}
|
||||||
|
>
|
||||||
|
<GlobeIcon data-icon="inline-start" />
|
||||||
HTTP
|
HTTP
|
||||||
</ToggleGroupItem>
|
</Button>
|
||||||
</ToggleGroup>
|
</ButtonGroup>
|
||||||
</FormFieldSimple>
|
</FormFieldSimple>
|
||||||
|
|
||||||
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
|
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
|
||||||
|
|||||||
@@ -29,15 +29,21 @@ export interface HealthTimelineEvent {
|
|||||||
|
|
||||||
interface HealthTimelineProps {
|
interface HealthTimelineProps {
|
||||||
events: HealthTimelineEvent[]
|
events: HealthTimelineEvent[]
|
||||||
|
emptyTitle?: string
|
||||||
|
emptyDescription?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HealthTimeline({ events }: HealthTimelineProps) {
|
export function HealthTimeline({
|
||||||
|
events,
|
||||||
|
emptyTitle = 'Нет событий',
|
||||||
|
emptyDescription = 'Результаты проверок появятся после первого прогона',
|
||||||
|
}: HealthTimelineProps) {
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Link2Icon}
|
icon={Link2Icon}
|
||||||
title="Нет событий"
|
title={emptyTitle}
|
||||||
description="Результаты проверок появятся после первого прогона"
|
description={emptyDescription}
|
||||||
centered={false}
|
centered={false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ function getBreadcrumbs(
|
|||||||
return [{ label: 'Панель управления', href: '/' }]
|
return [{ label: 'Панель управления', href: '/' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname.match(/^\/services\/\d+$/)) {
|
||||||
|
return [
|
||||||
|
{ label: 'Сервисы', href: '/services' },
|
||||||
|
{ label: dynamicLabels[pathname] ?? 'Сервис', href: pathname },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname.match(/^\/groups\/\d+$/)) {
|
if (pathname.match(/^\/groups\/\d+$/)) {
|
||||||
return [
|
return [
|
||||||
{ label: 'Группы доменов', href: '/groups' },
|
{ label: 'Группы доменов', href: '/groups' },
|
||||||
|
|||||||
@@ -1,17 +1,56 @@
|
|||||||
import type { KeyboardEvent, ReactNode } from 'react'
|
import type { KeyboardEvent, ReactNode } from 'react'
|
||||||
import { CheckIcon, GlobeIcon, ServerIcon, CloudIcon, LayersIcon, ShieldAlertIcon, ScaleIcon } from 'lucide-react'
|
import { ServerIcon, LayersIcon, ShieldAlertIcon, ScaleIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
import { IconTile } from '@/components/reui/icon-tile'
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@cfdm/ui/components/item'
|
||||||
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
|
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
import type { HealthLogStatus } from '@/lib/health-log'
|
||||||
|
|
||||||
export type HealthProvider = HealthCheckProvider
|
export type HealthProvider = HealthCheckProvider
|
||||||
export type HealthAggregate = HealthCheckAggregate
|
export type HealthAggregate = HealthCheckAggregate
|
||||||
|
|
||||||
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||||
|
|
||||||
const PROVIDER_ITEMS: Array<{
|
/** Official Cloudflare mark (Simple Icons). */
|
||||||
|
function CloudflareMark() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Official Globalping mark (globalping.io favicon). */
|
||||||
|
function GlobalpingMark() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 26 26" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M10.354 6.081a2.636 2.636 0 0 1 4.32.257 31.5 31.5 0 0 1 8.55-1.349A12.97 12.97 0 0 0 3.247 4.42a33.6 33.6 0 0 1 7.107 1.661m13.647.003-.764.02a30.6 30.6 0 0 0-8.235 1.3q.005.08 0 .16c0 .6-.222 1.178-.624 1.624a42 42 0 0 1 2.382 3.695q.478.835.884 1.645h.134c.348-.003.689.102.975.302q3.286-2.798 5.83-7.517l.052-.094m-11.16 2.616a2.65 2.65 0 0 1-2.997-.644c-3.276 1.658-6.702 4.284-9.022 8.45 1.579.6 3.232.982 4.914 1.135a1.566 1.566 0 0 1 2.811.026h.14a16 16 0 0 0 6.935-2.188 1.5 1.5 0 0 1-.097-.52 1.53 1.53 0 0 1 .475-1.105 39 39 0 0 0-3.16-5.154Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M18.866 24.463c.28-2.216-.22-4.582-1.06-6.825h-.019a1.66 1.66 0 0 1-.812-.211 17 17 0 0 1-7.622 2.467h-.14a1.563 1.563 0 0 1-2.902-.026 19 19 0 0 1-4.94-1.086 13 13 0 0 0 17.479 5.801zm6.324-15.97a26.5 26.5 0 0 1-5.82 7.218 1.4 1.4 0 0 1 .049.374 1.55 1.55 0 0 1-.56 1.18c.907 2.417 1.3 4.644 1.174 6.659a12.98 12.98 0 0 0 5.158-15.431Zm-15.258-.28a2.35 2.35 0 0 1-.045-1.108 33 33 0 0 0-7.46-1.658 12.97 12.97 0 0 0-1.829 11.45c2.106-3.682 5.268-6.627 9.334-8.684"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HEALTH_PROVIDER_ITEMS: Array<{
|
||||||
id: HealthProvider
|
id: HealthProvider
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
@@ -29,14 +68,14 @@ const PROVIDER_ITEMS: Array<{
|
|||||||
id: 'cloudflare',
|
id: 'cloudflare',
|
||||||
title: 'Cloudflare',
|
title: 'Cloudflare',
|
||||||
description: 'Worker на edge, KV mailbox',
|
description: 'Worker на edge, KV mailbox',
|
||||||
icon: <CloudIcon />,
|
icon: <CloudflareMark />,
|
||||||
iconClassName: 'text-warning [&_svg]:text-current',
|
iconClassName: 'text-warning [&_svg]:text-current',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'globalping',
|
id: 'globalping',
|
||||||
title: 'Globalping',
|
title: 'Globalping',
|
||||||
description: 'Пробы из сети globalping.io',
|
description: 'Пробы из сети globalping.io',
|
||||||
icon: <GlobeIcon />,
|
icon: <GlobalpingMark />,
|
||||||
iconClassName: 'text-success [&_svg]:text-current',
|
iconClassName: 'text-success [&_svg]:text-current',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -74,13 +113,14 @@ function handleTileKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function TilePanel({
|
function ChoicePanel({
|
||||||
selected,
|
selected,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
icon,
|
icon,
|
||||||
iconClassName,
|
iconClassName,
|
||||||
role,
|
role,
|
||||||
|
trailing,
|
||||||
onActivate,
|
onActivate,
|
||||||
}: {
|
}: {
|
||||||
selected: boolean
|
selected: boolean
|
||||||
@@ -89,47 +129,57 @@ function TilePanel({
|
|||||||
icon: ReactNode
|
icon: ReactNode
|
||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
role: 'checkbox' | 'radio'
|
role: 'checkbox' | 'radio'
|
||||||
|
trailing?: ReactNode
|
||||||
onActivate: () => void
|
onActivate: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<FramePanel
|
<FramePanel
|
||||||
|
fit
|
||||||
role={role}
|
role={role}
|
||||||
aria-checked={selected}
|
aria-checked={selected}
|
||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative isolate flex h-full cursor-pointer flex-col p-3 transition-colors',
|
'min-w-0 cursor-pointer transition-colors',
|
||||||
'hover:bg-muted/40 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
|
'hover:bg-muted/40 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
|
||||||
selected && 'ring-ring ring-1',
|
selected && 'bg-muted/40',
|
||||||
)}
|
)}
|
||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
onKeyDown={(event) => handleTileKeyDown(onActivate, event)}
|
onKeyDown={(event) => handleTileKeyDown(onActivate, event)}
|
||||||
>
|
>
|
||||||
<div className="relative z-10 flex h-full items-start gap-3">
|
<Item size="sm" className="w-full min-w-0 border-0 p-0">
|
||||||
<IconTile
|
<ItemMedia>
|
||||||
variant="elevated"
|
<IconTile
|
||||||
aria-hidden="true"
|
variant="elevated"
|
||||||
className={cn('size-10.5', iconClassName ?? DEFAULT_ICON_CLASS)}
|
aria-hidden="true"
|
||||||
>
|
className={cn('size-10.5', iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
{icon}
|
>
|
||||||
</IconTile>
|
{icon}
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
</IconTile>
|
||||||
<div className="flex items-start justify-between gap-2">
|
</ItemMedia>
|
||||||
<span className="text-foreground text-sm font-medium">{title}</span>
|
<ItemContent className="min-w-0 gap-0.5">
|
||||||
{selected ? (
|
<ItemTitle className="w-full min-w-0">{title}</ItemTitle>
|
||||||
<CheckIcon className="text-foreground size-4 shrink-0" aria-hidden />
|
<ItemDescription>{description}</ItemDescription>
|
||||||
) : null}
|
</ItemContent>
|
||||||
</div>
|
{trailing ? (
|
||||||
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
|
<ItemActions className="shrink-0">{trailing}</ItemActions>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</Item>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChoiceFrame({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Frame stacked spacing="sm" className="w-full min-w-0">
|
||||||
|
{children}
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Мультивыбор источников проб (Local / Cloudflare / Globalping).
|
* Мультивыбор источников проб (Local / Cloudflare / Globalping).
|
||||||
* Preview: https://reui.io/preview/base/card-12
|
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12
|
||||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
||||||
*/
|
*/
|
||||||
export function HealthSourceTiles({
|
export function HealthSourceTiles({
|
||||||
@@ -151,28 +201,33 @@ export function HealthSourceTiles({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className="@container w-full">
|
<ChoiceFrame>
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
{HEALTH_PROVIDER_ITEMS.map((item) => (
|
||||||
{PROVIDER_ITEMS.map((item) => (
|
<ChoicePanel
|
||||||
<TilePanel
|
key={item.id}
|
||||||
key={item.id}
|
selected={selected.includes(item.id)}
|
||||||
selected={selected.includes(item.id)}
|
title={item.title}
|
||||||
title={item.title}
|
description={item.description}
|
||||||
description={item.description}
|
icon={item.icon}
|
||||||
icon={item.icon}
|
iconClassName={item.iconClassName}
|
||||||
iconClassName={item.iconClassName}
|
role="checkbox"
|
||||||
role="checkbox"
|
trailing={
|
||||||
onActivate={() => toggle(item.id)}
|
selected.includes(item.id) ? (
|
||||||
/>
|
<Badge variant="outline" size="sm">
|
||||||
))}
|
Выбрано
|
||||||
</div>
|
</Badge>
|
||||||
</Frame>
|
) : null
|
||||||
|
}
|
||||||
|
onActivate={() => toggle(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ChoiceFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Правило агрегации (ровно одно): any / all / majority.
|
* Правило агрегации (ровно одно): any / all / majority.
|
||||||
* Preview: https://reui.io/preview/base/card-12 · https://reui.io/preview/base/settings-5
|
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/settings-5
|
||||||
*/
|
*/
|
||||||
export function HealthAggregateTiles({
|
export function HealthAggregateTiles({
|
||||||
value,
|
value,
|
||||||
@@ -183,20 +238,80 @@ export function HealthAggregateTiles({
|
|||||||
}) {
|
}) {
|
||||||
const selected = value || 'majority'
|
const selected = value || 'majority'
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className="@container w-full">
|
<ChoiceFrame>
|
||||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
{AGGREGATE_ITEMS.map((item) => (
|
||||||
{AGGREGATE_ITEMS.map((item) => (
|
<ChoicePanel
|
||||||
<TilePanel
|
key={item.id}
|
||||||
key={item.id}
|
selected={selected === item.id}
|
||||||
selected={selected === item.id}
|
title={item.title}
|
||||||
title={item.title}
|
description={item.description}
|
||||||
description={item.description}
|
icon={item.icon}
|
||||||
icon={item.icon}
|
role="radio"
|
||||||
role="radio"
|
trailing={
|
||||||
onActivate={() => onChange(item.id)}
|
selected === item.id ? (
|
||||||
/>
|
<Badge variant="outline" size="sm">
|
||||||
))}
|
Выбрано
|
||||||
</div>
|
</Badge>
|
||||||
</Frame>
|
) : null
|
||||||
|
}
|
||||||
|
onActivate={() => onChange(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ChoiceFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only status tiles for enabled probe sources; click filters the monitor.
|
||||||
|
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12
|
||||||
|
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
||||||
|
*/
|
||||||
|
export function HealthProviderStatusTiles({
|
||||||
|
enabled,
|
||||||
|
selected,
|
||||||
|
statuses,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
enabled: HealthProvider[]
|
||||||
|
selected: HealthProvider[]
|
||||||
|
statuses: Partial<Record<HealthProvider, HealthLogStatus>>
|
||||||
|
onChange: (next: HealthProvider[]) => void
|
||||||
|
}) {
|
||||||
|
const visible = HEALTH_PROVIDER_ITEMS.filter((item) => enabled.includes(item.id))
|
||||||
|
if (visible.length === 0) return null
|
||||||
|
|
||||||
|
const active = selected.length > 0 ? selected : enabled
|
||||||
|
|
||||||
|
function toggle(id: HealthProvider) {
|
||||||
|
if (active.includes(id)) {
|
||||||
|
if (active.length === 1) return
|
||||||
|
onChange(active.filter((item) => item !== id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange([...active, id])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChoiceFrame>
|
||||||
|
{visible.map((item) => (
|
||||||
|
<ChoicePanel
|
||||||
|
key={item.id}
|
||||||
|
selected={active.includes(item.id)}
|
||||||
|
title={item.title}
|
||||||
|
description={item.description}
|
||||||
|
icon={item.icon}
|
||||||
|
iconClassName={item.iconClassName}
|
||||||
|
role="checkbox"
|
||||||
|
trailing={
|
||||||
|
<HealthCheckBadge
|
||||||
|
status={statuses[item.id] ?? 'unknown'}
|
||||||
|
provider={item.id}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onActivate={() => toggle(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ChoiceFrame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export { UptimeChart, type UptimeProbe, type UptimePeriodKey } from './uptime-chart'
|
||||||
|
export { ServiceHealthMonitor } from './service-health-monitor'
|
||||||
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||||
@@ -21,6 +23,7 @@ export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
|||||||
export {
|
export {
|
||||||
HealthSourceTiles,
|
HealthSourceTiles,
|
||||||
HealthAggregateTiles,
|
HealthAggregateTiles,
|
||||||
|
HealthProviderStatusTiles,
|
||||||
type HealthProvider,
|
type HealthProvider,
|
||||||
type HealthAggregate,
|
type HealthAggregate,
|
||||||
} from './health-source-tiles'
|
} from './health-source-tiles'
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { HealthTimeline } from '@/components/health/health-timeline'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { UptimeChart, UPTIME_PERIODS, type UptimePeriodKey } from '@/components/reui-kit/uptime-chart'
|
||||||
|
import {
|
||||||
|
collapseStatusChanges,
|
||||||
|
filterByPeriod,
|
||||||
|
filterByProviders,
|
||||||
|
type HealthLogProbe,
|
||||||
|
} from '@/lib/health-log'
|
||||||
|
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined uptime chart + status-change timeline (solution-analytics-8 DNA).
|
||||||
|
* Preview: https://reui.io/preview/base/solution-analytics-8 · https://reui.io/preview/base/chart-17
|
||||||
|
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/timeline
|
||||||
|
*/
|
||||||
|
export function ServiceHealthMonitor({
|
||||||
|
items,
|
||||||
|
selectedProviders,
|
||||||
|
isLoading = false,
|
||||||
|
}: {
|
||||||
|
items: HealthLogProbe[]
|
||||||
|
selectedProviders: readonly HealthCheckProvider[]
|
||||||
|
isLoading?: boolean
|
||||||
|
}) {
|
||||||
|
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
|
||||||
|
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => filterByProviders(filterByPeriod(items, days), selectedProviders),
|
||||||
|
[items, days, selectedProviders],
|
||||||
|
)
|
||||||
|
|
||||||
|
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
||||||
|
<UptimeChart
|
||||||
|
items={filtered}
|
||||||
|
isLoading={isLoading}
|
||||||
|
period={period}
|
||||||
|
onPeriodChange={setPeriod}
|
||||||
|
skipPeriodFilter
|
||||||
|
embedded
|
||||||
|
/>
|
||||||
|
<FramePanel className="flex flex-col gap-3">
|
||||||
|
<FrameHeader className="px-0 py-0">
|
||||||
|
<FrameTitle>Смены статуса</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Только переходы up / degraded / down · Cloudflare = Worker, не Health Checks API
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<HealthTimeline
|
||||||
|
events={changes.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
hostname: row.ip,
|
||||||
|
type: row.provider,
|
||||||
|
status: row.status,
|
||||||
|
latency_ms: row.latency_ms,
|
||||||
|
error: row.error,
|
||||||
|
checked_at: row.checked_at,
|
||||||
|
colo: row.colo,
|
||||||
|
provider: row.provider,
|
||||||
|
}))}
|
||||||
|
emptyTitle="Нет смен статуса"
|
||||||
|
emptyDescription="События появятся при переходе up / degraded / down"
|
||||||
|
/>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { useEffect, useId, useMemo, useState } from 'react'
|
||||||
|
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
|
||||||
|
import { Area, AreaChart, XAxis } from 'recharts'
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import { filterByPeriod, probeTime } from '@/lib/health-log'
|
||||||
|
import { formatDate } from '@/lib/format'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from '@cfdm/ui/components/chart'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs).
|
||||||
|
* Preview: https://reui.io/preview/base/chart-17
|
||||||
|
* Frame: https://reui.io/docs/components/base/frame
|
||||||
|
* Chart: shadcn Chart + Recharts AreaChart
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UptimeProbe {
|
||||||
|
id: number
|
||||||
|
status: 'up' | 'down' | 'degraded' | 'unknown'
|
||||||
|
ok: boolean
|
||||||
|
latency_ms: number | null
|
||||||
|
checked_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UptimePeriodKey = '5D' | '2W' | '1M'
|
||||||
|
|
||||||
|
export const UPTIME_PERIODS: { key: UptimePeriodKey; label: string; days: number }[] = [
|
||||||
|
{ key: '5D', label: '5D', days: 5 },
|
||||||
|
{ key: '2W', label: '2W', days: 14 },
|
||||||
|
{ key: '1M', label: '1M', days: 30 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
latency: {
|
||||||
|
label: 'Задержка',
|
||||||
|
color: 'var(--chart-1)',
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
interface ChartPoint {
|
||||||
|
period: string
|
||||||
|
latency: number
|
||||||
|
ok: boolean
|
||||||
|
at: string
|
||||||
|
status: UptimeProbe['status']
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSeries(items: UptimeProbe[]): ChartPoint[] {
|
||||||
|
return [...items]
|
||||||
|
.sort((a, b) => probeTime(a.checked_at) - probeTime(b.checked_at))
|
||||||
|
.map((item) => ({
|
||||||
|
period: formatDate(item.checked_at),
|
||||||
|
latency: item.latency_ms ?? 0,
|
||||||
|
ok: item.ok && item.status !== 'down',
|
||||||
|
at: item.checked_at,
|
||||||
|
status: item.status,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function uptimePercent(points: ChartPoint[]): number | null {
|
||||||
|
if (points.length === 0) return null
|
||||||
|
const okCount = points.filter((point) => point.ok).length
|
||||||
|
return (okCount / points.length) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function deltaPercent(points: ChartPoint[]): number | null {
|
||||||
|
if (points.length < 4) return null
|
||||||
|
const mid = Math.floor(points.length / 2)
|
||||||
|
const prev = uptimePercent(points.slice(0, mid))
|
||||||
|
const next = uptimePercent(points.slice(mid))
|
||||||
|
if (prev == null || next == null) return null
|
||||||
|
return next - prev
|
||||||
|
}
|
||||||
|
|
||||||
|
function UptimeDelta({ delta }: { delta: number }) {
|
||||||
|
if (Math.abs(delta) < 0.05) {
|
||||||
|
return <span className="text-muted-foreground">без изменений за период</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta > 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TrendingUpIcon className="text-success size-4" aria-hidden="true" />
|
||||||
|
<span className="text-success font-medium">+{delta.toFixed(1)} п.п.</span>
|
||||||
|
<span className="text-muted-foreground">с начала периода</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TrendingDownIcon className="text-destructive size-4" aria-hidden="true" />
|
||||||
|
<span className="text-destructive font-medium">{delta.toFixed(1)} п.п.</span>
|
||||||
|
<span className="text-muted-foreground">с начала периода</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UptimeChartProps {
|
||||||
|
items: UptimeProbe[]
|
||||||
|
isLoading?: boolean
|
||||||
|
period?: UptimePeriodKey
|
||||||
|
onPeriodChange?: (period: UptimePeriodKey) => void
|
||||||
|
skipPeriodFilter?: boolean
|
||||||
|
embedded?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UptimeChart({
|
||||||
|
items,
|
||||||
|
isLoading = false,
|
||||||
|
period: periodProp,
|
||||||
|
onPeriodChange,
|
||||||
|
skipPeriodFilter = false,
|
||||||
|
embedded = false,
|
||||||
|
}: UptimeChartProps) {
|
||||||
|
const gradientId = useId().replace(/:/g, '')
|
||||||
|
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
|
||||||
|
const [tooltipPortal, setTooltipPortal] = useState<HTMLElement | null>(null)
|
||||||
|
const period = periodProp ?? internalPeriod
|
||||||
|
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTooltipPortal(document.body)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function handlePeriodChange(next: UptimePeriodKey) {
|
||||||
|
onPeriodChange?.(next)
|
||||||
|
if (periodProp == null) setInternalPeriod(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = useMemo(
|
||||||
|
() => toSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
|
||||||
|
[items, days, skipPeriodFilter],
|
||||||
|
)
|
||||||
|
const uptime = uptimePercent(points)
|
||||||
|
const delta = deltaPercent(points)
|
||||||
|
const lastOk = points.at(-1)?.ok ?? true
|
||||||
|
const tileClass = lastOk ? 'text-success' : 'text-destructive'
|
||||||
|
|
||||||
|
const panel = (
|
||||||
|
<FramePanel className="flex flex-col gap-6">
|
||||||
|
<div className="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
className={`size-10.5 ${tileClass}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<ActivityIcon />
|
||||||
|
</IconTile>
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
<h3 className="text-base font-semibold">Uptime</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Пробы health-check за период
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TooltipProvider delay={150}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
aria-label="О графике uptime"
|
||||||
|
className="text-muted-foreground/70 -mr-1"
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InfoIcon data-icon="inline-start" aria-hidden="true" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" sideOffset={8}>
|
||||||
|
<p>Доля успешных проб и задержка (мс) по журналу health-log.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="bg-muted h-40 w-full animate-pulse rounded-xl" />
|
||||||
|
) : points.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ActivityIcon}
|
||||||
|
title="Нет проб за период"
|
||||||
|
description="Результаты появятся после health-check"
|
||||||
|
stackedIcon={false}
|
||||||
|
centered={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="text-foreground text-3xl font-semibold tabular-nums">
|
||||||
|
{uptime == null ? '—' : `${uptime.toFixed(uptime >= 99.95 ? 2 : 1)}%`}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{delta == null ? (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{points.length} проб
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<UptimeDelta delta={delta} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-40 w-full overflow-visible">
|
||||||
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className="h-full w-full overflow-visible rounded-b-xl"
|
||||||
|
initialDimension={{ width: 320, height: 160 }}
|
||||||
|
>
|
||||||
|
<AreaChart
|
||||||
|
data={points}
|
||||||
|
margin={{ top: 16, left: 8, right: 8, bottom: 4 }}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor="var(--color-latency)"
|
||||||
|
stopOpacity={0.8}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor="var(--color-latency)"
|
||||||
|
stopOpacity={0.1}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis dataKey="period" hide />
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
|
||||||
|
allowEscapeViewBox={{ x: true, y: true }}
|
||||||
|
portal={tooltipPortal ?? undefined}
|
||||||
|
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
formatter={(value, _name, item) => {
|
||||||
|
const point = item.payload as ChartPoint | undefined
|
||||||
|
const ping = Number(value)
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{point?.ok === false ? 'Down' : 'Пинг'}
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{Number.isFinite(ping) ? `${ping} мс` : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
dataKey="latency"
|
||||||
|
name="latency"
|
||||||
|
type="natural"
|
||||||
|
fill={`url(#${gradientId})`}
|
||||||
|
stroke="var(--color-latency)"
|
||||||
|
strokeWidth={2}
|
||||||
|
isAnimationActive={false}
|
||||||
|
dot={(dotProps) => {
|
||||||
|
const { cx, cy, payload, index } = dotProps
|
||||||
|
if (cx == null || cy == null) return <g key={index} />
|
||||||
|
const point = payload as ChartPoint | undefined
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={index}
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={4}
|
||||||
|
fill={
|
||||||
|
point?.ok
|
||||||
|
? 'var(--color-latency)'
|
||||||
|
: 'var(--destructive)'
|
||||||
|
}
|
||||||
|
stroke="var(--background)"
|
||||||
|
strokeWidth={2}
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
activeDot={{
|
||||||
|
r: 6,
|
||||||
|
stroke: 'var(--background)',
|
||||||
|
strokeWidth: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={period}
|
||||||
|
onValueChange={(value) => handlePeriodChange(value as UptimePeriodKey)}
|
||||||
|
>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
{UPTIME_PERIODS.map((entry) => (
|
||||||
|
<TabsTrigger key={entry.key} value={entry.key} className="flex-1">
|
||||||
|
{entry.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (embedded) return panel
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame spacing="sm" className="min-w-0 w-full">
|
||||||
|
{panel}
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,793 @@
|
|||||||
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
|
import {
|
||||||
|
GlobeIcon,
|
||||||
|
NetworkIcon,
|
||||||
|
PlusIcon,
|
||||||
|
SearchIcon,
|
||||||
|
ServerIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||||
|
import { ResourcePage } from '@/components/reui-kit'
|
||||||
|
import { certRelativeBadge } from '@/components/columns/certificates-columns'
|
||||||
|
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||||
|
import { formatDate } from '@/lib/format'
|
||||||
|
import type { ServiceCertificateRow, ServiceView } from '@/lib/schemas'
|
||||||
|
import type { CertMonitoring } from '@cfdm/shared'
|
||||||
|
import {
|
||||||
|
certKeys,
|
||||||
|
checkServiceCertificates,
|
||||||
|
patchBindingCertMonitoring,
|
||||||
|
serviceCertificatesQueryOptions,
|
||||||
|
} from '@/queries'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from '@cfdm/ui/components/toggle-group'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
|
||||||
|
type HealthStatus = 'up' | 'down' | 'degraded' | 'unknown'
|
||||||
|
|
||||||
|
interface ServiceIpRow {
|
||||||
|
id: string
|
||||||
|
ip: string
|
||||||
|
status: HealthStatus
|
||||||
|
enabled: boolean
|
||||||
|
active: boolean
|
||||||
|
weight: number
|
||||||
|
priority: number
|
||||||
|
latency_ms: number | null
|
||||||
|
last_checked_at: string | null
|
||||||
|
last_error: string | null
|
||||||
|
colo: string | null
|
||||||
|
provider: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceFqdnRow {
|
||||||
|
id: string
|
||||||
|
fqdn: string
|
||||||
|
zone_name: string
|
||||||
|
target_ips: string[]
|
||||||
|
binding_id: number
|
||||||
|
domain_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceNodeRow {
|
||||||
|
id: string
|
||||||
|
nodeId: number
|
||||||
|
address: string
|
||||||
|
protocol: string
|
||||||
|
port: number | null
|
||||||
|
health_status: HealthStatus
|
||||||
|
weight: number
|
||||||
|
priority: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'ip', label: 'IP' },
|
||||||
|
{ id: 'fqdn', label: 'FQDN' },
|
||||||
|
{ id: 'nodes', label: 'Ноды' },
|
||||||
|
{ id: 'ssl', label: 'SSL' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const HEALTH_OPTIONS = [
|
||||||
|
{ value: 'up', label: 'OK' },
|
||||||
|
{ value: 'degraded', label: 'Slow' },
|
||||||
|
{ value: 'down', label: 'Down' },
|
||||||
|
{ value: 'unknown', label: '—' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function mapNodeHealth(status: string): HealthStatus {
|
||||||
|
if (status === 'healthy' || status === 'up') return 'up'
|
||||||
|
if (status === 'unhealthy' || status === 'down') return 'down'
|
||||||
|
if (status === 'degraded') return 'degraded'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIpRows(service: ServiceView): ServiceIpRow[] {
|
||||||
|
const healthByIp = new Map(service.ip_health.map((row) => [row.ip, row]))
|
||||||
|
const weights = Object.assign(
|
||||||
|
{},
|
||||||
|
...service.domains.map((domain) => domain.target_ip_weights ?? {}),
|
||||||
|
) as Record<string, number>
|
||||||
|
const priorities = Object.assign(
|
||||||
|
{},
|
||||||
|
...service.domains.map((domain) => domain.target_ip_priorities ?? {}),
|
||||||
|
) as Record<string, number>
|
||||||
|
const activeSet = new Set(service.active_ips)
|
||||||
|
|
||||||
|
return service.ips.map((ip) => {
|
||||||
|
const health = healthByIp.get(ip)
|
||||||
|
return {
|
||||||
|
id: ip,
|
||||||
|
ip,
|
||||||
|
status: health?.status ?? 'unknown',
|
||||||
|
enabled: service.ip_enabled[ip] !== false,
|
||||||
|
active: activeSet.has(ip),
|
||||||
|
weight: weights[ip] ?? 1,
|
||||||
|
priority: priorities[ip] ?? 1,
|
||||||
|
latency_ms: health?.latency_ms ?? null,
|
||||||
|
last_checked_at: health?.last_checked_at ?? null,
|
||||||
|
last_error: health?.last_error ?? null,
|
||||||
|
colo: health?.colo ?? null,
|
||||||
|
provider: health?.provider ?? null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFqdnRows(service: ServiceView): ServiceFqdnRow[] {
|
||||||
|
return service.domains.map((domain) => ({
|
||||||
|
id: String(domain.binding_id),
|
||||||
|
fqdn: domain.fqdn,
|
||||||
|
zone_name: domain.zone_name,
|
||||||
|
target_ips: domain.target_ips ?? [],
|
||||||
|
binding_id: domain.binding_id,
|
||||||
|
domain_id: domain.domain_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNodeRows(
|
||||||
|
nodes: Array<{
|
||||||
|
id: number
|
||||||
|
address: string
|
||||||
|
protocol: string
|
||||||
|
port: number | null
|
||||||
|
health_status: string
|
||||||
|
weight: number
|
||||||
|
priority: number
|
||||||
|
}>,
|
||||||
|
): ServiceNodeRow[] {
|
||||||
|
return nodes.map((node) => ({
|
||||||
|
id: String(node.id),
|
||||||
|
nodeId: node.id,
|
||||||
|
address: node.address,
|
||||||
|
protocol: node.protocol,
|
||||||
|
port: node.port,
|
||||||
|
health_status: mapNodeHealth(node.health_status),
|
||||||
|
weight: node.weight,
|
||||||
|
priority: node.priority,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function NameCell({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
iconClassName,
|
||||||
|
}: {
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
iconClassName?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="xs"
|
||||||
|
className={iconClassName ?? 'text-muted-foreground'}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</IconTile>
|
||||||
|
<span className="truncate font-mono text-sm">{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceDetailGridProps {
|
||||||
|
service: ServiceView
|
||||||
|
nodes: Array<{
|
||||||
|
id: number
|
||||||
|
address: string
|
||||||
|
protocol: string
|
||||||
|
port: number | null
|
||||||
|
health_status: string
|
||||||
|
weight: number
|
||||||
|
priority: number
|
||||||
|
}>
|
||||||
|
togglingIp: string | null
|
||||||
|
onToggleIp: (ip: string, enabled: boolean) => void
|
||||||
|
onChangeIp: (row: ServiceFqdnRow) => void
|
||||||
|
onChangeDomain: () => void
|
||||||
|
onAddNode: () => void
|
||||||
|
onDeleteNode: (nodeId: number) => void
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceDetailGrid({
|
||||||
|
service,
|
||||||
|
nodes,
|
||||||
|
togglingIp,
|
||||||
|
onToggleIp,
|
||||||
|
onChangeIp,
|
||||||
|
onChangeDomain,
|
||||||
|
onAddNode,
|
||||||
|
onDeleteNode,
|
||||||
|
isLoading = false,
|
||||||
|
}: ServiceDetailGridProps) {
|
||||||
|
const [tab, setTab] = useState<(typeof TABS)[number]['id']>('ip')
|
||||||
|
const [ipFilters, setIpFilters] = useState<Filter[]>(() => [
|
||||||
|
createFilter('ip', 'contains', ['']),
|
||||||
|
createFilter('status', 'is', ['']),
|
||||||
|
])
|
||||||
|
const [fqdnFilters, setFqdnFilters] = useState<Filter[]>(() => [
|
||||||
|
createFilter('fqdn', 'contains', ['']),
|
||||||
|
])
|
||||||
|
const [nodeFilters, setNodeFilters] = useState<Filter[]>(() => [
|
||||||
|
createFilter('address', 'contains', ['']),
|
||||||
|
createFilter('health_status', 'is', ['']),
|
||||||
|
])
|
||||||
|
const [sslFilters, setSslFilters] = useState<Filter[]>(() => [
|
||||||
|
createFilter('hostname', 'contains', ['']),
|
||||||
|
])
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const certQuery = useQuery(serviceCertificatesQueryOptions(service.id))
|
||||||
|
const sslRows = certQuery.data ?? []
|
||||||
|
|
||||||
|
const patchCertMonitoring = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
bindingId,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
bindingId: number
|
||||||
|
mode: CertMonitoring
|
||||||
|
}) => patchBindingCertMonitoring(bindingId, mode),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: certKeys.byService(service.id) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: certKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: certKeys.summary }),
|
||||||
|
])
|
||||||
|
toast.success('Режим проверки SSL обновлён')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : 'Не удалось обновить режим SSL',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkSsl = useMutation({
|
||||||
|
mutationFn: () => checkServiceCertificates(service.id),
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: certKeys.byService(service.id) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: certKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: certKeys.summary }),
|
||||||
|
])
|
||||||
|
toast.success(
|
||||||
|
result.checked > 0
|
||||||
|
? `Проверено FQDN: ${result.checked}`
|
||||||
|
: 'Нет FQDN для проверки SSL',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : 'Не удалось проверить SSL',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ipRows = useMemo(() => buildIpRows(service), [service])
|
||||||
|
const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
|
||||||
|
const nodeRows = useMemo(() => buildNodeRows(nodes), [nodes])
|
||||||
|
const markActive = service.lb_mode === 'failover' || service.lb_mode === 'weighted'
|
||||||
|
|
||||||
|
const tabs = TABS.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
count:
|
||||||
|
entry.id === 'ip'
|
||||||
|
? ipRows.length
|
||||||
|
: entry.id === 'fqdn'
|
||||||
|
? fqdnRows.length
|
||||||
|
: entry.id === 'ssl'
|
||||||
|
? sslRows.length
|
||||||
|
: nodeRows.length,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const ipFilterFields = useMemo<FilterFieldConfig[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'ip',
|
||||||
|
label: 'IP',
|
||||||
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
className: 'w-52',
|
||||||
|
placeholder: 'Поиск по IP…',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Статус',
|
||||||
|
type: 'select',
|
||||||
|
searchable: true,
|
||||||
|
className: 'w-[168px]',
|
||||||
|
options: HEALTH_OPTIONS,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const fqdnFilterFields = useMemo<FilterFieldConfig[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'fqdn',
|
||||||
|
label: 'FQDN',
|
||||||
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
className: 'w-52',
|
||||||
|
placeholder: 'Поиск по FQDN…',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const nodeFilterFields = useMemo<FilterFieldConfig[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'address',
|
||||||
|
label: 'Адрес',
|
||||||
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
className: 'w-52',
|
||||||
|
placeholder: 'Поиск по адресу…',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'health_status',
|
||||||
|
label: 'Статус',
|
||||||
|
type: 'select',
|
||||||
|
searchable: true,
|
||||||
|
className: 'w-[168px]',
|
||||||
|
options: HEALTH_OPTIONS,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sslFilterFields = useMemo<FilterFieldConfig[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'hostname',
|
||||||
|
label: 'FQDN',
|
||||||
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
className: 'w-52',
|
||||||
|
placeholder: 'Поиск по FQDN…',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const ipColumns = useMemo<ColumnDef<ServiceIpRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'ip',
|
||||||
|
accessorKey: 'ip',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="IP" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<NameCell
|
||||||
|
icon={<NetworkIcon />}
|
||||||
|
label={row.original.ip}
|
||||||
|
iconClassName="text-info"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Health',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<HealthCheckBadge
|
||||||
|
status={row.original.status}
|
||||||
|
latencyMs={row.original.latency_ms}
|
||||||
|
lastCheckedAt={row.original.last_checked_at}
|
||||||
|
lastError={row.original.last_error}
|
||||||
|
colo={row.original.colo}
|
||||||
|
provider={row.original.provider}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'active',
|
||||||
|
header: 'Пул',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
markActive && row.original.active ? (
|
||||||
|
<StatusBadge status="active" />
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'weight',
|
||||||
|
accessorKey: 'weight',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Вес" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{service.lb_mode === 'weighted' ? `w${row.original.weight}` : row.original.weight}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'priority',
|
||||||
|
accessorKey: 'priority',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Приоритет" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums">{row.original.priority}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'enabled',
|
||||||
|
header: 'Вкл',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
checked={row.original.enabled}
|
||||||
|
disabled={togglingIp === row.original.ip}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onToggleIp(row.original.ip, Boolean(checked))
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
row.original.enabled
|
||||||
|
? `Выключить IP ${row.original.ip}`
|
||||||
|
: `Включить IP ${row.original.ip}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[markActive, onToggleIp, service.lb_mode, togglingIp],
|
||||||
|
)
|
||||||
|
|
||||||
|
const fqdnColumns = useMemo<ColumnDef<ServiceFqdnRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'fqdn',
|
||||||
|
accessorKey: 'fqdn',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="FQDN" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<NameCell
|
||||||
|
icon={<GlobeIcon />}
|
||||||
|
label={row.original.fqdn}
|
||||||
|
iconClassName="text-foreground"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zone',
|
||||||
|
accessorKey: 'zone_name',
|
||||||
|
header: 'Зона',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ips',
|
||||||
|
header: 'Target IP',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
{row.original.target_ips.join(', ') || '—'}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" size="xs">
|
||||||
|
{row.original.target_ips.length} IP
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onChangeIp(row.original)}
|
||||||
|
>
|
||||||
|
Сменить IP
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onChangeIp],
|
||||||
|
)
|
||||||
|
|
||||||
|
const nodeColumns = useMemo<ColumnDef<ServiceNodeRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'address',
|
||||||
|
accessorKey: 'address',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Адрес" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<NameCell
|
||||||
|
icon={<ServerIcon />}
|
||||||
|
label={row.original.address}
|
||||||
|
iconClassName="text-foreground"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'health',
|
||||||
|
accessorKey: 'health_status',
|
||||||
|
header: 'Health',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<HealthCheckBadge status={row.original.health_status} size="xs" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'meta',
|
||||||
|
header: 'Вес / приоритет',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
{row.original.protocol}
|
||||||
|
{row.original.port ? `:${row.original.port}` : ''} · w
|
||||||
|
{row.original.weight} · p{row.original.priority}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: '',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onDeleteNode(row.original.nodeId)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[onDeleteNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sslColumns = useMemo<ColumnDef<ServiceCertificateRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'hostname',
|
||||||
|
accessorKey: 'hostname',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="FQDN" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<NameCell
|
||||||
|
icon={<ShieldCheckIcon />}
|
||||||
|
label={row.original.hostname}
|
||||||
|
iconClassName="text-foreground"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
header: 'Статус',
|
||||||
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'expires_at',
|
||||||
|
accessorKey: 'expires_at',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Истекает" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground tabular-nums">
|
||||||
|
{formatDate(row.original.expires_at)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'relative',
|
||||||
|
header: 'Срок',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
certRelativeBadge(row.original.status, row.original.expires_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mode',
|
||||||
|
header: 'Режим',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<ToggleGroup
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
value={[row.original.cert_monitoring]}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
const value = Array.isArray(next) ? next[0] : next
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
value === row.original.cert_monitoring
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
patchCertMonitoring.mutate({
|
||||||
|
bindingId: row.original.binding_id,
|
||||||
|
mode: value as CertMonitoring,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{certMonitoringOptions.map((option) => (
|
||||||
|
<ToggleGroupItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'last_checked_at',
|
||||||
|
accessorKey: 'last_checked_at',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Проверка" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground tabular-nums">
|
||||||
|
{formatDate(row.original.last_checked_at)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[patchCertMonitoring],
|
||||||
|
)
|
||||||
|
|
||||||
|
const sharedTabs = {
|
||||||
|
tabs,
|
||||||
|
activeTab: tab,
|
||||||
|
onTabChange: (id: string) => setTab(id as typeof tab),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab === 'fqdn') {
|
||||||
|
return (
|
||||||
|
<ResourcePage
|
||||||
|
title="Активы сервиса"
|
||||||
|
description="IP, FQDN и ноды этого сервиса"
|
||||||
|
{...sharedTabs}
|
||||||
|
filterFields={fqdnFilterFields}
|
||||||
|
filters={fqdnFilters}
|
||||||
|
onFiltersChange={setFqdnFilters}
|
||||||
|
onClearFilters={() => setFqdnFilters([createFilter('fqdn', 'contains', [''])])}
|
||||||
|
getFilterFieldValue={(item, field) =>
|
||||||
|
field === 'fqdn' ? `${item.fqdn} ${item.zone_name}` : ''
|
||||||
|
}
|
||||||
|
columns={fqdnColumns}
|
||||||
|
data={fqdnRows}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
isLoading={isLoading}
|
||||||
|
primaryAction={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onChangeDomain}
|
||||||
|
disabled={fqdnRows.length === 0}
|
||||||
|
>
|
||||||
|
Сменить домен
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab === 'nodes') {
|
||||||
|
return (
|
||||||
|
<ResourcePage
|
||||||
|
title="Активы сервиса"
|
||||||
|
description="IP, FQDN и ноды этого сервиса"
|
||||||
|
{...sharedTabs}
|
||||||
|
filterFields={nodeFilterFields}
|
||||||
|
filters={nodeFilters}
|
||||||
|
onFiltersChange={setNodeFilters}
|
||||||
|
onClearFilters={() =>
|
||||||
|
setNodeFilters([
|
||||||
|
createFilter('address', 'contains', ['']),
|
||||||
|
createFilter('health_status', 'is', ['']),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
getFilterFieldValue={(item, field) => {
|
||||||
|
if (field === 'address') return item.address
|
||||||
|
if (field === 'health_status') return item.health_status
|
||||||
|
return ''
|
||||||
|
}}
|
||||||
|
columns={nodeColumns}
|
||||||
|
data={nodeRows}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
isLoading={isLoading}
|
||||||
|
primaryAction={
|
||||||
|
<Button size="sm" onClick={onAddNode}>
|
||||||
|
<PlusIcon className="size-4" aria-hidden />
|
||||||
|
Добавить ноду
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab === 'ssl') {
|
||||||
|
return (
|
||||||
|
<ResourcePage
|
||||||
|
title="Активы сервиса"
|
||||||
|
description="IP, FQDN, ноды и SSL этого сервиса"
|
||||||
|
{...sharedTabs}
|
||||||
|
filterFields={sslFilterFields}
|
||||||
|
filters={sslFilters}
|
||||||
|
onFiltersChange={setSslFilters}
|
||||||
|
onClearFilters={() =>
|
||||||
|
setSslFilters([createFilter('hostname', 'contains', [''])])
|
||||||
|
}
|
||||||
|
getFilterFieldValue={(item, field) =>
|
||||||
|
field === 'hostname' ? item.hostname : ''
|
||||||
|
}
|
||||||
|
columns={sslColumns}
|
||||||
|
data={sslRows}
|
||||||
|
getRowId={(row) => String(row.binding_id)}
|
||||||
|
isLoading={isLoading || certQuery.isLoading}
|
||||||
|
primaryAction={
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Проверить SSL"
|
||||||
|
disabled={checkSsl.isPending || sslRows.length === 0}
|
||||||
|
onClick={() => checkSsl.mutate()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ShieldCheckIcon className="size-4.5" aria-hidden />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Проверить</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
emptyState={{
|
||||||
|
title: 'Нет FQDN',
|
||||||
|
description: 'Привяжите домен к сервису, чтобы мониторить SSL.',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResourcePage
|
||||||
|
title="Активы сервиса"
|
||||||
|
description="IP, FQDN и ноды этого сервиса"
|
||||||
|
{...sharedTabs}
|
||||||
|
filterFields={ipFilterFields}
|
||||||
|
filters={ipFilters}
|
||||||
|
onFiltersChange={setIpFilters}
|
||||||
|
onClearFilters={() =>
|
||||||
|
setIpFilters([
|
||||||
|
createFilter('ip', 'contains', ['']),
|
||||||
|
createFilter('status', 'is', ['']),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
getFilterFieldValue={(item, field) => {
|
||||||
|
if (field === 'ip') return item.ip
|
||||||
|
if (field === 'status') return item.status
|
||||||
|
return ''
|
||||||
|
}}
|
||||||
|
columns={ipColumns}
|
||||||
|
data={ipRows}
|
||||||
|
getRowId={(row) => row.id}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,12 +2,20 @@ import { CheckIcon, CopyIcon } from 'lucide-react'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { TruncatedText } from '@/components/truncated-text'
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
||||||
import type { ServiceView } from '@/lib/schemas'
|
import type { ServiceView } from '@/lib/schemas'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemGroup,
|
||||||
|
ItemMedia,
|
||||||
|
} from '@cfdm/ui/components/item'
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -17,6 +25,9 @@ import {
|
|||||||
} from '@cfdm/ui/components/tooltip'
|
} from '@cfdm/ui/components/tooltip'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
/** Matches `Button size="icon-sm"` so Switch columns align with the card menu. */
|
||||||
|
const MENU_SLOT_CLASS = 'size-7 shrink-0'
|
||||||
|
|
||||||
export function CopyFqdnButton({
|
export function CopyFqdnButton({
|
||||||
value,
|
value,
|
||||||
className,
|
className,
|
||||||
@@ -126,6 +137,11 @@ interface ServiceIpListProps {
|
|||||||
togglingIp?: string | null
|
togglingIp?: string | null
|
||||||
ipToggleDisabled?: boolean
|
ipToggleDisabled?: boolean
|
||||||
onToggleIp?: (ip: string, enabled: boolean) => void
|
onToggleIp?: (ip: string, enabled: boolean) => void
|
||||||
|
/** Invisible icon-sm slot so IP Switch lines up with the card overflow menu. */
|
||||||
|
alignWithMenu?: boolean
|
||||||
|
lbMode?: ServiceView['lb_mode']
|
||||||
|
activeIps?: string[]
|
||||||
|
ipWeights?: Record<string, number>
|
||||||
className?: string
|
className?: string
|
||||||
emptyLabel?: string
|
emptyLabel?: string
|
||||||
copyable?: boolean
|
copyable?: boolean
|
||||||
@@ -139,6 +155,10 @@ export function ServiceIpList({
|
|||||||
togglingIp = null,
|
togglingIp = null,
|
||||||
ipToggleDisabled = false,
|
ipToggleDisabled = false,
|
||||||
onToggleIp,
|
onToggleIp,
|
||||||
|
alignWithMenu = false,
|
||||||
|
lbMode,
|
||||||
|
activeIps = [],
|
||||||
|
ipWeights = {},
|
||||||
className,
|
className,
|
||||||
emptyLabel = 'Нет IP',
|
emptyLabel = 'Нет IP',
|
||||||
copyable = false,
|
copyable = false,
|
||||||
@@ -155,49 +175,83 @@ export function ServiceIpList({
|
|||||||
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
|
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
|
||||||
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
|
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
|
||||||
const extraCount = ips.length - visible.length
|
const extraCount = ips.length - visible.length
|
||||||
|
const showMenuSlot = Boolean(onToggleIp && alignWithMenu)
|
||||||
|
const markActive = lbMode === 'failover' || lbMode === 'weighted'
|
||||||
|
const activeSet = new Set(activeIps)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex min-w-0 flex-col gap-1', className)}>
|
<ItemGroup className={cn('gap-1', className)}>
|
||||||
{visible.map((ip) => {
|
{visible.map((ip) => {
|
||||||
const health = healthByIp.get(ip)
|
const health = healthByIp.get(ip)
|
||||||
const enabled = ipEnabled[ip] !== false
|
const enabled = ipEnabled[ip] !== false
|
||||||
return (
|
return (
|
||||||
<div key={ip} className="flex min-w-0 items-center gap-1.5">
|
<Item
|
||||||
<HealthCheckBadge
|
key={ip}
|
||||||
status={health?.status ?? 'unknown'}
|
size="sm"
|
||||||
latencyMs={health?.latency_ms}
|
className="w-full min-w-0 flex-nowrap border-0 p-0"
|
||||||
lastCheckedAt={health?.last_checked_at}
|
>
|
||||||
lastError={health?.last_error}
|
<ItemMedia>
|
||||||
colo={health?.colo}
|
<HealthCheckBadge
|
||||||
provider={health?.provider}
|
status={health?.status ?? 'unknown'}
|
||||||
size="xs"
|
latencyMs={health?.latency_ms}
|
||||||
/>
|
lastCheckedAt={health?.last_checked_at}
|
||||||
<TruncatedText
|
lastError={health?.last_error}
|
||||||
className={cn(
|
colo={health?.colo}
|
||||||
'min-w-0 font-mono text-xs',
|
provider={health?.provider}
|
||||||
enabled ? 'text-muted-foreground' : 'text-muted-foreground/60',
|
size="xs"
|
||||||
textClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{ip}
|
|
||||||
</TruncatedText>
|
|
||||||
{copyable ? <CopyFqdnButton value={ip} /> : null}
|
|
||||||
{onToggleIp ? (
|
|
||||||
<Switch
|
|
||||||
size="sm"
|
|
||||||
className="ml-auto shrink-0"
|
|
||||||
checked={enabled}
|
|
||||||
disabled={ipToggleDisabled || togglingIp === ip}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onCheckedChange={(checked) => onToggleIp(ip, Boolean(checked))}
|
|
||||||
aria-label={
|
|
||||||
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent className="min-w-0 gap-0">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<TruncatedText
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 font-mono text-xs',
|
||||||
|
enabled
|
||||||
|
? 'text-muted-foreground'
|
||||||
|
: 'text-muted-foreground/60',
|
||||||
|
textClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ip}
|
||||||
|
</TruncatedText>
|
||||||
|
{copyable ? <CopyFqdnButton value={ip} /> : null}
|
||||||
|
{markActive && activeSet.has(ip) ? (
|
||||||
|
<StatusBadge status="active" className="shrink-0" />
|
||||||
|
) : null}
|
||||||
|
{lbMode === 'weighted' ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="shrink-0 tabular-nums"
|
||||||
|
>
|
||||||
|
w{ipWeights[ip] ?? 1}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ItemContent>
|
||||||
|
{onToggleIp ? (
|
||||||
|
<ItemActions className="ml-auto shrink-0 gap-1">
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
checked={enabled}
|
||||||
|
disabled={ipToggleDisabled || togglingIp === ip}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onToggleIp(ip, Boolean(checked))
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{showMenuSlot ? (
|
||||||
|
<span className={MENU_SLOT_CLASS} aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
</ItemActions>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</Item>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{extraCount > 0 ? (
|
{extraCount > 0 ? (
|
||||||
@@ -224,6 +278,6 @@ export function ServiceIpList({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</ItemGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
|
import {
|
||||||
|
GitForkIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
Repeat2Icon,
|
||||||
|
ScaleIcon,
|
||||||
|
ServerIcon,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
@@ -23,6 +29,12 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@cfdm/ui/components/dropdown-menu'
|
} from '@cfdm/ui/components/dropdown-menu'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemMedia,
|
||||||
|
} from '@cfdm/ui/components/item'
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -30,6 +42,63 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@cfdm/ui/components/tooltip'
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact service card — settings-8 DNA (Badge + copy + Switch + menu).
|
||||||
|
* Preview: https://reui.io/preview/base/settings-8
|
||||||
|
* Frame: https://reui.io/docs/components/base/frame
|
||||||
|
* IconTile: https://reui.io/docs/components/base/icon-tile
|
||||||
|
* Header fill: FramePanel `bg-muted` (overrides `--frame-panel-bg`; see frame.tsx).
|
||||||
|
*/
|
||||||
|
|
||||||
|
type LbMode = ServiceView['lb_mode']
|
||||||
|
|
||||||
|
const LB_MODE_META: Record<
|
||||||
|
LbMode,
|
||||||
|
{ icon: LucideIcon; className: string; label: string }
|
||||||
|
> = {
|
||||||
|
round_robin: {
|
||||||
|
icon: Repeat2Icon,
|
||||||
|
className: 'text-info',
|
||||||
|
label: 'Round Robin',
|
||||||
|
},
|
||||||
|
failover: {
|
||||||
|
icon: GitForkIcon,
|
||||||
|
className: 'text-warning',
|
||||||
|
label: 'Failover (приоритет)',
|
||||||
|
},
|
||||||
|
weighted: {
|
||||||
|
icon: ScaleIcon,
|
||||||
|
className: 'text-info',
|
||||||
|
label: 'Weighted (веса)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LbModeTile({ mode }: { mode: LbMode }) {
|
||||||
|
const meta = LB_MODE_META[mode]
|
||||||
|
const Icon = meta.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="xs"
|
||||||
|
className={cn('shrink-0', meta.className)}
|
||||||
|
aria-label={meta.label}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{meta.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface ServiceUnitCardProps {
|
interface ServiceUnitCardProps {
|
||||||
service: ServiceView
|
service: ServiceView
|
||||||
@@ -55,27 +124,32 @@ export function ServiceUnitCard({
|
|||||||
const extraCount = Math.max(0, fqdns.length - 1)
|
const extraCount = Math.max(0, fqdns.length - 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden">
|
<Frame stacked spacing="sm" className="h-full min-w-0">
|
||||||
<FrameHeader className="flex-row items-center justify-between gap-2">
|
<FramePanel fit className="bg-muted">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<Item size="sm" className="w-full min-w-0 flex-nowrap border-0 p-0">
|
||||||
<IconTile
|
<ItemMedia>
|
||||||
variant="elevated"
|
<IconTile
|
||||||
size="sm"
|
variant="elevated"
|
||||||
className="text-muted-foreground"
|
size="sm"
|
||||||
aria-hidden="true"
|
className="text-foreground"
|
||||||
>
|
aria-hidden="true"
|
||||||
<ServerIcon />
|
>
|
||||||
</IconTile>
|
<ServerIcon />
|
||||||
<div className="flex min-w-0 flex-col gap-px">
|
</IconTile>
|
||||||
<FrameTitle className="min-w-0 truncate text-sm">
|
</ItemMedia>
|
||||||
<Link
|
<ItemContent className="min-w-0 gap-px">
|
||||||
to="/services/$serviceId"
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
params={{ serviceId: String(service.id) }}
|
<FrameTitle className="min-w-0 truncate text-base font-semibold">
|
||||||
className="hover:underline"
|
<Link
|
||||||
>
|
to="/services/$serviceId"
|
||||||
{service.name}
|
params={{ serviceId: String(service.id) }}
|
||||||
</Link>
|
className="hover:underline"
|
||||||
</FrameTitle>
|
>
|
||||||
|
{service.name}
|
||||||
|
</Link>
|
||||||
|
</FrameTitle>
|
||||||
|
<LbModeTile mode={service.lb_mode} />
|
||||||
|
</div>
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
||||||
{primaryDomain}
|
{primaryDomain}
|
||||||
@@ -108,66 +182,70 @@ export function ServiceUnitCard({
|
|||||||
<CopyFqdnButton value={fqdns.join('\n')} />
|
<CopyFqdnButton value={fqdns.join('\n')} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ItemContent>
|
||||||
</div>
|
<ItemActions className="ml-auto shrink-0 gap-1">
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<Switch
|
||||||
<Switch
|
size="sm"
|
||||||
size="sm"
|
checked={service.enabled}
|
||||||
checked={service.enabled}
|
disabled={togglingId === service.id}
|
||||||
disabled={togglingId === service.id}
|
onCheckedChange={(checked) =>
|
||||||
onCheckedChange={(checked) =>
|
onToggleService(service.id, Boolean(checked))
|
||||||
onToggleService(service.id, Boolean(checked))
|
|
||||||
}
|
|
||||||
aria-label={
|
|
||||||
service.enabled ? 'Выключить сервис' : 'Включить сервис'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Действия ${service.name}`}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
>
|
aria-label={
|
||||||
<MoreHorizontalIcon aria-hidden />
|
service.enabled ? 'Выключить сервис' : 'Включить сервис'
|
||||||
</DropdownMenuTrigger>
|
}
|
||||||
<DropdownMenuContent align="end">
|
/>
|
||||||
<DropdownMenuItem
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
render={
|
render={
|
||||||
<Link
|
<Button
|
||||||
to="/services/$serviceId"
|
type="button"
|
||||||
params={{ serviceId: String(service.id) }}
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Действия ${service.name}`}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Обзор
|
<MoreHorizontalIcon aria-hidden />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuItem onClick={() => onEditService(service)}>
|
<DropdownMenuContent align="end">
|
||||||
Изменить
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
render={
|
||||||
<DropdownMenuItem
|
<Link
|
||||||
variant="destructive"
|
to="/services/$serviceId"
|
||||||
onClick={() => onDeleteService(service)}
|
params={{ serviceId: String(service.id) }}
|
||||||
>
|
/>
|
||||||
Удалить
|
}
|
||||||
</DropdownMenuItem>
|
>
|
||||||
</DropdownMenuContent>
|
Обзор
|
||||||
</DropdownMenu>
|
</DropdownMenuItem>
|
||||||
</div>
|
<DropdownMenuItem onClick={() => onEditService(service)}>
|
||||||
</FrameHeader>
|
Изменить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onDeleteService(service)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
</FramePanel>
|
||||||
|
|
||||||
<FramePanel className="flex min-w-0 flex-col gap-1 pt-0 shadow-none!">
|
<FramePanel className="flex min-w-0 flex-col">
|
||||||
<ServiceIpList
|
<ServiceIpList
|
||||||
copyable
|
copyable
|
||||||
|
alignWithMenu
|
||||||
ips={service.ips ?? []}
|
ips={service.ips ?? []}
|
||||||
ipHealth={service.ip_health ?? []}
|
ipHealth={service.ip_health ?? []}
|
||||||
ipEnabled={service.ip_enabled ?? {}}
|
ipEnabled={service.ip_enabled ?? {}}
|
||||||
ipToggleDisabled={togglingId === service.id}
|
ipToggleDisabled={togglingId === service.id}
|
||||||
togglingIp={togglingIp}
|
togglingIp={togglingIp}
|
||||||
|
lbMode={service.lb_mode}
|
||||||
|
activeIps={service.active_ips}
|
||||||
|
ipWeights={service.domains[0]?.target_ip_weights}
|
||||||
onToggleIp={(ip, enabled) =>
|
onToggleIp={(ip, enabled) =>
|
||||||
onToggleServiceIp(service.id, ip, enabled)
|
onToggleServiceIp(service.id, ip, enabled)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function SettingRow({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full justify-start',
|
'flex w-full min-w-0 justify-start',
|
||||||
stacked ? 'justify-start' : '@md/field-group:justify-end',
|
stacked ? 'justify-start' : '@md/field-group:justify-end',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { useEffect, useMemo } from 'react'
|
|||||||
import { useForm, Controller } from 'react-hook-form'
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import type { CertMonitoring } from '@cfdm/shared'
|
|
||||||
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
|
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
|
||||||
import type { SubdomainServiceLink } from '@/hooks/use-domain-page'
|
import type { SubdomainServiceLink } from '@/hooks/use-domain-page'
|
||||||
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
|
||||||
import { formatServiceGroupLabel } from '@/lib/service-utils'
|
import { formatServiceGroupLabel } from '@/lib/service-utils'
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
import { FormFieldSimple } from '@/components/form-field'
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
@@ -30,7 +28,6 @@ import {
|
|||||||
const subdomainEditSchema = z.object({
|
const subdomainEditSchema = z.object({
|
||||||
name: z.string().min(1, 'Укажите имя'),
|
name: z.string().min(1, 'Укажите имя'),
|
||||||
serviceId: z.string(),
|
serviceId: z.string(),
|
||||||
certMonitoring: z.enum(['auto', 'required', 'skipped']),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export type SubdomainEditValues = z.infer<typeof subdomainEditSchema>
|
export type SubdomainEditValues = z.infer<typeof subdomainEditSchema>
|
||||||
@@ -67,7 +64,6 @@ export function SubdomainEditSheet({
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
serviceId: 'none',
|
serviceId: 'none',
|
||||||
certMonitoring: 'auto',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -85,42 +81,27 @@ export function SubdomainEditSheet({
|
|||||||
[services, serviceGroupById],
|
[services, serviceGroupById],
|
||||||
)
|
)
|
||||||
|
|
||||||
const certMonitoringItems = useMemo(
|
|
||||||
() =>
|
|
||||||
certMonitoringOptions.map((option) => ({
|
|
||||||
label: option.label,
|
|
||||||
value: option.value,
|
|
||||||
})),
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
if (mode === 'edit' && subdomain) {
|
if (mode === 'edit' && subdomain) {
|
||||||
form.reset({
|
form.reset({
|
||||||
name: subdomain.name,
|
name: subdomain.name,
|
||||||
serviceId: currentServiceId || 'none',
|
serviceId: currentServiceId || 'none',
|
||||||
certMonitoring: subdomain.cert_monitoring,
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
form.reset({
|
form.reset({
|
||||||
name: '',
|
name: '',
|
||||||
serviceId: 'none',
|
serviceId: 'none',
|
||||||
certMonitoring: 'auto',
|
|
||||||
})
|
})
|
||||||
}, [open, mode, subdomain, currentServiceId, form])
|
}, [open, mode, subdomain, currentServiceId, form])
|
||||||
|
|
||||||
const certMonitoring = form.watch('certMonitoring')
|
|
||||||
const certHint =
|
|
||||||
certMonitoringOptions.find((o) => o.value === certMonitoring)?.description
|
|
||||||
const hasMultipleServices = mode === 'edit' && serviceLinks.length > 1
|
const hasMultipleServices = mode === 'edit' && serviceLinks.length > 1
|
||||||
|
|
||||||
function handleSubmit(values: SubdomainEditValues) {
|
function handleSubmit(values: SubdomainEditValues) {
|
||||||
onSubmit({
|
onSubmit({
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
serviceId: values.serviceId,
|
serviceId: values.serviceId,
|
||||||
certMonitoring: values.certMonitoring as CertMonitoring,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,39 +199,6 @@ export function SubdomainEditSheet({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FormFieldSimple>
|
</FormFieldSimple>
|
||||||
<FormFieldSimple
|
|
||||||
label="Мониторинг SSL"
|
|
||||||
htmlFor="subdomain_cert_monitoring"
|
|
||||||
hint={certHint}
|
|
||||||
>
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="certMonitoring"
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select
|
|
||||||
items={certMonitoringItems}
|
|
||||||
value={field.value}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
field.onChange((value ?? 'auto') as CertMonitoring)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
id="subdomain_cert_monitoring"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{certMonitoringOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FormFieldSimple>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
serviceGroupsQueryOptions,
|
serviceGroupsQueryOptions,
|
||||||
servicesQueryOptions,
|
servicesQueryOptions,
|
||||||
subdomainsListQueryOptions,
|
subdomainsListQueryOptions,
|
||||||
updateDomain,
|
|
||||||
updateSubdomain,
|
updateSubdomain,
|
||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import type { CertMonitoring } from '@cfdm/shared'
|
import type { CertMonitoring } from '@cfdm/shared'
|
||||||
@@ -172,21 +171,6 @@ export function useDomainPage(domainId: number) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateDomainCertMonitoringMutation = useMutation({
|
|
||||||
mutationFn: (certMonitoring: CertMonitoring) =>
|
|
||||||
updateDomain(domainId, { cert_monitoring: certMonitoring }),
|
|
||||||
onSuccess: () => {
|
|
||||||
invalidate()
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ['domains'] })
|
|
||||||
toast.success('Режим мониторинга SSL обновлён')
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
toast.error(
|
|
||||||
err instanceof Error ? err.message : 'Не удалось обновить мониторинг SSL',
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteSubdomainMutation = useMutation({
|
const deleteSubdomainMutation = useMutation({
|
||||||
mutationFn: (id: number) => deleteSubdomain(id),
|
mutationFn: (id: number) => deleteSubdomain(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -249,7 +233,6 @@ export function useDomainPage(domainId: number) {
|
|||||||
syncMutation,
|
syncMutation,
|
||||||
createSubdomainMutation,
|
createSubdomainMutation,
|
||||||
updateSubdomainMutation,
|
updateSubdomainMutation,
|
||||||
updateDomainCertMonitoringMutation,
|
|
||||||
deleteSubdomainMutation,
|
deleteSubdomainMutation,
|
||||||
linkServiceMutation,
|
linkServiceMutation,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,17 @@ export const certMonitoringOptions: Array<{
|
|||||||
{
|
{
|
||||||
value: 'auto',
|
value: 'auto',
|
||||||
label: 'Авто',
|
label: 'Авто',
|
||||||
description: 'Проверять, если хост обслуживается активным сервисом',
|
description: 'Проверять, если health-check сервиса с verify TLS',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'required',
|
value: 'required',
|
||||||
label: 'Обязательно',
|
label: 'Обязательно',
|
||||||
description: 'Всегда проверять SSL, даже без привязок',
|
description: 'Всегда проверять SSL для этого FQDN',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'skipped',
|
value: 'skipped',
|
||||||
label: 'Не проверять',
|
label: 'Не проверять',
|
||||||
description: 'Исключить из мониторинга сертификатов',
|
description: 'Исключить FQDN из мониторинга сертификатов',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
collapseStatusChanges,
|
||||||
|
enabledHealthProviders,
|
||||||
|
providerHealthStatuses,
|
||||||
|
worstHealthStatus,
|
||||||
|
type HealthLogProbe,
|
||||||
|
} from '@/lib/health-log'
|
||||||
|
|
||||||
|
function probe(
|
||||||
|
overrides: Partial<HealthLogProbe> & Pick<HealthLogProbe, 'id' | 'status' | 'checked_at'>,
|
||||||
|
): HealthLogProbe {
|
||||||
|
return {
|
||||||
|
ip: '1.1.1.1',
|
||||||
|
provider: 'local',
|
||||||
|
ok: overrides.status === 'up',
|
||||||
|
latency_ms: 12,
|
||||||
|
colo: null,
|
||||||
|
error: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('collapseStatusChanges', () => {
|
||||||
|
it('keeps only status transitions per ip+provider', () => {
|
||||||
|
const items = [
|
||||||
|
probe({ id: 1, status: 'up', checked_at: '2026-01-01T00:00:00Z' }),
|
||||||
|
probe({ id: 2, status: 'up', checked_at: '2026-01-01T00:01:00Z' }),
|
||||||
|
probe({ id: 3, status: 'down', checked_at: '2026-01-01T00:02:00Z' }),
|
||||||
|
probe({ id: 4, status: 'down', checked_at: '2026-01-01T00:03:00Z' }),
|
||||||
|
probe({ id: 5, status: 'up', checked_at: '2026-01-01T00:04:00Z' }),
|
||||||
|
]
|
||||||
|
const changes = collapseStatusChanges(items)
|
||||||
|
expect(changes.map((item) => item.id)).toEqual([5, 3, 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tracks series independently by provider', () => {
|
||||||
|
const items = [
|
||||||
|
probe({ id: 1, provider: 'local', status: 'up', checked_at: '2026-01-01T00:00:00Z' }),
|
||||||
|
probe({
|
||||||
|
id: 2,
|
||||||
|
provider: 'cloudflare',
|
||||||
|
status: 'up',
|
||||||
|
checked_at: '2026-01-01T00:00:00Z',
|
||||||
|
}),
|
||||||
|
probe({ id: 3, provider: 'local', status: 'up', checked_at: '2026-01-01T00:01:00Z' }),
|
||||||
|
probe({
|
||||||
|
id: 4,
|
||||||
|
provider: 'cloudflare',
|
||||||
|
status: 'down',
|
||||||
|
checked_at: '2026-01-01T00:01:00Z',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const changes = collapseStatusChanges(items)
|
||||||
|
expect(changes.map((item) => item.id).sort()).toEqual([1, 2, 4])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('enabledHealthProviders', () => {
|
||||||
|
it('unions bindings in registry order', () => {
|
||||||
|
expect(
|
||||||
|
enabledHealthProviders([
|
||||||
|
{ health_check_providers: ['globalping'] },
|
||||||
|
{ health_check_providers: ['local', 'cloudflare'] },
|
||||||
|
]),
|
||||||
|
).toEqual(['local', 'cloudflare', 'globalping'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to local', () => {
|
||||||
|
expect(enabledHealthProviders([])).toEqual(['local'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('providerHealthStatuses', () => {
|
||||||
|
it('uses worst latest-per-ip status', () => {
|
||||||
|
const items = [
|
||||||
|
probe({ id: 1, ip: '1.1.1.1', status: 'up', checked_at: '2026-01-01T00:02:00Z' }),
|
||||||
|
probe({ id: 2, ip: '2.2.2.2', status: 'down', checked_at: '2026-01-01T00:01:00Z' }),
|
||||||
|
probe({
|
||||||
|
id: 3,
|
||||||
|
ip: '2.2.2.2',
|
||||||
|
status: 'up',
|
||||||
|
checked_at: '2026-01-01T00:00:00Z',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
expect(providerHealthStatuses(items, ['local']).local).toBe('down')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('worstHealthStatus', () => {
|
||||||
|
it('ranks down over degraded over up', () => {
|
||||||
|
expect(worstHealthStatus(['up', 'degraded'])).toBe('degraded')
|
||||||
|
expect(worstHealthStatus(['degraded', 'down'])).toBe('down')
|
||||||
|
expect(worstHealthStatus([])).toBe('unknown')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
import { HEALTH_CHECK_PROVIDERS, uniqueHealthProviders } from '@cfdm/shared'
|
||||||
|
|
||||||
|
import { sqliteUtcToIso } from '@/lib/format'
|
||||||
|
import type { IpHealthStatus } from '@/lib/schemas'
|
||||||
|
|
||||||
|
export type HealthLogStatus = IpHealthStatus['status']
|
||||||
|
|
||||||
|
export interface HealthLogProbe {
|
||||||
|
id: number
|
||||||
|
ip: string
|
||||||
|
provider: HealthCheckProvider
|
||||||
|
status: HealthLogStatus
|
||||||
|
ok: boolean
|
||||||
|
latency_ms: number | null
|
||||||
|
colo: string | null
|
||||||
|
error: string | null
|
||||||
|
checked_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_RANK: Record<HealthLogStatus, number> = {
|
||||||
|
unknown: 0,
|
||||||
|
up: 1,
|
||||||
|
degraded: 2,
|
||||||
|
down: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function probeTime(checkedAt: string): number {
|
||||||
|
const iso = sqliteUtcToIso(checkedAt) ?? checkedAt
|
||||||
|
const time = new Date(iso).getTime()
|
||||||
|
return Number.isNaN(time) ? 0 : time
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterByPeriod<T extends { checked_at: string }>(
|
||||||
|
items: T[],
|
||||||
|
days: number,
|
||||||
|
): T[] {
|
||||||
|
const cutoff = Date.now() - days * 86_400_000
|
||||||
|
return items.filter((item) => probeTime(item.checked_at) >= cutoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterByProviders<T extends { provider: string }>(
|
||||||
|
items: T[],
|
||||||
|
providers: readonly HealthCheckProvider[],
|
||||||
|
): T[] {
|
||||||
|
if (providers.length === 0) return items
|
||||||
|
const allowed = new Set(providers)
|
||||||
|
return items.filter((item) => allowed.has(item.provider as HealthCheckProvider))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the first probe of each ip+provider series and every later probe
|
||||||
|
* whose status differs from the previous one. Newest first.
|
||||||
|
*/
|
||||||
|
export function collapseStatusChanges<T extends HealthLogProbe>(items: T[]): T[] {
|
||||||
|
const byKey = new Map<string, T[]>()
|
||||||
|
for (const item of items) {
|
||||||
|
const key = `${item.ip}\0${item.provider}`
|
||||||
|
const list = byKey.get(key)
|
||||||
|
if (list) list.push(item)
|
||||||
|
else byKey.set(key, [item])
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes: T[] = []
|
||||||
|
for (const list of byKey.values()) {
|
||||||
|
list.sort(
|
||||||
|
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
|
||||||
|
)
|
||||||
|
let previous: HealthLogStatus | undefined
|
||||||
|
for (const item of list) {
|
||||||
|
if (item.status !== previous) {
|
||||||
|
changes.push(item)
|
||||||
|
previous = item.status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changes.sort(
|
||||||
|
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id,
|
||||||
|
)
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enabledHealthProviders(
|
||||||
|
domains: Array<{ health_check_providers?: readonly HealthCheckProvider[] | null }>,
|
||||||
|
): HealthCheckProvider[] {
|
||||||
|
const collected = uniqueHealthProviders(
|
||||||
|
domains.flatMap((domain) => domain.health_check_providers ?? []),
|
||||||
|
)
|
||||||
|
if (collected.length === 0) return ['local']
|
||||||
|
return HEALTH_CHECK_PROVIDERS.filter((provider) => collected.includes(provider))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function worstHealthStatus(statuses: readonly HealthLogStatus[]): HealthLogStatus {
|
||||||
|
if (statuses.length === 0) return 'unknown'
|
||||||
|
return statuses.reduce((worst, status) =>
|
||||||
|
STATUS_RANK[status] > STATUS_RANK[worst] ? status : worst,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Latest probe per IP for a provider, then worst among those IPs. */
|
||||||
|
export function providerHealthStatuses(
|
||||||
|
items: readonly HealthLogProbe[],
|
||||||
|
providers: readonly HealthCheckProvider[],
|
||||||
|
): Record<HealthCheckProvider, HealthLogStatus> {
|
||||||
|
const latestByIp = new Map<string, HealthLogProbe>()
|
||||||
|
const sorted = [...items].sort(
|
||||||
|
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id,
|
||||||
|
)
|
||||||
|
for (const item of sorted) {
|
||||||
|
const key = `${item.provider}\0${item.ip}`
|
||||||
|
if (!latestByIp.has(key)) latestByIp.set(key, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = Object.fromEntries(
|
||||||
|
HEALTH_CHECK_PROVIDERS.map((provider) => [provider, 'unknown' as HealthLogStatus]),
|
||||||
|
) as Record<HealthCheckProvider, HealthLogStatus>
|
||||||
|
|
||||||
|
for (const provider of providers) {
|
||||||
|
const statuses = [...latestByIp.values()]
|
||||||
|
.filter((item) => item.provider === provider)
|
||||||
|
.map((item) => item.status)
|
||||||
|
result[provider] = worstHealthStatus(statuses)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -82,6 +82,7 @@ export const serviceDomainBindingSchema = z
|
|||||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||||
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||||
|
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
|
||||||
sync_status: z.string().nullable().default(null),
|
sync_status: z.string().nullable().default(null),
|
||||||
})
|
})
|
||||||
.transform((binding) => ({
|
.transform((binding) => ({
|
||||||
@@ -133,6 +134,8 @@ export const serviceViewSchema = serviceSchema.extend({
|
|||||||
health_latency_ms: z.number().nullable().default(null),
|
health_latency_ms: z.number().nullable().default(null),
|
||||||
ip_health: z.array(serviceIpHealthSchema).default([]),
|
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||||
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
||||||
|
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
|
||||||
|
active_ips: z.array(z.string()).default([]),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||||
@@ -195,6 +198,7 @@ export const serviceBindingSchema = z
|
|||||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||||
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||||
|
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
|
||||||
sync_status: z.string().nullable().default(null),
|
sync_status: z.string().nullable().default(null),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
updated_at: z.string(),
|
updated_at: z.string(),
|
||||||
@@ -232,6 +236,8 @@ export const certificateSchema = z.object({
|
|||||||
id: z.number(),
|
id: z.number(),
|
||||||
domain_id: z.number(),
|
domain_id: z.number(),
|
||||||
subdomain_id: z.number().nullable(),
|
subdomain_id: z.number().nullable(),
|
||||||
|
service_id: z.number().nullable().optional().default(null),
|
||||||
|
service_name: z.string().nullable().optional().default(null),
|
||||||
hostname: z.string(),
|
hostname: z.string(),
|
||||||
expires_at: z.string().nullable(),
|
expires_at: z.string().nullable(),
|
||||||
last_checked_at: z.string().nullable(),
|
last_checked_at: z.string().nullable(),
|
||||||
@@ -241,6 +247,19 @@ export const certificateSchema = z.object({
|
|||||||
updated_at: z.string(),
|
updated_at: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const serviceCertificateRowSchema = z.object({
|
||||||
|
binding_id: z.number(),
|
||||||
|
domain_id: z.number(),
|
||||||
|
service_id: z.number(),
|
||||||
|
hostname: z.string(),
|
||||||
|
cert_monitoring: z.enum(['auto', 'required', 'skipped']),
|
||||||
|
id: z.number().nullable(),
|
||||||
|
status: z.string(),
|
||||||
|
expires_at: z.string().nullable(),
|
||||||
|
last_checked_at: z.string().nullable(),
|
||||||
|
last_error: z.string().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
export type Group = z.infer<typeof groupSchema>
|
export type Group = z.infer<typeof groupSchema>
|
||||||
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
|
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
|
||||||
export type Service = z.infer<typeof serviceSchema>
|
export type Service = z.infer<typeof serviceSchema>
|
||||||
@@ -254,6 +273,7 @@ export type DomainListItem = z.infer<typeof domainListItemSchema>
|
|||||||
export type ServiceBinding = z.infer<typeof serviceBindingSchema>
|
export type ServiceBinding = z.infer<typeof serviceBindingSchema>
|
||||||
export type DnsRecord = z.infer<typeof dnsRecordSchema>
|
export type DnsRecord = z.infer<typeof dnsRecordSchema>
|
||||||
export type Certificate = z.infer<typeof certificateSchema>
|
export type Certificate = z.infer<typeof certificateSchema>
|
||||||
|
export type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>
|
||||||
|
|
||||||
export const createGroupSchema = z.object({
|
export const createGroupSchema = z.object({
|
||||||
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
|
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query'
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
import { certificateSchema } from '@/lib/schemas'
|
import { certificateSchema, serviceCertificateRowSchema } from '@/lib/schemas'
|
||||||
|
import type { CertMonitoring } from '@cfdm/shared'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
export const certKeys = {
|
export const certKeys = {
|
||||||
all: ['certificates'] as const,
|
all: ['certificates'] as const,
|
||||||
summary: ['certificates', 'summary'] as const,
|
summary: ['certificates', 'summary'] as const,
|
||||||
|
byService: (serviceId: number) =>
|
||||||
|
[...certKeys.all, 'service', serviceId] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const certificatesQueryOptions = () =>
|
export const certificatesQueryOptions = () =>
|
||||||
@@ -23,3 +26,29 @@ export const certSummaryQueryOptions = () =>
|
|||||||
queryKey: certKeys.summary,
|
queryKey: certKeys.summary,
|
||||||
queryFn: () => api.get<[string, number][]>('/api/v1/certificates/summary'),
|
queryFn: () => api.get<[string, number][]>('/api/v1/certificates/summary'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const serviceCertificatesQueryOptions = (serviceId: number) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: certKeys.byService(serviceId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await api.get<unknown[]>(
|
||||||
|
`/api/v1/services/${serviceId}/certificates`,
|
||||||
|
)
|
||||||
|
return z.array(serviceCertificateRowSchema).parse(data)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function patchBindingCertMonitoring(
|
||||||
|
bindingId: number,
|
||||||
|
certMonitoring: CertMonitoring,
|
||||||
|
) {
|
||||||
|
return api.patch(`/api/v1/service-bindings/${bindingId}`, {
|
||||||
|
cert_monitoring: certMonitoring,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkServiceCertificates(serviceId: number) {
|
||||||
|
return api.post<{ checked: number }>(
|
||||||
|
`/api/v1/services/${serviceId}/certificates/check`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ function CertificatesPage() {
|
|||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Сертификаты"
|
title="Сертификаты"
|
||||||
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
|
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
|
||||||
actions={primaryAction}
|
actions={primaryAction}
|
||||||
/>
|
/>
|
||||||
<CertKpiStats
|
<CertKpiStats
|
||||||
@@ -86,7 +86,7 @@ function CertificatesPage() {
|
|||||||
/>
|
/>
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="Сертификаты"
|
title="Сертификаты"
|
||||||
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
|
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
|
||||||
hideHeader
|
hideHeader
|
||||||
tabs={CERT_TABS.map((tab) => ({ ...tab }))}
|
tabs={CERT_TABS.map((tab) => ({ ...tab }))}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import {
|
|||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
Link2Icon,
|
Link2Icon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
ShieldCheckIcon,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import type { Filter } from '@/components/reui/filters'
|
import type { Filter } from '@/components/reui/filters'
|
||||||
import type { CertMonitoring } from '@cfdm/shared'
|
|
||||||
import {
|
import {
|
||||||
domainDetailQueryOptions,
|
domainDetailQueryOptions,
|
||||||
domainServiceBindingsQueryOptions,
|
domainServiceBindingsQueryOptions,
|
||||||
@@ -41,19 +39,11 @@ import {
|
|||||||
import { DomainBindingsPanel } from '@/components/domain-bindings-panel'
|
import { DomainBindingsPanel } from '@/components/domain-bindings-panel'
|
||||||
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
|
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
|
|
||||||
import { formatDate } from '@/lib/format'
|
import { formatDate } from '@/lib/format'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@cfdm/ui/components/select'
|
|
||||||
import { TabsContent } from '@cfdm/ui/components/tabs'
|
import { TabsContent } from '@cfdm/ui/components/tabs'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||||
@@ -108,7 +98,6 @@ function DomainOverviewPage() {
|
|||||||
syncMutation,
|
syncMutation,
|
||||||
createSubdomainMutation,
|
createSubdomainMutation,
|
||||||
updateSubdomainMutation,
|
updateSubdomainMutation,
|
||||||
updateDomainCertMonitoringMutation,
|
|
||||||
deleteSubdomainMutation,
|
deleteSubdomainMutation,
|
||||||
linkServiceMutation,
|
linkServiceMutation,
|
||||||
} = useDomainPage(id)
|
} = useDomainPage(id)
|
||||||
@@ -167,20 +156,15 @@ function DomainOverviewPage() {
|
|||||||
if (!editTarget) return
|
if (!editTarget) return
|
||||||
|
|
||||||
const nameChanged = values.name !== editTarget.subdomain.name
|
const nameChanged = values.name !== editTarget.subdomain.name
|
||||||
const certMonitoringChanged =
|
|
||||||
values.certMonitoring !== editTarget.subdomain.cert_monitoring
|
|
||||||
const currentServiceId = resolveServiceId(editTarget)
|
const currentServiceId = resolveServiceId(editTarget)
|
||||||
const serviceChanged = values.serviceId !== currentServiceId
|
const serviceChanged = values.serviceId !== currentServiceId
|
||||||
const targetServiceId =
|
const targetServiceId =
|
||||||
values.serviceId === 'none' ? null : Number(values.serviceId)
|
values.serviceId === 'none' ? null : Number(values.serviceId)
|
||||||
|
|
||||||
if (nameChanged || certMonitoringChanged) {
|
if (nameChanged) {
|
||||||
await updateSubdomainMutation.mutateAsync({
|
await updateSubdomainMutation.mutateAsync({
|
||||||
id: editTarget.subdomain.id,
|
id: editTarget.subdomain.id,
|
||||||
...(nameChanged ? { name: values.name } : {}),
|
name: values.name,
|
||||||
...(certMonitoringChanged
|
|
||||||
? { cert_monitoring: values.certMonitoring }
|
|
||||||
: {}),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,11 +193,6 @@ function DomainOverviewPage() {
|
|||||||
updateSubdomainMutation.isPending ||
|
updateSubdomainMutation.isPending ||
|
||||||
linkServiceMutation.isPending
|
linkServiceMutation.isPending
|
||||||
|
|
||||||
const certMonitoringItems = certMonitoringOptions.map((option) => ({
|
|
||||||
label: option.label,
|
|
||||||
value: option.value,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const metricCards = useMemo(() => {
|
const metricCards = useMemo(() => {
|
||||||
if (!domain) return []
|
if (!domain) return []
|
||||||
return [
|
return [
|
||||||
@@ -344,40 +323,6 @@ function DomainOverviewPage() {
|
|||||||
>
|
>
|
||||||
<TabsContent value="overview" className="flex flex-col gap-4">
|
<TabsContent value="overview" className="flex flex-col gap-4">
|
||||||
<DetailPanel.Metrics cards={metricCards} />
|
<DetailPanel.Metrics cards={metricCards} />
|
||||||
<DetailPanel.Section
|
|
||||||
title="Мониторинг SSL"
|
|
||||||
description="Настройка проверки сертификата для apex-зоны"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
|
|
||||||
<span className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
||||||
<ShieldCheckIcon className="size-4" aria-hidden="true" />
|
|
||||||
Мониторинг SSL (apex):
|
|
||||||
</span>
|
|
||||||
<Select
|
|
||||||
items={certMonitoringItems}
|
|
||||||
value={domain.cert_monitoring}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
updateDomainCertMonitoringMutation.mutate(
|
|
||||||
(value ?? 'auto') as CertMonitoring,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={updateDomainCertMonitoringMutation.isPending}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full sm:w-56">
|
|
||||||
<SelectValue>
|
|
||||||
{certMonitoringLabel(domain.cert_monitoring)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{certMonitoringOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</DetailPanel.Section>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="availability" className="flex flex-col gap-4">
|
<TabsContent value="availability" className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -1,104 +1,11 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
|
|
||||||
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
|
||||||
import { HealthTimeline } from '@/components/health/health-timeline'
|
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
|
||||||
import {
|
|
||||||
serviceHealthLogQueryOptions,
|
|
||||||
serviceViewQueryOptions,
|
|
||||||
} from '@/queries'
|
|
||||||
import { formatDate } from '@/lib/format'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
|
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
|
||||||
component: ServiceHealthPage,
|
beforeLoad: ({ params }) => {
|
||||||
|
throw redirect({
|
||||||
|
to: '/services/$serviceId',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
component: () => null,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function ServiceHealthPage() {
|
|
||||||
const { serviceId } = Route.useParams()
|
|
||||||
const id = Number(serviceId)
|
|
||||||
const serviceQuery = useQuery(serviceViewQueryOptions(id))
|
|
||||||
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
|
||||||
const service = serviceQuery.data
|
|
||||||
const items = logQuery.data?.items ?? []
|
|
||||||
const ipHealth = service?.ip_health ?? []
|
|
||||||
|
|
||||||
const kpiCards = ipHealth.map((row) => {
|
|
||||||
const variant =
|
|
||||||
row.status === 'down'
|
|
||||||
? ('destructive' as const)
|
|
||||||
: row.status === 'degraded'
|
|
||||||
? ('warning' as const)
|
|
||||||
: ('default' as const)
|
|
||||||
return {
|
|
||||||
id: row.ip,
|
|
||||||
label: row.ip,
|
|
||||||
value: row.latency_ms != null ? `${row.latency_ms} мс` : '—',
|
|
||||||
hint: row.colo ? `colo ${row.colo}` : row.provider === 'cloudflare' ? 'Worker' : 'Local',
|
|
||||||
icon: row.provider === 'cloudflare' ? <GlobeIcon /> : <ServerIcon />,
|
|
||||||
variant,
|
|
||||||
footer: (
|
|
||||||
<HealthCheckBadge
|
|
||||||
status={row.status}
|
|
||||||
latencyMs={row.latency_ms}
|
|
||||||
lastCheckedAt={row.last_checked_at}
|
|
||||||
lastError={row.last_error}
|
|
||||||
colo={row.colo}
|
|
||||||
provider={row.provider}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DetailPanel>
|
|
||||||
<DetailPanel.Header
|
|
||||||
title="Health"
|
|
||||||
description="Снимок проб этого сервиса. Cloudflare = Worker с edge, не Health Checks API."
|
|
||||||
/>
|
|
||||||
<Alert>
|
|
||||||
<AlertTitle>XOR провайдеров</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Local ходит с API CFDM; Cloudflare — через Worker. Cron и пороги Slow/Down общие, в{' '}
|
|
||||||
<Link to="/settings/health" className="text-foreground underline">
|
|
||||||
Настройках → Health-check
|
|
||||||
</Link>
|
|
||||||
. Если Worker не задан, цель не пробируется как Local.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
{kpiCards.length > 0 ? (
|
|
||||||
<KpiStatGrid cards={kpiCards} />
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
icon={ActivityIcon}
|
|
||||||
title="Нет проб"
|
|
||||||
description="Включите health-check на привязке — статус IP появится после cron."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<DetailPanel.Header
|
|
||||||
title="Журнал проб"
|
|
||||||
description={
|
|
||||||
items[0]?.checked_at
|
|
||||||
? `Последняя: ${formatDate(items[0].checked_at)}`
|
|
||||||
: 'Последние пробы по IP этого сервиса'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<HealthTimeline
|
|
||||||
events={items.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
hostname: row.ip,
|
|
||||||
type: row.provider,
|
|
||||||
status: row.status,
|
|
||||||
latency_ms: row.latency_ms,
|
|
||||||
error: row.error,
|
|
||||||
checked_at: row.checked_at,
|
|
||||||
colo: row.colo,
|
|
||||||
provider: row.provider,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</DetailPanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,94 +1,447 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useMemo, useState } from 'react'
|
||||||
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
|
import { useForm } from 'react-hook-form'
|
||||||
import { DetailPanel } from '@/components/reui-kit'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
NetworkIcon,
|
||||||
|
PencilIcon,
|
||||||
|
ServerIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
|
||||||
|
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { FailoverTimeline } from '@/components/failover-timeline'
|
||||||
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { serviceOverviewQueryOptions } from '@/queries'
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||||
|
import {
|
||||||
|
ServiceDetailGrid,
|
||||||
|
type ServiceFqdnRow,
|
||||||
|
} from '@/components/services/service-detail-grid'
|
||||||
|
import { LbModeTile } from '@/components/services/service-unit-card'
|
||||||
|
import {
|
||||||
|
HealthProviderStatusTiles,
|
||||||
|
KpiStatGrid,
|
||||||
|
ServiceHealthMonitor,
|
||||||
|
} from '@/components/reui-kit'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import {
|
||||||
|
enabledHealthProviders,
|
||||||
|
providerHealthStatuses,
|
||||||
|
} from '@/lib/health-log'
|
||||||
|
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
||||||
|
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
import {
|
||||||
|
createServiceNode,
|
||||||
|
deleteServiceNode,
|
||||||
|
domainKeys,
|
||||||
|
domainsListQueryOptions,
|
||||||
|
serviceBindingKeys,
|
||||||
|
serviceDetailKeys,
|
||||||
|
serviceGroupKeys,
|
||||||
|
serviceGroupsQueryOptions,
|
||||||
|
serviceHealthLogQueryOptions,
|
||||||
|
serviceKeys,
|
||||||
|
serviceNodesQueryOptions,
|
||||||
|
serviceOverviewQueryOptions,
|
||||||
|
serviceViewQueryOptions,
|
||||||
|
} from '@/queries'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/')({
|
export const Route = createFileRoute('/_auth/services/$serviceId/')({
|
||||||
component: ServiceOverviewPage,
|
component: ServiceDetailPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function ServiceOverviewPage() {
|
interface OverviewPayload {
|
||||||
const { serviceId } = Route.useParams()
|
routing_strategy?: string
|
||||||
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
|
active_addresses?: string[]
|
||||||
const overview = data as {
|
nodes?: Array<{
|
||||||
service: {
|
id: number
|
||||||
name: string
|
address: string
|
||||||
enabled: boolean
|
protocol: string
|
||||||
health_status: 'up' | 'down' | 'degraded' | 'unknown'
|
port: number | null
|
||||||
domains: Array<{ fqdn: string; zone_name: string }>
|
health_status: string
|
||||||
}
|
weight: number
|
||||||
nodes: Array<{ id: number; address: string; health_status: string }>
|
priority: number
|
||||||
routing_strategy: string
|
consecutive_failures: number
|
||||||
active_addresses: string[]
|
last_failure_reason: string | null
|
||||||
} | undefined
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
if (!overview) {
|
function ServiceDetailPage() {
|
||||||
return (
|
const { serviceId } = Route.useParams()
|
||||||
<EmptyState
|
const id = Number(serviceId)
|
||||||
title="Сервис не найден"
|
const navigate = useNavigate()
|
||||||
description="Вернитесь в каталог и выберите сервис."
|
const queryClient = useQueryClient()
|
||||||
/>
|
|
||||||
)
|
const viewQuery = useQuery(serviceViewQueryOptions(id))
|
||||||
|
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
|
||||||
|
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
||||||
|
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
|
||||||
|
const groupsQuery = useQuery(serviceGroupsQueryOptions())
|
||||||
|
const domainsQuery = useQuery(domainsListQueryOptions())
|
||||||
|
|
||||||
|
const service = viewQuery.data
|
||||||
|
const overview = overviewQuery.data as OverviewPayload | undefined
|
||||||
|
const logItems = useMemo(
|
||||||
|
() => logQuery.data?.items ?? [],
|
||||||
|
[logQuery.data?.items],
|
||||||
|
)
|
||||||
|
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [selectedProviders, setSelectedProviders] = useState<
|
||||||
|
HealthCheckProvider[] | null
|
||||||
|
>(null)
|
||||||
|
const [togglingIp, setTogglingIp] = useState<string | null>(null)
|
||||||
|
const [changeIp, setChangeIp] = useState<{
|
||||||
|
bindingId: number
|
||||||
|
ip?: string
|
||||||
|
} | null>(null)
|
||||||
|
const [changeDomain, setChangeDomain] = useState(false)
|
||||||
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
|
const nodeForm = useForm<{ address: string; port: string }>({
|
||||||
|
defaultValues: { address: '', port: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const groups = groupsQuery.data
|
||||||
|
? [...groupsQuery.data.groups]
|
||||||
|
: []
|
||||||
|
|
||||||
|
async function invalidateService() {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes = overview.nodes ?? []
|
const updateMutation = useMutation({
|
||||||
const domains = overview.service.domains ?? []
|
mutationFn: ({ body }: { body: UpdateServiceConfigInput }) =>
|
||||||
|
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateService()
|
||||||
|
setEditOpen(false)
|
||||||
|
toast.success('Сервис сохранён')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
|
||||||
|
},
|
||||||
|
onSettled: () => setSaving(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => api.delete(`/api/v1/services/${id}`),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateService()
|
||||||
|
toast.success('Сервис удалён')
|
||||||
|
await navigate({ to: '/services' })
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleIpMutation = useMutation({
|
||||||
|
mutationFn: ({ ip, enabled }: { ip: string; enabled: boolean }) =>
|
||||||
|
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, { ip, enabled }),
|
||||||
|
onSuccess: async (_data, { enabled }) => {
|
||||||
|
await invalidateService()
|
||||||
|
toast.success(
|
||||||
|
enabled
|
||||||
|
? 'IP включён и добавлен в DNS-привязки'
|
||||||
|
: 'IP выключен и снят с DNS-привязок',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Не удалось переключить IP')
|
||||||
|
},
|
||||||
|
onSettled: () => setTogglingIp(null),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createNodeMut = useMutation({
|
||||||
|
mutationFn: (values: { address: string; port: string }) =>
|
||||||
|
createServiceNode(id, {
|
||||||
|
address: values.address.trim(),
|
||||||
|
port: values.port ? Number(values.port) : null,
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('Нода добавлена, статус CHECKING')
|
||||||
|
await invalidateService()
|
||||||
|
setAddNodeOpen(false)
|
||||||
|
nodeForm.reset()
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteNodeMut = useMutation({
|
||||||
|
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('Нода удалена')
|
||||||
|
await invalidateService()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = viewQuery.isLoading || overviewQuery.isLoading
|
||||||
|
const isError = viewQuery.isError || overviewQuery.isError
|
||||||
|
const error = viewQuery.error ?? overviewQuery.error
|
||||||
|
|
||||||
|
const failoverEvents =
|
||||||
|
(nodes.length > 0 ? nodes : (overview?.nodes ?? []))
|
||||||
|
.filter(
|
||||||
|
(node) =>
|
||||||
|
node.health_status === 'unhealthy' ||
|
||||||
|
node.health_status === 'down' ||
|
||||||
|
node.health_status === 'checking',
|
||||||
|
)
|
||||||
|
.map((node) => ({
|
||||||
|
id: node.address,
|
||||||
|
title: `${node.address}: ${node.health_status}`,
|
||||||
|
detail: node.last_failure_reason
|
||||||
|
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
|
||||||
|
: `fail ${node.consecutive_failures}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const enabledProviders = useMemo(
|
||||||
|
() => enabledHealthProviders(service?.domains ?? []),
|
||||||
|
[service],
|
||||||
|
)
|
||||||
|
const activeProviders =
|
||||||
|
selectedProviders?.filter((provider) => enabledProviders.includes(provider)) ??
|
||||||
|
enabledProviders
|
||||||
|
const effectiveProviders =
|
||||||
|
activeProviders.length > 0 ? activeProviders : enabledProviders
|
||||||
|
const providerStatuses = useMemo(
|
||||||
|
() => providerHealthStatuses(logItems, enabledProviders),
|
||||||
|
[logItems, enabledProviders],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailPanel>
|
<QueryState
|
||||||
<DetailPanel.Header
|
isLoading={isLoading}
|
||||||
title={overview.service.name}
|
isError={isError}
|
||||||
description={`Маршрутизация: ${overview.routing_strategy}. Активные IP: ${
|
error={error}
|
||||||
overview.active_addresses.join(', ') || '—'
|
onRetry={() => {
|
||||||
}`}
|
void viewQuery.refetch()
|
||||||
actions={
|
void overviewQuery.refetch()
|
||||||
<HealthCheckBadge status={overview.service.health_status} />
|
}}
|
||||||
}
|
>
|
||||||
/>
|
{!service ? (
|
||||||
<DetailPanel.Metrics
|
|
||||||
cards={[
|
|
||||||
{
|
|
||||||
id: 'subdomains',
|
|
||||||
icon: <GlobeIcon />,
|
|
||||||
label: 'Поддомены',
|
|
||||||
description:
|
|
||||||
domains.length > 0
|
|
||||||
? domains.map((d) => d.fqdn).join(', ')
|
|
||||||
: 'Нет привязанных FQDN',
|
|
||||||
footer: <Badge variant="outline">{domains.length}</Badge>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'nodes',
|
|
||||||
icon: <ServerIcon />,
|
|
||||||
label: 'Ноды',
|
|
||||||
description:
|
|
||||||
nodes.length > 0
|
|
||||||
? nodes.map((n) => n.address).join(', ')
|
|
||||||
: 'Добавьте ноду, чтобы публиковать DNS',
|
|
||||||
footer: <Badge variant="outline">{nodes.length}</Badge>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'health',
|
|
||||||
icon: <ActivityIcon />,
|
|
||||||
label: 'Пул',
|
|
||||||
description:
|
|
||||||
overview.active_addresses.length > 0
|
|
||||||
? 'Здоровые адреса участвуют в DNS'
|
|
||||||
: 'unknown не попадает в пул, пока не станет healthy',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{domains.length === 0 && nodes.length === 0 ? (
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Пустой сервис"
|
title="Сервис не найден"
|
||||||
description="Добавьте поддомен и ноду, затем настройте health-check."
|
description="Вернитесь в каталог и выберите сервис."
|
||||||
stackedIcon
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : (
|
||||||
</DetailPanel>
|
<div className="@container flex w-full flex-col gap-4 md:gap-6">
|
||||||
|
<PageHeader
|
||||||
|
title={service.name}
|
||||||
|
description="Domain → Service → Node → Health → Failover"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<LbModeTile mode={service.lb_mode} />
|
||||||
|
<HealthCheckBadge status={service.health_status} />
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Изменить"
|
||||||
|
onClick={() => setEditOpen(true)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PencilIcon aria-hidden="true" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Изменить</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<KpiStatGrid
|
||||||
|
cards={[
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
icon: <ActivityIcon />,
|
||||||
|
label: 'Статус',
|
||||||
|
value: service.health_status === 'up' ? 'OK' : service.health_status,
|
||||||
|
variant:
|
||||||
|
service.health_status === 'down'
|
||||||
|
? 'destructive'
|
||||||
|
: service.health_status === 'degraded'
|
||||||
|
? 'warning'
|
||||||
|
: 'default',
|
||||||
|
iconClassName:
|
||||||
|
service.health_status === 'down'
|
||||||
|
? 'text-destructive'
|
||||||
|
: service.health_status === 'degraded'
|
||||||
|
? 'text-warning'
|
||||||
|
: 'text-success',
|
||||||
|
hint: <HealthCheckBadge status={service.health_status} size="xs" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fqdn',
|
||||||
|
icon: <GlobeIcon />,
|
||||||
|
label: 'FQDN',
|
||||||
|
value: String(service.domains.length),
|
||||||
|
hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ip',
|
||||||
|
icon: <NetworkIcon />,
|
||||||
|
label: 'IP',
|
||||||
|
value: String(service.ips.length),
|
||||||
|
hint: `${service.active_ips.length} в пуле`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pool',
|
||||||
|
icon: <ServerIcon />,
|
||||||
|
label: 'Активный пул',
|
||||||
|
value: String((overview?.active_addresses ?? service.active_ips).length),
|
||||||
|
hint: (overview?.active_addresses ?? service.active_ips).join(', ') || 'нет',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<HealthProviderStatusTiles
|
||||||
|
enabled={enabledProviders}
|
||||||
|
selected={effectiveProviders}
|
||||||
|
statuses={providerStatuses}
|
||||||
|
onChange={setSelectedProviders}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-label="Мониторинг"
|
||||||
|
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||||
|
>
|
||||||
|
<ServiceHealthMonitor
|
||||||
|
items={logItems}
|
||||||
|
selectedProviders={effectiveProviders}
|
||||||
|
isLoading={logQuery.isLoading}
|
||||||
|
/>
|
||||||
|
<Frame dense spacing="sm" className="min-w-0 w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Failover</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Нездоровые ноды и причины последней ошибки
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel>
|
||||||
|
<FailoverTimeline events={failoverEvents} />
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{service.ips.length === 0 && service.domains.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="Пустой сервис"
|
||||||
|
description="Добавьте поддомен и ноду, затем настройте health-check."
|
||||||
|
stackedIcon
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ServiceDetailGrid
|
||||||
|
service={service}
|
||||||
|
nodes={nodes}
|
||||||
|
togglingIp={togglingIp}
|
||||||
|
onToggleIp={(ip, enabled) => {
|
||||||
|
setTogglingIp(ip)
|
||||||
|
toggleIpMutation.mutate({ ip, enabled })
|
||||||
|
}}
|
||||||
|
onChangeIp={(row: ServiceFqdnRow) =>
|
||||||
|
setChangeIp({
|
||||||
|
bindingId: row.binding_id,
|
||||||
|
ip: row.target_ips[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onChangeDomain={() => setChangeDomain(true)}
|
||||||
|
onAddNode={() => setAddNodeOpen(true)}
|
||||||
|
onDeleteNode={(nodeId) => deleteNodeMut.mutate(nodeId)}
|
||||||
|
isLoading={nodesQuery.isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ServiceEditSheet
|
||||||
|
mode="edit"
|
||||||
|
service={service}
|
||||||
|
groups={groups}
|
||||||
|
open={editOpen}
|
||||||
|
knownDomains={domainsQuery.data ?? []}
|
||||||
|
isSaving={saving}
|
||||||
|
isDeleting={deleteMutation.isPending}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
onSave={(_serviceId, body) => {
|
||||||
|
setSaving(true)
|
||||||
|
updateMutation.mutate({ body })
|
||||||
|
}}
|
||||||
|
onDelete={() => deleteMutation.mutate()}
|
||||||
|
/>
|
||||||
|
<ChangeIpSheet
|
||||||
|
open={changeIp != null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setChangeIp(null)
|
||||||
|
}}
|
||||||
|
bindingId={changeIp?.bindingId ?? null}
|
||||||
|
serviceId={id}
|
||||||
|
currentIp={changeIp?.ip}
|
||||||
|
/>
|
||||||
|
<ChangeDomainSheet
|
||||||
|
open={changeDomain}
|
||||||
|
onOpenChange={setChangeDomain}
|
||||||
|
serviceId={id}
|
||||||
|
fromDomainId={service.domains[0]?.domain_id ?? null}
|
||||||
|
/>
|
||||||
|
<FormSheet
|
||||||
|
open={addNodeOpen}
|
||||||
|
onOpenChange={setAddNodeOpen}
|
||||||
|
title="Добавить ноду"
|
||||||
|
description="IP станет CHECKING до порога успешных проверок."
|
||||||
|
form={nodeForm}
|
||||||
|
onSubmit={(values) => createNodeMut.mutate(values)}
|
||||||
|
footer={
|
||||||
|
<LoadingButton type="submit" isLoading={createNodeMut.isPending}>
|
||||||
|
Добавить
|
||||||
|
</LoadingButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormFieldSimple label="IP" htmlFor="address">
|
||||||
|
<Input id="address" {...nodeForm.register('address')} placeholder="10.0.0.10" />
|
||||||
|
</FormFieldSimple>
|
||||||
|
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
|
||||||
|
<Input id="port" {...nodeForm.register('port')} placeholder="443" />
|
||||||
|
</FormFieldSimple>
|
||||||
|
</FormSheet>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,143 +1,11 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { useState } from 'react'
|
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { PlusIcon } from 'lucide-react'
|
|
||||||
import { DetailPanel } from '@/components/reui-kit'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
|
||||||
import { FormFieldSimple } from '@/components/form-field'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
|
||||||
import { createServiceNode, deleteServiceNode, serviceNodesQueryOptions } from '@/queries'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({
|
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({
|
||||||
component: ServiceNodesPage,
|
beforeLoad: ({ params }) => {
|
||||||
|
throw redirect({
|
||||||
|
to: '/services/$serviceId',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
component: () => null,
|
||||||
})
|
})
|
||||||
|
|
||||||
interface NodeRow {
|
|
||||||
id: number
|
|
||||||
address: string
|
|
||||||
port: number | null
|
|
||||||
protocol: string
|
|
||||||
health_status: 'up' | 'down' | 'degraded' | 'unknown' | 'healthy' | 'unhealthy' | 'checking' | 'disabled'
|
|
||||||
weight: number
|
|
||||||
priority: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapHealth(
|
|
||||||
status: NodeRow['health_status'],
|
|
||||||
): 'up' | 'down' | 'degraded' | 'unknown' {
|
|
||||||
if (status === 'healthy' || status === 'up') return 'up'
|
|
||||||
if (status === 'unhealthy' || status === 'down') return 'down'
|
|
||||||
if (status === 'degraded') return 'degraded'
|
|
||||||
return 'unknown'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ServiceNodesPage() {
|
|
||||||
const { serviceId } = Route.useParams()
|
|
||||||
const id = Number(serviceId)
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
|
|
||||||
const nodes = (nodesQuery.data ?? []) as NodeRow[]
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const form = useForm<{ address: string; port: string }>({
|
|
||||||
defaultValues: { address: '', port: '' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const createMut = useMutation({
|
|
||||||
mutationFn: (values: { address: string; port: string }) =>
|
|
||||||
createServiceNode(id, {
|
|
||||||
address: values.address.trim(),
|
|
||||||
port: values.port ? Number(values.port) : null,
|
|
||||||
}),
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast.success('Нода добавлена, статус CHECKING')
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['services'] })
|
|
||||||
setOpen(false)
|
|
||||||
form.reset()
|
|
||||||
},
|
|
||||||
onError: (e: unknown) =>
|
|
||||||
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const deleteMut = useMutation({
|
|
||||||
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast.success('Нода удалена')
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['services'] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DetailPanel>
|
|
||||||
<DetailPanel.Header
|
|
||||||
title="Ноды"
|
|
||||||
description="Адреса происхождения сервиса."
|
|
||||||
actions={
|
|
||||||
<Button size="sm" onClick={() => setOpen(true)}>
|
|
||||||
<PlusIcon className="size-4" aria-hidden />
|
|
||||||
Добавить ноду
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{nodes.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title="Нет нод"
|
|
||||||
description="Добавьте IP, затем настройте health-check."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{nodes.map((node) => (
|
|
||||||
<div
|
|
||||||
key={node.id}
|
|
||||||
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">{node.address}</span>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{node.protocol}
|
|
||||||
{node.port ? `:${node.port}` : ''} · вес {node.weight} · приоритет{' '}
|
|
||||||
{node.priority}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<HealthCheckBadge status={mapHealth(node.health_status)} />
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => deleteMut.mutate(node.id)}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FormSheet
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
title="Добавить ноду"
|
|
||||||
description="IP станет CHECKING до порога успешных проверок."
|
|
||||||
form={form}
|
|
||||||
onSubmit={(values) => createMut.mutate(values)}
|
|
||||||
footer={
|
|
||||||
<LoadingButton type="submit" isLoading={createMut.isPending}>
|
|
||||||
Добавить
|
|
||||||
</LoadingButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FormFieldSimple label="IP" htmlFor="address">
|
|
||||||
<Input id="address" {...form.register('address')} placeholder="10.0.0.10" />
|
|
||||||
</FormFieldSimple>
|
|
||||||
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
|
|
||||||
<Input id="port" {...form.register('port')} placeholder="443" />
|
|
||||||
</FormFieldSimple>
|
|
||||||
</FormSheet>
|
|
||||||
</DetailPanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,79 +1,30 @@
|
|||||||
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router'
|
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { ArrowLeftIcon } from 'lucide-react'
|
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import {
|
||||||
import { QueryState } from '@/components/query-state'
|
serviceHealthLogQueryOptions,
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
serviceNodesQueryOptions,
|
||||||
import { serviceOverviewQueryOptions } from '@/queries'
|
serviceOverviewQueryOptions,
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
serviceViewQueryOptions,
|
||||||
|
} from '@/queries'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId')({
|
export const Route = createFileRoute('/_auth/services/$serviceId')({
|
||||||
loader: ({ context: { queryClient }, params }) =>
|
loader: async ({ context: { queryClient }, params }) => {
|
||||||
queryClient.ensureQueryData(serviceOverviewQueryOptions(Number(params.serviceId))),
|
const id = Number(params.serviceId)
|
||||||
|
const [view] = await Promise.all([
|
||||||
|
queryClient.ensureQueryData(serviceViewQueryOptions(id)),
|
||||||
|
queryClient.ensureQueryData(serviceOverviewQueryOptions(id)),
|
||||||
|
queryClient.ensureQueryData(serviceHealthLogQueryOptions(id)),
|
||||||
|
queryClient.ensureQueryData(serviceNodesQueryOptions(id)),
|
||||||
|
])
|
||||||
|
return { breadcrumb: view.name }
|
||||||
|
},
|
||||||
component: ServiceLayout,
|
component: ServiceLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ to: '/services/$serviceId', label: 'Обзор', exact: true },
|
|
||||||
{ to: '/services/$serviceId/subdomains', label: 'Поддомены', exact: false },
|
|
||||||
{ to: '/services/$serviceId/nodes', label: 'Ноды', exact: false },
|
|
||||||
{ to: '/services/$serviceId/health', label: 'Health', exact: false },
|
|
||||||
{ to: '/services/$serviceId/routing', label: 'Маршрутизация', exact: false },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
function ServiceLayout() {
|
function ServiceLayout() {
|
||||||
const { serviceId } = Route.useParams()
|
|
||||||
const id = Number(serviceId)
|
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
|
||||||
const overview = useQuery(serviceOverviewQueryOptions(id))
|
|
||||||
const name = (overview.data as { service?: { name?: string } } | undefined)?.service?.name
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<Outlet />
|
||||||
title={name ?? 'Сервис'}
|
|
||||||
description="Domain → Service → Node → Health → Failover"
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
render={<Link to="/services" />}
|
|
||||||
>
|
|
||||||
<ArrowLeftIcon className="size-4" aria-hidden />
|
|
||||||
К каталогу
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<nav className="flex flex-wrap gap-4 border-b">
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const href = tab.to.replace('$serviceId', serviceId)
|
|
||||||
const active = tab.exact
|
|
||||||
? pathname === `/services/${serviceId}` || pathname === `/services/${serviceId}/`
|
|
||||||
: pathname.startsWith(href)
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={tab.to}
|
|
||||||
to={tab.to}
|
|
||||||
params={{ serviceId }}
|
|
||||||
className={cn(
|
|
||||||
'text-muted-foreground hover:text-foreground pb-3 text-sm font-medium',
|
|
||||||
active && 'text-foreground border-b-2 border-primary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
<QueryState
|
|
||||||
isLoading={overview.isLoading}
|
|
||||||
isError={overview.isError}
|
|
||||||
error={overview.error}
|
|
||||||
onRetry={() => void overview.refetch()}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</QueryState>
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,11 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { DetailPanel } from '@/components/reui-kit'
|
|
||||||
import { FailoverTimeline } from '@/components/failover-timeline'
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { serviceOverviewQueryOptions } from '@/queries'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({
|
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({
|
||||||
component: ServiceRoutingPage,
|
beforeLoad: ({ params }) => {
|
||||||
|
throw redirect({
|
||||||
|
to: '/services/$serviceId',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
component: () => null,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function ServiceRoutingPage() {
|
|
||||||
const { serviceId } = Route.useParams()
|
|
||||||
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
|
|
||||||
const overview = data as {
|
|
||||||
routing_strategy: string
|
|
||||||
active_addresses: string[]
|
|
||||||
nodes: Array<{
|
|
||||||
address: string
|
|
||||||
health_status: string
|
|
||||||
consecutive_failures: number
|
|
||||||
last_failure_reason: string | null
|
|
||||||
}>
|
|
||||||
} | undefined
|
|
||||||
|
|
||||||
const events =
|
|
||||||
overview?.nodes
|
|
||||||
.filter(
|
|
||||||
(node) =>
|
|
||||||
node.health_status === 'unhealthy' ||
|
|
||||||
node.health_status === 'down' ||
|
|
||||||
node.health_status === 'checking',
|
|
||||||
)
|
|
||||||
.map((node) => ({
|
|
||||||
id: node.address,
|
|
||||||
title: `${node.address}: ${node.health_status}`,
|
|
||||||
detail: node.last_failure_reason
|
|
||||||
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
|
|
||||||
: `fail ${node.consecutive_failures}`,
|
|
||||||
})) ?? []
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DetailPanel>
|
|
||||||
<DetailPanel.Header
|
|
||||||
title="Маршрутизация"
|
|
||||||
description="Round Robin / Failover. Weighted на DNS = alias Round Robin."
|
|
||||||
actions={<Badge variant="outline">{overview?.routing_strategy ?? 'round_robin'}</Badge>}
|
|
||||||
/>
|
|
||||||
<p className="text-sm">
|
|
||||||
Активные адреса:{' '}
|
|
||||||
{overview?.active_addresses.join(', ') || 'нет (unknown не в пуле)'}
|
|
||||||
</p>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Запись обновляется в Cloudflare. Распространение зависит от TTL.
|
|
||||||
</p>
|
|
||||||
<FailoverTimeline events={events} />
|
|
||||||
</DetailPanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,114 +1,11 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { ArrowRightLeftIcon } from 'lucide-react'
|
|
||||||
import { DetailPanel } from '@/components/reui-kit'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
|
||||||
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
|
|
||||||
import { serviceOverviewQueryOptions } from '@/queries'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({
|
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({
|
||||||
component: ServiceSubdomainsPage,
|
beforeLoad: ({ params }) => {
|
||||||
|
throw redirect({
|
||||||
|
to: '/services/$serviceId',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
component: () => null,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function ServiceSubdomainsPage() {
|
|
||||||
const { serviceId } = Route.useParams()
|
|
||||||
const id = Number(serviceId)
|
|
||||||
const { data } = useQuery(serviceOverviewQueryOptions(id))
|
|
||||||
const overview = data as {
|
|
||||||
service: {
|
|
||||||
domains: Array<{
|
|
||||||
binding_id: number
|
|
||||||
domain_id: number
|
|
||||||
fqdn: string
|
|
||||||
zone_name: string
|
|
||||||
target_ips: string[]
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
} | undefined
|
|
||||||
const rows = overview?.service.domains ?? []
|
|
||||||
const [changeIp, setChangeIp] = useState<{
|
|
||||||
bindingId: number
|
|
||||||
ip?: string
|
|
||||||
} | null>(null)
|
|
||||||
const [changeDomain, setChangeDomain] = useState(false)
|
|
||||||
const fromDomainId = useMemo(
|
|
||||||
() => rows[0]?.domain_id ?? null,
|
|
||||||
[rows],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DetailPanel>
|
|
||||||
<DetailPanel.Header
|
|
||||||
title="Поддомены"
|
|
||||||
description="FQDN сервиса в одной или нескольких зонах Cloudflare."
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setChangeDomain(true)}
|
|
||||||
disabled={rows.length === 0}
|
|
||||||
>
|
|
||||||
<ArrowRightLeftIcon className="size-4" aria-hidden />
|
|
||||||
Сменить домен
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{rows.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title="Нет поддоменов"
|
|
||||||
description="Привяжите FQDN к сервису из карточки редактирования."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{rows.map((row) => (
|
|
||||||
<div
|
|
||||||
key={row.binding_id}
|
|
||||||
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 flex-col gap-1">
|
|
||||||
<span className="font-medium">{row.fqdn}</span>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{row.zone_name} · {row.target_ips.join(', ') || 'нет IP'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline">{row.target_ips.length} IP</Badge>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
setChangeIp({
|
|
||||||
bindingId: row.binding_id,
|
|
||||||
ip: row.target_ips[0],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Сменить IP
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ChangeIpSheet
|
|
||||||
open={changeIp != null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setChangeIp(null)
|
|
||||||
}}
|
|
||||||
bindingId={changeIp?.bindingId ?? null}
|
|
||||||
serviceId={id}
|
|
||||||
currentIp={changeIp?.ip}
|
|
||||||
/>
|
|
||||||
<ChangeDomainSheet
|
|
||||||
open={changeDomain}
|
|
||||||
onOpenChange={setChangeDomain}
|
|
||||||
serviceId={id}
|
|
||||||
fromDomainId={fromDomainId}
|
|
||||||
/>
|
|
||||||
</DetailPanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
ALTER TABLE service_bindings ADD COLUMN cert_monitoring TEXT NOT NULL DEFAULT 'auto'
|
||||||
|
CHECK (cert_monitoring IN ('auto', 'required', 'skipped'));
|
||||||
|
|
||||||
|
ALTER TABLE certificates ADD COLUMN service_id INTEGER REFERENCES services(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
UPDATE service_bindings
|
||||||
|
SET cert_monitoring = COALESCE(
|
||||||
|
(
|
||||||
|
SELECT d.cert_monitoring FROM domains d
|
||||||
|
WHERE d.id = service_bindings.domain_id
|
||||||
|
),
|
||||||
|
'auto'
|
||||||
|
)
|
||||||
|
WHERE hostname = '@';
|
||||||
|
|
||||||
|
UPDATE service_bindings
|
||||||
|
SET cert_monitoring = COALESCE(
|
||||||
|
(
|
||||||
|
SELECT s.cert_monitoring FROM subdomains s
|
||||||
|
WHERE s.domain_id = service_bindings.domain_id
|
||||||
|
AND s.name = service_bindings.hostname
|
||||||
|
),
|
||||||
|
'auto'
|
||||||
|
)
|
||||||
|
WHERE hostname != '@';
|
||||||
|
|
||||||
|
UPDATE certificates
|
||||||
|
SET service_id = (
|
||||||
|
SELECT sb.service_id
|
||||||
|
FROM service_bindings sb
|
||||||
|
JOIN domains d ON d.id = sb.domain_id
|
||||||
|
WHERE CASE
|
||||||
|
WHEN sb.hostname = '@' THEN d.zone_name
|
||||||
|
ELSE sb.hostname || '.' || d.zone_name
|
||||||
|
END = certificates.hostname
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
+67
-16
@@ -851,6 +851,7 @@ function mapServiceBinding(
|
|||||||
health_check_timeout_ms: row.health_check_timeout_ms,
|
health_check_timeout_ms: row.health_check_timeout_ms,
|
||||||
health_check_verify_tls: row.health_check_verify_tls,
|
health_check_verify_tls: row.health_check_verify_tls,
|
||||||
...mapHealthFields(row),
|
...mapHealthFields(row),
|
||||||
|
cert_monitoring: row.cert_monitoring ?? "auto",
|
||||||
routing_strategy: row.routing_strategy as LbMode,
|
routing_strategy: row.routing_strategy as LbMode,
|
||||||
operation_version: row.operation_version,
|
operation_version: row.operation_version,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
@@ -1530,6 +1531,7 @@ export interface BindingLbPatch {
|
|||||||
health_check_provider?: HealthCheckProvider;
|
health_check_provider?: HealthCheckProvider;
|
||||||
health_check_providers?: HealthCheckProvider[];
|
health_check_providers?: HealthCheckProvider[];
|
||||||
health_check_aggregate?: HealthCheckAggregate;
|
health_check_aggregate?: HealthCheckAggregate;
|
||||||
|
cert_monitoring?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateBindingLbConfig(
|
export function updateBindingLbConfig(
|
||||||
@@ -1560,6 +1562,8 @@ export function updateBindingLbConfig(
|
|||||||
update.health_check_timeout_ms = patch.health_check_timeout_ms;
|
update.health_check_timeout_ms = patch.health_check_timeout_ms;
|
||||||
if (patch.health_check_verify_tls !== undefined)
|
if (patch.health_check_verify_tls !== undefined)
|
||||||
update.health_check_verify_tls = patch.health_check_verify_tls;
|
update.health_check_verify_tls = patch.health_check_verify_tls;
|
||||||
|
if (patch.cert_monitoring !== undefined)
|
||||||
|
update.cert_monitoring = patch.cert_monitoring;
|
||||||
Object.assign(update, healthProviderColumns(patch));
|
Object.assign(update, healthProviderColumns(patch));
|
||||||
db.update(serviceBindings)
|
db.update(serviceBindings)
|
||||||
.set(update)
|
.set(update)
|
||||||
@@ -1669,7 +1673,7 @@ const SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.h
|
|||||||
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
|
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
|
||||||
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
|
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
|
||||||
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider,
|
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider,
|
||||||
sb.health_check_providers, sb.health_check_aggregate, sb.cname_target,
|
sb.health_check_providers, sb.health_check_aggregate, sb.cert_monitoring, sb.cname_target,
|
||||||
d.zone_name, d.group_id, g.name AS group_name,
|
d.zone_name, d.group_id, g.name AS group_name,
|
||||||
s.name AS service_name, s.slug AS service_slug,
|
s.name AS service_name, s.slug AS service_slug,
|
||||||
dr.content AS target_ip, dr.sync_status,
|
dr.content AS target_ip, dr.sync_status,
|
||||||
@@ -1733,6 +1737,7 @@ function enrichServiceBindingView(
|
|||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
cname_target: row.cname_target ?? null,
|
cname_target: row.cname_target ?? null,
|
||||||
|
cert_monitoring: row.cert_monitoring ?? "auto",
|
||||||
...mapHealthFields(row),
|
...mapHealthFields(row),
|
||||||
target_ips,
|
target_ips,
|
||||||
target_ip: target_ips[0] ?? null,
|
target_ip: target_ips[0] ?? null,
|
||||||
@@ -1914,26 +1919,69 @@ export function deleteBinding(db: Db, id: number): void {
|
|||||||
|
|
||||||
// --- Certificates ---
|
// --- Certificates ---
|
||||||
|
|
||||||
|
const CERTIFICATE_SELECT = `c.id, c.domain_id, c.subdomain_id, c.service_id, c.hostname,
|
||||||
|
c.expires_at, c.last_checked_at, c.last_error, c.status, c.created_at, c.updated_at,
|
||||||
|
s.name AS service_name`;
|
||||||
|
|
||||||
|
type CertificateRow = {
|
||||||
|
id: number;
|
||||||
|
domain_id: number;
|
||||||
|
subdomain_id: number | null;
|
||||||
|
service_id: number | null;
|
||||||
|
hostname: string;
|
||||||
|
expires_at: string | null;
|
||||||
|
last_checked_at: string | null;
|
||||||
|
last_error: string | null;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
service_name: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapCertificate(row: CertificateRow): Certificate {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
domain_id: row.domain_id,
|
||||||
|
subdomain_id: row.subdomain_id,
|
||||||
|
service_id: row.service_id ?? null,
|
||||||
|
service_name: row.service_name ?? null,
|
||||||
|
hostname: row.hostname,
|
||||||
|
expires_at: row.expires_at,
|
||||||
|
last_checked_at: row.last_checked_at,
|
||||||
|
last_error: row.last_error,
|
||||||
|
status: row.status,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function listCertificates(db: Db, status?: string): Certificate[] {
|
export function listCertificates(db: Db, status?: string): Certificate[] {
|
||||||
if (status) {
|
const rows = status
|
||||||
return db
|
? db.all<CertificateRow>(sql`
|
||||||
.select()
|
SELECT ${sql.raw(CERTIFICATE_SELECT)}
|
||||||
.from(certificates)
|
FROM certificates c
|
||||||
.where(eq(certificates.status, status))
|
LEFT JOIN services s ON s.id = c.service_id
|
||||||
.orderBy(asc(certificates.expires_at))
|
WHERE c.status = ${status}
|
||||||
.all() as Certificate[];
|
ORDER BY c.expires_at ASC
|
||||||
}
|
`)
|
||||||
return db
|
: db.all<CertificateRow>(sql`
|
||||||
.select()
|
SELECT ${sql.raw(CERTIFICATE_SELECT)}
|
||||||
.from(certificates)
|
FROM certificates c
|
||||||
.orderBy(asc(certificates.expires_at))
|
LEFT JOIN services s ON s.id = c.service_id
|
||||||
.all() as Certificate[];
|
ORDER BY c.expires_at ASC
|
||||||
|
`);
|
||||||
|
return rows.map(mapCertificate);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCertificate(db: Db, id: number): Certificate {
|
export function getCertificate(db: Db, id: number): Certificate {
|
||||||
const row = db.select().from(certificates).where(eq(certificates.id, id)).get();
|
const row = db.all<CertificateRow>(sql`
|
||||||
|
SELECT ${sql.raw(CERTIFICATE_SELECT)}
|
||||||
|
FROM certificates c
|
||||||
|
LEFT JOIN services s ON s.id = c.service_id
|
||||||
|
WHERE c.id = ${id}
|
||||||
|
`)[0];
|
||||||
if (!row) throw new NotFoundError(`certificate ${id}`);
|
if (!row) throw new NotFoundError(`certificate ${id}`);
|
||||||
return row as Certificate;
|
return mapCertificate(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function upsertCertificateCheck(
|
export function upsertCertificateCheck(
|
||||||
@@ -1944,6 +1992,7 @@ export function upsertCertificateCheck(
|
|||||||
expiresAt: string | null,
|
expiresAt: string | null,
|
||||||
status: string,
|
status: string,
|
||||||
lastError: string | null,
|
lastError: string | null,
|
||||||
|
serviceId?: number | null,
|
||||||
): Certificate {
|
): Certificate {
|
||||||
const existing = db
|
const existing = db
|
||||||
.select()
|
.select()
|
||||||
@@ -1956,6 +2005,7 @@ export function upsertCertificateCheck(
|
|||||||
.set({
|
.set({
|
||||||
domain_id: domainId,
|
domain_id: domainId,
|
||||||
subdomain_id: subdomainId,
|
subdomain_id: subdomainId,
|
||||||
|
service_id: serviceId === undefined ? existing.service_id : serviceId,
|
||||||
expires_at: expiresAt,
|
expires_at: expiresAt,
|
||||||
last_checked_at: sql`datetime('now')`,
|
last_checked_at: sql`datetime('now')`,
|
||||||
last_error: lastError,
|
last_error: lastError,
|
||||||
@@ -1972,6 +2022,7 @@ export function upsertCertificateCheck(
|
|||||||
.values({
|
.values({
|
||||||
domain_id: domainId,
|
domain_id: domainId,
|
||||||
subdomain_id: subdomainId,
|
subdomain_id: subdomainId,
|
||||||
|
service_id: serviceId ?? null,
|
||||||
hostname,
|
hostname,
|
||||||
expires_at: expiresAt,
|
expires_at: expiresAt,
|
||||||
last_checked_at: sql`datetime('now')`,
|
last_checked_at: sql`datetime('now')`,
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ export const serviceBindings = sqliteTable(
|
|||||||
health_check_aggregate: text("health_check_aggregate")
|
health_check_aggregate: text("health_check_aggregate")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("majority"),
|
.default("majority"),
|
||||||
|
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
|
||||||
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
|
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
|
||||||
operation_version: integer("operation_version").notNull().default(0),
|
operation_version: integer("operation_version").notNull().default(0),
|
||||||
created_at: text("created_at")
|
created_at: text("created_at")
|
||||||
@@ -324,6 +325,9 @@ export const certificates = sqliteTable("certificates", {
|
|||||||
subdomain_id: integer("subdomain_id").references(() => subdomains.id, {
|
subdomain_id: integer("subdomain_id").references(() => subdomains.id, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
service_id: integer("service_id").references(() => services.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
hostname: text("hostname").notNull().unique(),
|
hostname: text("hostname").notNull().unique(),
|
||||||
expires_at: text("expires_at"),
|
expires_at: text("expires_at"),
|
||||||
last_checked_at: text("last_checked_at"),
|
last_checked_at: text("last_checked_at"),
|
||||||
|
|||||||
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([]),
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export const serviceDomainBindingSchema = z
|
|||||||
health_check_provider: healthCheckProviderSchema.catch('local'),
|
health_check_provider: healthCheckProviderSchema.catch('local'),
|
||||||
health_check_providers: healthCheckProvidersSchema.catch(['local']),
|
health_check_providers: healthCheckProvidersSchema.catch(['local']),
|
||||||
health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
|
health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
|
||||||
|
cert_monitoring: certMonitoringSchema.default('auto'),
|
||||||
sync_status: z.string().nullable().default(null),
|
sync_status: z.string().nullable().default(null),
|
||||||
})
|
})
|
||||||
.transform((binding) => ({
|
.transform((binding) => ({
|
||||||
@@ -205,6 +206,8 @@ export const serviceViewSchema = serviceSchema.extend({
|
|||||||
health_latency_ms: z.number().nullable().default(null),
|
health_latency_ms: z.number().nullable().default(null),
|
||||||
ip_health: z.array(serviceIpHealthSchema).default([]),
|
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||||
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
||||||
|
lb_mode: lbModeSchema.catch('round_robin'),
|
||||||
|
active_ips: z.array(z.string()).default([]),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||||
@@ -264,6 +267,7 @@ export const serviceBindingSchema = z
|
|||||||
health_check_interval_sec: z.number().default(30),
|
health_check_interval_sec: z.number().default(30),
|
||||||
health_check_timeout_ms: z.number().default(3000),
|
health_check_timeout_ms: z.number().default(3000),
|
||||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||||
|
cert_monitoring: certMonitoringSchema.default('auto'),
|
||||||
sync_status: z.string().nullable().default(null),
|
sync_status: z.string().nullable().default(null),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
updated_at: z.string(),
|
updated_at: z.string(),
|
||||||
@@ -301,6 +305,8 @@ export const certificateSchema = z.object({
|
|||||||
id: z.number(),
|
id: z.number(),
|
||||||
domain_id: z.number(),
|
domain_id: z.number(),
|
||||||
subdomain_id: z.number().nullable(),
|
subdomain_id: z.number().nullable(),
|
||||||
|
service_id: z.number().nullable().optional().default(null),
|
||||||
|
service_name: z.string().nullable().optional().default(null),
|
||||||
hostname: z.string(),
|
hostname: z.string(),
|
||||||
expires_at: z.string().nullable(),
|
expires_at: z.string().nullable(),
|
||||||
last_checked_at: z.string().nullable(),
|
last_checked_at: z.string().nullable(),
|
||||||
@@ -310,6 +316,19 @@ export const certificateSchema = z.object({
|
|||||||
updated_at: z.string(),
|
updated_at: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const serviceCertificateRowSchema = z.object({
|
||||||
|
binding_id: z.number(),
|
||||||
|
domain_id: z.number(),
|
||||||
|
service_id: z.number(),
|
||||||
|
hostname: z.string(),
|
||||||
|
cert_monitoring: certMonitoringSchema,
|
||||||
|
id: z.number().nullable(),
|
||||||
|
status: z.string(),
|
||||||
|
expires_at: z.string().nullable(),
|
||||||
|
last_checked_at: z.string().nullable(),
|
||||||
|
last_error: z.string().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
export type Group = z.infer<typeof groupSchema>
|
export type Group = z.infer<typeof groupSchema>
|
||||||
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
|
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
|
||||||
export type Service = z.infer<typeof serviceSchema>
|
export type Service = z.infer<typeof serviceSchema>
|
||||||
@@ -322,6 +341,7 @@ export type Domain = z.infer<typeof domainSchema>
|
|||||||
export type DomainListItem = z.infer<typeof domainListItemSchema>
|
export type DomainListItem = z.infer<typeof domainListItemSchema>
|
||||||
export type DnsRecord = z.infer<typeof dnsRecordSchema>
|
export type DnsRecord = z.infer<typeof dnsRecordSchema>
|
||||||
export type Certificate = z.infer<typeof certificateSchema>
|
export type Certificate = z.infer<typeof certificateSchema>
|
||||||
|
export type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>
|
||||||
|
|
||||||
export const createGroupSchema = z.object({
|
export const createGroupSchema = z.object({
|
||||||
name: z.string().min(1, 'Укажите название'),
|
name: z.string().min(1, 'Укажите название'),
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export interface Certificate {
|
|||||||
id: number;
|
id: number;
|
||||||
domain_id: number;
|
domain_id: number;
|
||||||
subdomain_id: number | null;
|
subdomain_id: number | null;
|
||||||
|
service_id: number | null;
|
||||||
|
service_name: string | null;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
last_checked_at: string | null;
|
last_checked_at: string | null;
|
||||||
@@ -139,6 +141,7 @@ export interface ServiceBinding {
|
|||||||
health_check_provider: HealthCheckProvider;
|
health_check_provider: HealthCheckProvider;
|
||||||
health_check_providers: HealthCheckProvider[];
|
health_check_providers: HealthCheckProvider[];
|
||||||
health_check_aggregate: HealthCheckAggregate;
|
health_check_aggregate: HealthCheckAggregate;
|
||||||
|
cert_monitoring: string;
|
||||||
routing_strategy: LbMode;
|
routing_strategy: LbMode;
|
||||||
operation_version: number;
|
operation_version: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -173,6 +176,7 @@ export interface ServiceBindingView {
|
|||||||
health_check_provider: HealthCheckProvider;
|
health_check_provider: HealthCheckProvider;
|
||||||
health_check_providers: HealthCheckProvider[];
|
health_check_providers: HealthCheckProvider[];
|
||||||
health_check_aggregate: HealthCheckAggregate;
|
health_check_aggregate: HealthCheckAggregate;
|
||||||
|
cert_monitoring: string;
|
||||||
sync_status: string | null;
|
sync_status: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -201,6 +205,7 @@ export interface ServiceDomainBindingView {
|
|||||||
health_check_provider: HealthCheckProvider;
|
health_check_provider: HealthCheckProvider;
|
||||||
health_check_providers: HealthCheckProvider[];
|
health_check_providers: HealthCheckProvider[];
|
||||||
health_check_aggregate: HealthCheckAggregate;
|
health_check_aggregate: HealthCheckAggregate;
|
||||||
|
cert_monitoring: string;
|
||||||
sync_status: string | null;
|
sync_status: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +227,8 @@ export interface ServiceView {
|
|||||||
health_latency_ms: number | null;
|
health_latency_ms: number | null;
|
||||||
ip_health: ServiceIpHealth[];
|
ip_health: ServiceIpHealth[];
|
||||||
ip_enabled: Record<string, boolean>;
|
ip_enabled: Record<string, boolean>;
|
||||||
|
lb_mode: LbMode;
|
||||||
|
active_ips: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupWithStats extends Group {
|
export interface GroupWithStats extends Group {
|
||||||
|
|||||||
Reference in New Issue
Block a user