Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1457389ae7 | ||
|
|
153be28799 | ||
|
|
39fac7834f | ||
|
|
d267e40157 | ||
|
|
634a9dc362 | ||
|
|
1bf6cfa0d0 | ||
|
|
3da6de9311 | ||
|
|
7a8bacade9 | ||
|
|
5d84c7bf6c | ||
|
|
78811bc9b1 | ||
|
|
a458465153 | ||
|
|
69119a08a4 | ||
|
|
ba03d2be9d | ||
|
|
d8fc4ac949 | ||
|
|
d063323402 | ||
|
|
6bced71037 | ||
|
|
44d0eb0114 | ||
|
|
9f00dfcf84 | ||
|
|
9b9dcc3b12 | ||
|
|
6008cd763a | ||
|
|
b9bea44dce | ||
|
|
4c4908558b | ||
|
|
2c92e78b24 | ||
|
|
d63c86065c | ||
|
|
4224db8eb3 | ||
|
|
4ca948292d |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -36,3 +36,10 @@ RUST_LOG=info
|
|||||||
|
|
||||||
# Certificate scheduler (cron)
|
# Certificate scheduler (cron)
|
||||||
CERT_CHECK_CRON=0 0 */6 * * *
|
CERT_CHECK_CRON=0 0 */6 * * *
|
||||||
|
|
||||||
|
# Local health-check engine (override in UI: Настройки → Health-check)
|
||||||
|
# HEALTH_CHECK_CRON=0 */2 * * * *
|
||||||
|
# HEALTH_DEGRADED_FAILURES=1
|
||||||
|
# HEALTH_DOWN_FAILURES=2
|
||||||
|
# HEALTH_SUCCESS_RECOVERIES=2
|
||||||
|
# HEALTH_LATENCY_WARN_MS=1000
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
"build": "tsup src/server.ts --format esm --dts",
|
"build": "tsup --config tsup.config.ts",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
|
|||||||
+15
-64
@@ -7,7 +7,6 @@ import {
|
|||||||
} from "@fastify/type-provider-zod";
|
} from "@fastify/type-provider-zod";
|
||||||
import type { AppConfig } from "./config.js";
|
import type { AppConfig } from "./config.js";
|
||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
import { repos } from "@cfdm/db";
|
|
||||||
import authPlugin from "./plugins/auth.js";
|
import authPlugin from "./plugins/auth.js";
|
||||||
import cfClientPlugin from "./plugins/cf-client.js";
|
import cfClientPlugin from "./plugins/cf-client.js";
|
||||||
import { requireAuth } from "./plugins/auth.js";
|
import { requireAuth } from "./plugins/auth.js";
|
||||||
@@ -34,8 +33,12 @@ import { settingsRoutes } from "./routes/settings.js";
|
|||||||
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
|
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
|
||||||
import { auditRoutes } from "./routes/audit.js";
|
import { auditRoutes } from "./routes/audit.js";
|
||||||
import * as certificateService from "./services/certificate-service.js";
|
import * as certificateService from "./services/certificate-service.js";
|
||||||
import * as healthCheckService from "./services/health-check-service.js";
|
import {
|
||||||
import * as serviceConfigService from "./services/service-config-service.js";
|
createHealthCheckTask,
|
||||||
|
healthEngineFallbacksFromConfig,
|
||||||
|
scheduleHealthCheckJob,
|
||||||
|
} from "./services/health-check-scheduler.js";
|
||||||
|
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
|
||||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
@@ -125,71 +128,19 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const healthTask = new AsyncTask(
|
const healthTask = createHealthCheckTask(app, config);
|
||||||
"health-check",
|
scheduleHealthCheckJob(app, config, healthTask);
|
||||||
async () => {
|
app.decorate("reloadHealthCheckJob", () => {
|
||||||
const thresholds = {
|
scheduleHealthCheckJob(app, config, healthTask);
|
||||||
degradedFailures: config.healthDegradedFailures,
|
});
|
||||||
downFailures: config.healthDownFailures,
|
if (config.cloudflareApiToken) {
|
||||||
latencyWarnMs: config.healthLatencyWarnMs,
|
fireEnsureHealthWorker(
|
||||||
successRecoveries: config.healthSuccessRecoveries,
|
|
||||||
};
|
|
||||||
const n = await healthCheckService.runAllChecks(app.db, {
|
|
||||||
thresholds,
|
|
||||||
probeGapMs: config.healthProbeGapMs,
|
|
||||||
onStatusChange: async (target, prev, next) => {
|
|
||||||
try {
|
|
||||||
const label =
|
|
||||||
next === "up"
|
|
||||||
? "OK"
|
|
||||||
: next === "degraded"
|
|
||||||
? "Slow"
|
|
||||||
: next === "down"
|
|
||||||
? "Down"
|
|
||||||
: "—";
|
|
||||||
repos.insertNotificationLog(
|
|
||||||
app.db,
|
|
||||||
"ip_health",
|
|
||||||
target.scope,
|
|
||||||
target.ref_id,
|
|
||||||
`${target.hostname || target.ip}: ${label}`,
|
|
||||||
`IP ${target.ip}: ${prev ?? "—"} → ${label}`,
|
|
||||||
);
|
|
||||||
await serviceConfigService.reconcileDnsForTarget(
|
|
||||||
app.db,
|
app.db,
|
||||||
app.cf,
|
app.cf,
|
||||||
target.scope,
|
healthEngineFallbacksFromConfig(config),
|
||||||
target.ref_id,
|
app.log,
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
app.log.warn(
|
|
||||||
{ err, scope: target.scope, refId: target.ref_id },
|
|
||||||
"health-check reconcile failed",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
const monitors = await healthCheckService.runDomainMonitors(
|
|
||||||
app.db,
|
|
||||||
thresholds,
|
|
||||||
);
|
|
||||||
app.log.info(
|
|
||||||
{ checked: n, monitors },
|
|
||||||
"health check completed",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
app.log.warn({ err }, "health check failed");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.scheduler.addCronJob(
|
|
||||||
new CronJob(
|
|
||||||
{ cronExpression: config.healthCheckCron },
|
|
||||||
healthTask,
|
|
||||||
{ preventOverrun: true },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export interface AppConfig {
|
|||||||
healthLatencyWarnMs: number;
|
healthLatencyWarnMs: number;
|
||||||
/** Min pause between probes to different physical targets (same IP is probed once). */
|
/** Min pause between probes to different physical targets (same IP is probed once). */
|
||||||
healthProbeGapMs: number;
|
healthProbeGapMs: number;
|
||||||
|
healthWorkerUrl: string;
|
||||||
|
healthWorkerToken: string;
|
||||||
logLevel: string;
|
logLevel: string;
|
||||||
/** Portal SSO — when true, require portal JWT with apps includes cfdm */
|
/** Portal SSO — when true, require portal JWT with apps includes cfdm */
|
||||||
authRequired: boolean;
|
authRequired: boolean;
|
||||||
@@ -61,6 +63,8 @@ export function loadConfig(): AppConfig {
|
|||||||
healthLatencyWarnMs:
|
healthLatencyWarnMs:
|
||||||
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
|
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
|
||||||
healthProbeGapMs: Number(process.env.HEALTH_PROBE_GAP_MS ?? "2000") || 2000,
|
healthProbeGapMs: Number(process.env.HEALTH_PROBE_GAP_MS ?? "2000") || 2000,
|
||||||
|
healthWorkerUrl: (process.env.HEALTH_WORKER_URL ?? "").trim(),
|
||||||
|
healthWorkerToken: (process.env.HEALTH_WORKER_TOKEN ?? "").trim(),
|
||||||
logLevel: process.env.LOG_LEVEL ?? "info",
|
logLevel: process.env.LOG_LEVEL ?? "info",
|
||||||
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||||
authIssuer:
|
authIssuer:
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
} from "@cfdm/shared";
|
} from "@cfdm/shared";
|
||||||
import { createDnsAdapter } from "./cloudflare/dns-service.js";
|
import { createDnsAdapter } from "./cloudflare/dns-service.js";
|
||||||
import { createHealthCheckAdapter, type CfHealthCheckPayload } from "./cloudflare/healthcheck-service.js";
|
import { createHealthCheckAdapter, type CfHealthCheckPayload } from "./cloudflare/healthcheck-service.js";
|
||||||
|
import { createKvAdapter } from "./cloudflare/kv-service.js";
|
||||||
|
import { createWorkersAdapter } from "./cloudflare/workers-service.js";
|
||||||
import { createZoneAdapter } from "./cloudflare/zone-service.js";
|
import { createZoneAdapter } from "./cloudflare/zone-service.js";
|
||||||
|
|
||||||
export type { CfHealthCheckPayload };
|
export type { CfHealthCheckPayload };
|
||||||
@@ -15,11 +17,21 @@ export class CloudflareClient {
|
|||||||
private readonly zones;
|
private readonly zones;
|
||||||
private readonly dns;
|
private readonly dns;
|
||||||
private readonly healthchecks;
|
private readonly healthchecks;
|
||||||
|
private readonly kv;
|
||||||
|
private readonly workers;
|
||||||
|
private readonly token;
|
||||||
|
|
||||||
constructor(token: string) {
|
constructor(token: string) {
|
||||||
|
this.token = token.trim();
|
||||||
this.zones = createZoneAdapter(token);
|
this.zones = createZoneAdapter(token);
|
||||||
this.dns = createDnsAdapter(token);
|
this.dns = createDnsAdapter(token);
|
||||||
this.healthchecks = createHealthCheckAdapter(token);
|
this.healthchecks = createHealthCheckAdapter(token);
|
||||||
|
this.kv = createKvAdapter(token);
|
||||||
|
this.workers = createWorkersAdapter(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConfigured(): boolean {
|
||||||
|
return this.token.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
listZones(): Promise<CfZone[]> {
|
listZones(): Promise<CfZone[]> {
|
||||||
@@ -81,4 +93,45 @@ export class CloudflareClient {
|
|||||||
deleteHealthCheck(zoneId: string, id: string): Promise<void> {
|
deleteHealthCheck(zoneId: string, id: string): Promise<void> {
|
||||||
return this.healthchecks.deleteHealthCheck(zoneId, id);
|
return this.healthchecks.deleteHealthCheck(zoneId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listAccounts() {
|
||||||
|
return this.workers.listAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
listKvNamespaces(accountId: string) {
|
||||||
|
return this.kv.listNamespaces(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
createKvNamespace(accountId: string, title: string) {
|
||||||
|
return this.kv.createNamespace(accountId, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
kvGet(accountId: string, namespaceId: string, key: string) {
|
||||||
|
return this.kv.getValue(accountId, namespaceId, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
kvPut(accountId: string, namespaceId: string, key: string, value: string) {
|
||||||
|
return this.kv.putValue(accountId, namespaceId, key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
putWorkerScript(opts: {
|
||||||
|
accountId: string;
|
||||||
|
scriptName: string;
|
||||||
|
source: string;
|
||||||
|
kvNamespaceId: string;
|
||||||
|
}) {
|
||||||
|
return this.workers.putScript(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
putWorkerSchedules(accountId: string, scriptName: string, crons: string[]) {
|
||||||
|
return this.workers.putSchedules(accountId, scriptName, crons);
|
||||||
|
}
|
||||||
|
|
||||||
|
enableWorkersDev(accountId: string, scriptName: string) {
|
||||||
|
return this.workers.enableWorkersDev(accountId, scriptName);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkersSubdomain(accountId: string) {
|
||||||
|
return this.workers.getWorkersSubdomain(accountId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ export function mapCloudflareFailure(
|
|||||||
): AppError {
|
): AppError {
|
||||||
const lower = message.toLowerCase();
|
const lower = message.toLowerCase();
|
||||||
if (status === 401 || status === 403 || lower.includes("authentication")) {
|
if (status === 401 || status === 403 || lower.includes("authentication")) {
|
||||||
|
if (
|
||||||
|
operation.includes("workers") ||
|
||||||
|
operation.includes("kv_") ||
|
||||||
|
operation.includes("accounts")
|
||||||
|
) {
|
||||||
|
return AppError.cloudflareAuthFailed(
|
||||||
|
"Токену нужны права Account: Workers Scripts Write и Workers KV Storage Write. Zone DNS недостаточно.",
|
||||||
|
);
|
||||||
|
}
|
||||||
return AppError.cloudflareAuthFailed(
|
return AppError.cloudflareAuthFailed(
|
||||||
"Cloudflare отклонил токен. Проверьте CLOUDFLARE_API_TOKEN.",
|
"Cloudflare отклонил токен. Проверьте CLOUDFLARE_API_TOKEN.",
|
||||||
);
|
);
|
||||||
@@ -67,6 +76,40 @@ export async function handleCfResponse<T>(
|
|||||||
return body.result;
|
return body.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** KV PUT / schedules often return `{ success: true }` without `result`. */
|
||||||
|
export async function handleCfSuccess(
|
||||||
|
response: Response,
|
||||||
|
operation: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (response.status === 429) {
|
||||||
|
const wait = parseRetryAfter(response.headers) ?? 5000;
|
||||||
|
throw AppError.rateLimited(
|
||||||
|
`Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw mapCloudflareFailure(operation, response.status, String(response.status));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let body: CfResponse<unknown>;
|
||||||
|
try {
|
||||||
|
body = JSON.parse(text) as CfResponse<unknown>;
|
||||||
|
} catch {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw mapCloudflareFailure(operation, response.status, text.slice(0, 180));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!body.success) {
|
||||||
|
const msg =
|
||||||
|
body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
|
||||||
|
throw mapCloudflareFailure(operation, response.status, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function cfRequest<T>(
|
export async function cfRequest<T>(
|
||||||
token: string,
|
token: string,
|
||||||
path: string,
|
path: string,
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { CF_API_BASE, handleCfResponse, handleCfSuccess, mapCloudflareFailure } from "./http.js";
|
||||||
|
|
||||||
|
export interface CfKvNamespace {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createKvAdapter(token: string) {
|
||||||
|
return {
|
||||||
|
async listNamespaces(accountId: string): Promise<CfKvNamespace[]> {
|
||||||
|
const all: CfKvNamespace[] = [];
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const url = new URL(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
|
||||||
|
);
|
||||||
|
url.searchParams.set("per_page", "100");
|
||||||
|
url.searchParams.set("page", String(page));
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("kv_list", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
const batch = await handleCfResponse<CfKvNamespace[]>(response, "kv_list");
|
||||||
|
all.push(...batch);
|
||||||
|
if (batch.length < 100) break;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createNamespace(accountId: string, title: string): Promise<CfKvNamespace> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ title }),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("kv_create", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
return handleCfResponse<CfKvNamespace>(response, "kv_create");
|
||||||
|
},
|
||||||
|
|
||||||
|
async getValue(
|
||||||
|
accountId: string,
|
||||||
|
namespaceId: string,
|
||||||
|
key: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("kv_get", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => "");
|
||||||
|
throw mapCloudflareFailure("kv_get", response.status, text.slice(0, 180));
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
},
|
||||||
|
|
||||||
|
async putValue(
|
||||||
|
accountId: string,
|
||||||
|
namespaceId: string,
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "text/plain",
|
||||||
|
},
|
||||||
|
body: value,
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("kv_put", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
await handleCfSuccess(response, "kv_put");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import {
|
||||||
|
CF_API_BASE,
|
||||||
|
handleCfResponse,
|
||||||
|
handleCfSuccess,
|
||||||
|
mapCloudflareFailure,
|
||||||
|
} from "./http.js";
|
||||||
|
|
||||||
|
export interface CfAccount {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CfWorkersSubdomain {
|
||||||
|
subdomain?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkersAdapter(token: string) {
|
||||||
|
return {
|
||||||
|
async listAccounts(): Promise<CfAccount[]> {
|
||||||
|
const response = await fetch(`${CF_API_BASE}/accounts?per_page=50`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("list_accounts", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
return handleCfResponse<CfAccount[]>(response, "list_accounts");
|
||||||
|
},
|
||||||
|
|
||||||
|
async putScript(opts: {
|
||||||
|
accountId: string;
|
||||||
|
scriptName: string;
|
||||||
|
source: string;
|
||||||
|
kvNamespaceId: string;
|
||||||
|
filename?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const filename = opts.filename ?? "index.mjs";
|
||||||
|
const metadata = {
|
||||||
|
main_module: filename,
|
||||||
|
compatibility_date: "2025-04-01",
|
||||||
|
bindings: [
|
||||||
|
{
|
||||||
|
type: "kv_namespace",
|
||||||
|
name: "HEALTH_KV",
|
||||||
|
namespace_id: opts.kvNamespaceId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const form = new FormData();
|
||||||
|
form.append(
|
||||||
|
"metadata",
|
||||||
|
new Blob([JSON.stringify(metadata)], { type: "application/json" }),
|
||||||
|
);
|
||||||
|
form.append(
|
||||||
|
filename,
|
||||||
|
new Blob([opts.source], { type: "application/javascript+module" }),
|
||||||
|
filename,
|
||||||
|
);
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: form,
|
||||||
|
signal: AbortSignal.timeout(60_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("workers_put_script", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
await handleCfSuccess(response, "workers_put_script");
|
||||||
|
},
|
||||||
|
|
||||||
|
async putSchedules(
|
||||||
|
accountId: string,
|
||||||
|
scriptName: string,
|
||||||
|
crons: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/schedules`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(crons.map((cron) => ({ cron }))),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("workers_put_schedules", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
await handleCfSuccess(response, "workers_put_schedules");
|
||||||
|
},
|
||||||
|
|
||||||
|
async enableWorkersDev(
|
||||||
|
accountId: string,
|
||||||
|
scriptName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled: true }),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status === 409) return;
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("workers_subdomain", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
if (!response.ok && response.status !== 200 && response.status !== 201) {
|
||||||
|
await handleCfSuccess(response, "workers_subdomain");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getWorkersSubdomain(accountId: string): Promise<string | null> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/accounts/${accountId}/workers/subdomain`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("workers_get_subdomain", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
const result = await handleCfResponse<CfWorkersSubdomain>(
|
||||||
|
response,
|
||||||
|
"workers_get_subdomain",
|
||||||
|
);
|
||||||
|
return result.subdomain?.trim() || null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||||
|
import {
|
||||||
|
clampGlobalpingLimit,
|
||||||
|
parseGlobalpingLocations,
|
||||||
|
} from "@cfdm/shared";
|
||||||
|
|
||||||
|
export const GLOBALPING_API_ROOT = "https://api.globalping.io";
|
||||||
|
export const GLOBALPING_MIN_POLL_MS = 500;
|
||||||
|
export const GLOBALPING_UA = "CFDM-health/1.0";
|
||||||
|
|
||||||
|
export interface GlobalpingClientOptions {
|
||||||
|
token?: string | null;
|
||||||
|
locations?: string;
|
||||||
|
limit?: number;
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
maxWaitMs?: number;
|
||||||
|
fetchImpl?: typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobalpingProbeResult {
|
||||||
|
ok: boolean;
|
||||||
|
latencyMs: number;
|
||||||
|
error: string | null;
|
||||||
|
colo: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeasurementCreateBody {
|
||||||
|
type: "ping" | "http";
|
||||||
|
target: string;
|
||||||
|
inProgressUpdates: false;
|
||||||
|
limit: number;
|
||||||
|
locations: Array<{ magic: string }>;
|
||||||
|
measurementOptions: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeasurementProbe {
|
||||||
|
continent?: string;
|
||||||
|
country?: string;
|
||||||
|
city?: string;
|
||||||
|
network?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeasurementResultRow {
|
||||||
|
probe?: MeasurementProbe;
|
||||||
|
result?: {
|
||||||
|
status?: string;
|
||||||
|
statusCode?: number;
|
||||||
|
timings?: { total?: number };
|
||||||
|
stats?: { avg?: number; loss?: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeasurementResponse {
|
||||||
|
id?: string;
|
||||||
|
status?: string;
|
||||||
|
results?: MeasurementResultRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function locationLabel(probe: MeasurementProbe | undefined): string | null {
|
||||||
|
if (!probe) return null;
|
||||||
|
const city = probe.city?.trim();
|
||||||
|
const country = probe.country?.trim();
|
||||||
|
if (city && country) return `${city}, ${country}`;
|
||||||
|
return city || country || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMeasurementBody(
|
||||||
|
target: HealthCheckTarget,
|
||||||
|
options: GlobalpingClientOptions,
|
||||||
|
): MeasurementCreateBody {
|
||||||
|
const limit = clampGlobalpingLimit(options.limit, 3);
|
||||||
|
const locations = parseGlobalpingLocations(options.locations).map((magic) => ({
|
||||||
|
magic,
|
||||||
|
}));
|
||||||
|
const port = target.port ?? (target.type === "http" ? 80 : 80);
|
||||||
|
const ip = String(target.ip || "").trim();
|
||||||
|
const hostname = (target.hostname || ip).trim();
|
||||||
|
|
||||||
|
if (target.type === "http") {
|
||||||
|
const path = target.path?.trim() || "/";
|
||||||
|
const protocol = port === 443 ? "HTTPS" : "HTTP";
|
||||||
|
return {
|
||||||
|
type: "http",
|
||||||
|
target: ip,
|
||||||
|
inProgressUpdates: false,
|
||||||
|
limit,
|
||||||
|
locations,
|
||||||
|
measurementOptions: {
|
||||||
|
protocol,
|
||||||
|
port,
|
||||||
|
request: {
|
||||||
|
method: "GET",
|
||||||
|
host: hostname,
|
||||||
|
path: path.startsWith("/") ? path : `/${path}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "ping",
|
||||||
|
target: ip,
|
||||||
|
inProgressUpdates: false,
|
||||||
|
limit,
|
||||||
|
locations,
|
||||||
|
measurementOptions: {
|
||||||
|
protocol: "TCP",
|
||||||
|
port,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowOk(target: HealthCheckTarget, row: MeasurementResultRow): boolean {
|
||||||
|
const result = row.result;
|
||||||
|
if (!result) return false;
|
||||||
|
const status = String(result.status ?? "").toLowerCase();
|
||||||
|
if (status && status !== "finished") return false;
|
||||||
|
if (target.type === "http") {
|
||||||
|
const code = result.statusCode;
|
||||||
|
if (code == null) return false;
|
||||||
|
if (target.expected_status != null) return code === target.expected_status;
|
||||||
|
return code >= 200 && code < 400;
|
||||||
|
}
|
||||||
|
const loss = result.stats?.loss;
|
||||||
|
if (loss != null && loss >= 100) return false;
|
||||||
|
return status === "finished" || status === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowLatency(row: MeasurementResultRow): number {
|
||||||
|
const total = row.result?.timings?.total;
|
||||||
|
if (typeof total === "number" && Number.isFinite(total)) return Math.round(total);
|
||||||
|
const avg = row.result?.stats?.avg;
|
||||||
|
if (typeof avg === "number" && Number.isFinite(avg)) return Math.round(avg);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeMeasurement(
|
||||||
|
target: HealthCheckTarget,
|
||||||
|
doc: MeasurementResponse,
|
||||||
|
): GlobalpingProbeResult {
|
||||||
|
const rows = doc.results ?? [];
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: "Globalping: пустой результат",
|
||||||
|
colo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const oks = rows.map((row) => rowOk(target, row));
|
||||||
|
const okCount = oks.filter(Boolean).length;
|
||||||
|
const ok = okCount > rows.length / 2;
|
||||||
|
const latencies = rows.map(rowLatency);
|
||||||
|
const latencyMs = Math.round(
|
||||||
|
latencies.reduce((sum, n) => sum + n, 0) / latencies.length,
|
||||||
|
);
|
||||||
|
const colo =
|
||||||
|
locationLabel(rows.find((_, i) => oks[i])?.probe) ??
|
||||||
|
locationLabel(rows[0]?.probe);
|
||||||
|
if (ok) {
|
||||||
|
return { ok: true, latencyMs, error: null, colo };
|
||||||
|
}
|
||||||
|
const expected =
|
||||||
|
target.type === "http" && target.expected_status != null
|
||||||
|
? `ожидали HTTP ${target.expected_status}`
|
||||||
|
: target.type === "http"
|
||||||
|
? "ожидали HTTP 2xx/3xx"
|
||||||
|
: "TCP ping с packet loss < 100%";
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs,
|
||||||
|
error: `Globalping: ${okCount}/${rows.length} проб успешны (${expected})`,
|
||||||
|
colo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseJson(response: Response): Promise<MeasurementResponse> {
|
||||||
|
try {
|
||||||
|
return (await response.json()) as MeasurementResponse;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runGlobalpingMeasurement(
|
||||||
|
target: HealthCheckTarget,
|
||||||
|
options: GlobalpingClientOptions = {},
|
||||||
|
): Promise<GlobalpingProbeResult> {
|
||||||
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
const pollMs =
|
||||||
|
options.pollIntervalMs === undefined
|
||||||
|
? GLOBALPING_MIN_POLL_MS
|
||||||
|
: Math.max(0, options.pollIntervalMs);
|
||||||
|
const maxWaitMs = options.maxWaitMs ?? Math.max(target.timeout_ms ?? 3000, 3000) + 15_000;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": GLOBALPING_UA,
|
||||||
|
};
|
||||||
|
const token = options.token?.trim();
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const created = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(buildMeasurementBody(target, options)),
|
||||||
|
});
|
||||||
|
if (created.status === 429) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: "Globalping: 429 rate limit",
|
||||||
|
colo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (created.status !== 202 && created.status !== 200) {
|
||||||
|
const body = await parseJson(created);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: `Globalping: HTTP ${created.status}${body.status ? ` (${body.status})` : ""}`,
|
||||||
|
colo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const createdBody = await parseJson(created);
|
||||||
|
const id = createdBody.id?.trim();
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: "Globalping: нет id измерения",
|
||||||
|
colo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = Date.now();
|
||||||
|
while (Date.now() - started < maxWaitMs) {
|
||||||
|
await sleep(pollMs);
|
||||||
|
const polled = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements/${id}`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": GLOBALPING_UA,
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (polled.status === 429) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: "Globalping: 429 rate limit",
|
||||||
|
colo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!polled.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: `Globalping: HTTP ${polled.status} при опросе`,
|
||||||
|
colo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const doc = await parseJson(polled);
|
||||||
|
if (String(doc.status ?? "").toLowerCase() === "in-progress") continue;
|
||||||
|
return summarizeMeasurement(target, doc);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: Date.now() - started,
|
||||||
|
error: "Globalping: timeout ожидания measurement",
|
||||||
|
colo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { healthStatusQuerySchema } from "@cfdm/shared";
|
import { healthStatusQuerySchema } from "@cfdm/shared";
|
||||||
import { repos } from "@cfdm/db";
|
import { getAppSettings, repos } from "@cfdm/db";
|
||||||
import * as healthCheckService from "../services/health-check-service.js";
|
import * as healthCheckService from "../services/health-check-service.js";
|
||||||
import * as serviceConfigService from "../services/service-config-service.js";
|
import * as serviceConfigService from "../services/service-config-service.js";
|
||||||
|
import {
|
||||||
|
healthEngineFallbacksFromConfig,
|
||||||
|
} from "../services/health-check-scheduler.js";
|
||||||
|
import { mailboxFromSettings } from "../services/health/health-worker-deploy.js";
|
||||||
|
import { cronStaleAfterMs } from "../services/health/mailbox.js";
|
||||||
|
|
||||||
export async function healthCheckRoutes(app: FastifyInstance) {
|
export async function healthCheckRoutes(app: FastifyInstance) {
|
||||||
app.get("/health-status", async (request) => {
|
app.get("/health-status", async (request) => {
|
||||||
@@ -16,15 +21,22 @@ export async function healthCheckRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.post("/health-check/run", async (request) => {
|
app.post("/health-check/run", async (request) => {
|
||||||
const config = request.server.config;
|
const config = request.server.config;
|
||||||
|
const fallbacks = healthEngineFallbacksFromConfig(config);
|
||||||
|
const settings = getAppSettings(
|
||||||
|
request.server.db,
|
||||||
|
fallbacks,
|
||||||
|
);
|
||||||
const thresholds = {
|
const thresholds = {
|
||||||
degradedFailures: config.healthDegradedFailures,
|
degradedFailures: settings.healthDegradedFailures,
|
||||||
downFailures: config.healthDownFailures,
|
downFailures: settings.healthDownFailures,
|
||||||
latencyWarnMs: config.healthLatencyWarnMs,
|
latencyWarnMs: settings.healthLatencyWarnMs,
|
||||||
successRecoveries: config.healthSuccessRecoveries,
|
successRecoveries: settings.healthSuccessRecoveries,
|
||||||
};
|
};
|
||||||
const checked = await healthCheckService.runAllChecks(request.server.db, {
|
const checked = await healthCheckService.runAllChecks(request.server.db, {
|
||||||
thresholds,
|
thresholds,
|
||||||
probeGapMs: config.healthProbeGapMs,
|
probeGapMs: config.healthProbeGapMs,
|
||||||
|
mailbox: mailboxFromSettings(request.server.db, request.server.cf, fallbacks),
|
||||||
|
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
|
||||||
onStatusChange: async (target, prev, next) => {
|
onStatusChange: async (target, prev, next) => {
|
||||||
try {
|
try {
|
||||||
const label =
|
const label =
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
changeDomainSchema,
|
changeDomainSchema,
|
||||||
createServiceNodeSchema,
|
createServiceNodeSchema,
|
||||||
reorderServicesSchema,
|
reorderServicesSchema,
|
||||||
|
toggleServiceIpSchema,
|
||||||
updateServiceConfigSchema,
|
updateServiceConfigSchema,
|
||||||
updateServiceNodeSchema,
|
updateServiceNodeSchema,
|
||||||
} from "@cfdm/shared";
|
} from "@cfdm/shared";
|
||||||
@@ -11,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) {
|
||||||
@@ -64,6 +66,35 @@ export async function serviceRoutes(app: FastifyInstance) {
|
|||||||
return serviceConfig.getView(request.server.db, Number(id));
|
return serviceConfig.getView(request.server.db, Number(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/services/:id/health-log", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
repos.getService(request.server.db, Number(id));
|
||||||
|
return {
|
||||||
|
items: repos.listHealthProbeLogForService(
|
||||||
|
request.server.db,
|
||||||
|
Number(id),
|
||||||
|
200,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/services/:id/certificates", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return certificateService.listServiceCertificates(
|
||||||
|
request.server.db,
|
||||||
|
Number(id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/services/:id/certificates/check", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const checked = await certificateService.runServiceChecks(
|
||||||
|
request.server.db,
|
||||||
|
Number(id),
|
||||||
|
);
|
||||||
|
return { checked };
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/services/:id/overview", async (request) => {
|
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));
|
||||||
@@ -191,4 +222,26 @@ export async function serviceRoutes(app: FastifyInstance) {
|
|||||||
body.enabled,
|
body.enabled,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch("/services/:id/ips/toggle", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = toggleServiceIpSchema.parse(request.body);
|
||||||
|
const view = await serviceConfig.toggleServiceIp(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
Number(id),
|
||||||
|
body.ip,
|
||||||
|
body.enabled,
|
||||||
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "service.ip.toggle",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(id),
|
||||||
|
summary: body.enabled
|
||||||
|
? `Включён IP ${body.ip} сервиса «${view.name}»`
|
||||||
|
: `Выключен IP ${body.ip} сервиса «${view.name}»`,
|
||||||
|
details: { ip: body.ip, enabled: body.enabled },
|
||||||
|
});
|
||||||
|
return view;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,65 @@ import {
|
|||||||
updateAppSettings,
|
updateAppSettings,
|
||||||
} from "@cfdm/db";
|
} from "@cfdm/db";
|
||||||
import { pingVpsTracker } from "../services/vps-tracker-sync.js";
|
import { pingVpsTracker } from "../services/vps-tracker-sync.js";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
import {
|
||||||
|
assertValidHealthCron,
|
||||||
|
healthEngineFallbacksFromConfig,
|
||||||
|
} from "../services/health-check-scheduler.js";
|
||||||
|
import { ensureHealthWorker } from "../services/health/health-worker-deploy.js";
|
||||||
|
|
||||||
export async function settingsRoutes(app: FastifyInstance) {
|
export async function settingsRoutes(app: FastifyInstance) {
|
||||||
app.get("/settings", async (request) => {
|
app.get("/settings", async (request) => {
|
||||||
return getAppSettings(request.server.db);
|
return getAppSettings(
|
||||||
|
request.server.db,
|
||||||
|
healthEngineFallbacksFromConfig(request.server.config),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/settings", async (request) => {
|
app.patch("/settings", async (request) => {
|
||||||
const body = appSettingsPatchSchema.parse(request.body);
|
const parsed = appSettingsPatchSchema.safeParse(request.body);
|
||||||
return updateAppSettings(request.server.db, body);
|
if (!parsed.success) {
|
||||||
|
throw AppError.validation(
|
||||||
|
parsed.error.issues[0]?.message ?? "некорректные настройки",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const body = parsed.data;
|
||||||
|
if (body.healthCheckCron) {
|
||||||
|
assertValidHealthCron(body.healthCheckCron);
|
||||||
|
}
|
||||||
|
const fallbacks = healthEngineFallbacksFromConfig(request.server.config);
|
||||||
|
const current = getAppSettings(request.server.db, fallbacks);
|
||||||
|
const nextDegraded =
|
||||||
|
body.healthDegradedFailures ?? current.healthDegradedFailures;
|
||||||
|
const nextDown = body.healthDownFailures ?? current.healthDownFailures;
|
||||||
|
if (nextDown < nextDegraded) {
|
||||||
|
throw AppError.validation(
|
||||||
|
"ошибок до down не меньше, чем до degraded",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
updateAppSettings(request.server.db, body, fallbacks);
|
||||||
|
if (body.healthCheckCron !== undefined) {
|
||||||
|
request.server.reloadHealthCheckJob?.();
|
||||||
|
const after = getAppSettings(request.server.db, fallbacks);
|
||||||
|
if (after.healthWorkerKvNamespaceId) {
|
||||||
|
try {
|
||||||
|
await ensureHealthWorker(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
fallbacks,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// error stored in settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getAppSettings(request.server.db, fallbacks);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/settings/health/worker/ensure", async (request) => {
|
||||||
|
const fallbacks = healthEngineFallbacksFromConfig(request.server.config);
|
||||||
|
await ensureHealthWorker(request.server.db, request.server.cf, fallbacks);
|
||||||
|
return getAppSettings(request.server.db, fallbacks);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/settings/vps-tracker/test", async (request) => {
|
app.post("/settings/vps-tracker/test", async (request) => {
|
||||||
|
|||||||
@@ -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,6 +193,13 @@ 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;
|
||||||
|
|
||||||
|
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
|
||||||
|
if (subdomain && !subdomain.enabled) continue;
|
||||||
|
|
||||||
|
const mode = binding.cert_monitoring ?? CERT_MONITOR_AUTO;
|
||||||
|
if (mode === CERT_MONITOR_SKIPPED) continue;
|
||||||
|
if (mode === CERT_MONITOR_AUTO) {
|
||||||
if (
|
if (
|
||||||
!hasSslHealthGate(
|
!hasSslHealthGate(
|
||||||
{
|
{
|
||||||
@@ -187,87 +211,22 @@ export function buildServiceCertificateFqdns(
|
|||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
} else if (mode !== CERT_MONITOR_REQUIRED) {
|
||||||
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
|
continue;
|
||||||
if (subdomain && !subdomain.enabled) 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);
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||||
|
import {
|
||||||
|
getAppSettings,
|
||||||
|
getAppSettingsSecrets,
|
||||||
|
updateAppSettings,
|
||||||
|
type HealthEngineFallbacks,
|
||||||
|
} from "@cfdm/db";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type { AppConfig } from "../config.js";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
import * as healthCheckService from "./health-check-service.js";
|
||||||
|
import * as serviceConfigService from "./service-config-service.js";
|
||||||
|
import {
|
||||||
|
mailboxFromSettings,
|
||||||
|
} from "./health/health-worker-deploy.js";
|
||||||
|
import { cronStaleAfterMs } from "./health/mailbox.js";
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyInstance {
|
||||||
|
reloadHealthCheckJob?: () => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HEALTH_CHECK_JOB_ID = "health-check";
|
||||||
|
|
||||||
|
export function healthEngineFallbacksFromConfig(
|
||||||
|
config: AppConfig,
|
||||||
|
): HealthEngineFallbacks {
|
||||||
|
return {
|
||||||
|
healthCheckCron: config.healthCheckCron,
|
||||||
|
healthDegradedFailures: config.healthDegradedFailures,
|
||||||
|
healthDownFailures: config.healthDownFailures,
|
||||||
|
healthLatencyWarnMs: config.healthLatencyWarnMs,
|
||||||
|
healthSuccessRecoveries: config.healthSuccessRecoveries,
|
||||||
|
healthWorkerUrl: config.healthWorkerUrl,
|
||||||
|
healthWorkerTokenSet: Boolean(config.healthWorkerToken),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertValidHealthCron(expr: string): void {
|
||||||
|
const cronExpression = expr.trim();
|
||||||
|
const parts = cronExpression.split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length < 5 || parts.length > 6) {
|
||||||
|
throw AppError.validation("некорректное cron-выражение");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const job = new CronJob(
|
||||||
|
{ cronExpression },
|
||||||
|
new AsyncTask("validate-cron", async () => undefined),
|
||||||
|
{ id: "validate-cron" },
|
||||||
|
);
|
||||||
|
job.stop();
|
||||||
|
} catch {
|
||||||
|
throw AppError.validation("некорректное cron-выражение");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHealthCheckTask(
|
||||||
|
app: FastifyInstance,
|
||||||
|
config: AppConfig,
|
||||||
|
): AsyncTask {
|
||||||
|
const fallbacks = healthEngineFallbacksFromConfig(config);
|
||||||
|
return new AsyncTask(
|
||||||
|
HEALTH_CHECK_JOB_ID,
|
||||||
|
async () => {
|
||||||
|
const settings = getAppSettings(app.db, fallbacks);
|
||||||
|
const thresholds = {
|
||||||
|
degradedFailures: settings.healthDegradedFailures,
|
||||||
|
downFailures: settings.healthDownFailures,
|
||||||
|
latencyWarnMs: settings.healthLatencyWarnMs,
|
||||||
|
successRecoveries: settings.healthSuccessRecoveries,
|
||||||
|
};
|
||||||
|
const mailbox = mailboxFromSettings(app.db, app.cf, fallbacks);
|
||||||
|
const secrets = getAppSettingsSecrets(app.db);
|
||||||
|
const n = await healthCheckService.runAllChecks(app.db, {
|
||||||
|
thresholds,
|
||||||
|
probeGapMs: config.healthProbeGapMs,
|
||||||
|
mailbox,
|
||||||
|
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
|
||||||
|
globalping: {
|
||||||
|
token: secrets.globalpingToken,
|
||||||
|
locations: secrets.globalpingLocations,
|
||||||
|
limit: secrets.globalpingLimit,
|
||||||
|
},
|
||||||
|
onStatusChange: async (target, prev, next) => {
|
||||||
|
try {
|
||||||
|
const label =
|
||||||
|
next === "up"
|
||||||
|
? "OK"
|
||||||
|
: next === "degraded"
|
||||||
|
? "Slow"
|
||||||
|
: next === "down"
|
||||||
|
? "Down"
|
||||||
|
: "—";
|
||||||
|
repos.insertNotificationLog(
|
||||||
|
app.db,
|
||||||
|
"ip_health",
|
||||||
|
target.scope,
|
||||||
|
target.ref_id,
|
||||||
|
`${target.hostname || target.ip}: ${label}`,
|
||||||
|
`IP ${target.ip}: ${prev ?? "—"} → ${label}`,
|
||||||
|
);
|
||||||
|
await serviceConfigService.reconcileDnsForTarget(
|
||||||
|
app.db,
|
||||||
|
app.cf,
|
||||||
|
target.scope,
|
||||||
|
target.ref_id,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn(
|
||||||
|
{ err, scope: target.scope, refId: target.ref_id },
|
||||||
|
"health-check reconcile failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (mailbox) {
|
||||||
|
updateAppSettings(
|
||||||
|
app.db,
|
||||||
|
{ healthWorkerLastIngestAt: new Date().toISOString() },
|
||||||
|
fallbacks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const monitors = await healthCheckService.runDomainMonitors(
|
||||||
|
app.db,
|
||||||
|
thresholds,
|
||||||
|
);
|
||||||
|
app.log.info({ checked: n, monitors }, "health check completed");
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
app.log.warn({ err }, "health check failed");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleHealthCheckJob(
|
||||||
|
app: FastifyInstance,
|
||||||
|
config: AppConfig,
|
||||||
|
task: AsyncTask,
|
||||||
|
): void {
|
||||||
|
const scheduler = app.scheduler;
|
||||||
|
if (!scheduler) return;
|
||||||
|
if (scheduler.existsById(HEALTH_CHECK_JOB_ID)) {
|
||||||
|
scheduler.removeById(HEALTH_CHECK_JOB_ID);
|
||||||
|
}
|
||||||
|
const settings = getAppSettings(
|
||||||
|
app.db,
|
||||||
|
healthEngineFallbacksFromConfig(config),
|
||||||
|
);
|
||||||
|
scheduler.addCronJob(
|
||||||
|
new CronJob(
|
||||||
|
{ cronExpression: settings.healthCheckCron },
|
||||||
|
task,
|
||||||
|
{ preventOverrun: true, id: HEALTH_CHECK_JOB_ID },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,28 @@ import { resolve4, resolve6 } from "node:dns/promises";
|
|||||||
import { Agent, buildConnector, fetch as undiciFetch } from "undici";
|
import { Agent, buildConnector, fetch as undiciFetch } from "undici";
|
||||||
import type { Db } from "@cfdm/db";
|
import type { Db } from "@cfdm/db";
|
||||||
import { repos } from "@cfdm/db";
|
import { repos } from "@cfdm/db";
|
||||||
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
|
import type { HealthCheckTarget, IpHealthState, HealthCheckProvider } from "@cfdm/shared";
|
||||||
|
import {
|
||||||
|
aggregateHealthOk,
|
||||||
|
parseHealthAggregate,
|
||||||
|
targetProviders,
|
||||||
|
} from "@cfdm/shared";
|
||||||
import { AppError } from "../errors.js";
|
import { AppError } from "../errors.js";
|
||||||
import { nextHealthState } from "./health/state-machine.js";
|
import { nextHealthState } from "./health/state-machine.js";
|
||||||
|
import { LocalHealthCheckProvider } from "./health/local.js";
|
||||||
|
import { workerNotConfiguredResult } from "./health/worker.js";
|
||||||
|
import {
|
||||||
|
globalpingNotConfiguredResult,
|
||||||
|
probeWithGlobalping,
|
||||||
|
} from "./health/globalping.js";
|
||||||
|
import {
|
||||||
|
buildTargetsDoc,
|
||||||
|
indexResults,
|
||||||
|
isResultsStale,
|
||||||
|
originProbeKey,
|
||||||
|
type HealthMailbox,
|
||||||
|
} from "./health/mailbox.js";
|
||||||
|
import type { GlobalpingClientOptions } from "../lib/globalping-client.js";
|
||||||
|
|
||||||
export interface HealthCheckThresholds {
|
export interface HealthCheckThresholds {
|
||||||
degradedFailures: number;
|
degradedFailures: number;
|
||||||
@@ -18,6 +37,7 @@ export interface ProbeResult {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
latencyMs: number;
|
latencyMs: number;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
colo?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bracket IPv6 for URL authority; leave IPv4/hostname as-is. */
|
/** Bracket IPv6 for URL authority; leave IPv4/hostname as-is. */
|
||||||
@@ -261,6 +281,11 @@ export interface RunAllChecksOptions {
|
|||||||
thresholds: HealthCheckThresholds;
|
thresholds: HealthCheckThresholds;
|
||||||
/** Pause between unique physical probes (default 2000). Same IP is only probed once. */
|
/** Pause between unique physical probes (default 2000). Same IP is only probed once. */
|
||||||
probeGapMs?: number;
|
probeGapMs?: number;
|
||||||
|
/** KV mailbox with Worker results. Missing → cloudflare targets fail, never Local fallback. */
|
||||||
|
mailbox?: HealthMailbox | null;
|
||||||
|
/** Results older than this are stale (default 10 min). */
|
||||||
|
staleAfterMs?: number;
|
||||||
|
globalping?: GlobalpingClientOptions | null;
|
||||||
onStatusChange?: (
|
onStatusChange?: (
|
||||||
target: HealthCheckTarget,
|
target: HealthCheckTarget,
|
||||||
prevState: IpHealthState | null,
|
prevState: IpHealthState | null,
|
||||||
@@ -273,55 +298,60 @@ function sleep(ms: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One network hit per key. Group+binding on the same IP share a single TCP/HTTP probe
|
* One network hit per origin+provider. Group+binding on the same IP share a probe.
|
||||||
* so anti-bot / rate-limit on the origin is not tripped by back-to-back checks.
|
|
||||||
*/
|
*/
|
||||||
export function physicalProbeKey(target: HealthCheckTarget): string {
|
export function physicalProbeKey(
|
||||||
const port = target.port ?? (target.type === "http" ? 80 : 80);
|
target: HealthCheckTarget,
|
||||||
const ip = String(target.ip || "").trim().toLowerCase();
|
provider: HealthCheckProvider = target.provider,
|
||||||
if (target.type === "http") {
|
): string {
|
||||||
const path = (target.path?.trim() || "/") || "/";
|
return `${provider}|${originProbeKey(target)}`;
|
||||||
const expected = target.expected_status ?? "";
|
|
||||||
return `http|${ip}|${port}|${path}|${expected}`;
|
|
||||||
}
|
|
||||||
if (target.type === "tcp") return `tcp|${ip}|${port}`;
|
|
||||||
if (target.type === "ping") {
|
|
||||||
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
|
||||||
}
|
|
||||||
if (target.type === "dns") {
|
|
||||||
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
|
||||||
}
|
|
||||||
return `${target.type}|${ip}|${port}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runAllChecks(
|
function logSourceResult(
|
||||||
db: Db,
|
db: Db,
|
||||||
|
target: HealthCheckTarget,
|
||||||
|
provider: HealthCheckProvider,
|
||||||
|
result: ProbeResult,
|
||||||
|
): void {
|
||||||
|
repos.insertHealthProbeLog(db, {
|
||||||
|
scope: target.scope,
|
||||||
|
refId: target.ref_id,
|
||||||
|
ip: target.ip,
|
||||||
|
provider,
|
||||||
|
status: result.ok ? "up" : "down",
|
||||||
|
ok: result.ok,
|
||||||
|
latencyMs: result.latencyMs,
|
||||||
|
colo: result.colo ?? null,
|
||||||
|
error: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAggregatedStatus(
|
||||||
|
db: Db,
|
||||||
|
target: HealthCheckTarget,
|
||||||
|
sources: Array<{ provider: HealthCheckProvider; result: ProbeResult }>,
|
||||||
options: RunAllChecksOptions,
|
options: RunAllChecksOptions,
|
||||||
): Promise<number> {
|
): void {
|
||||||
const targets = repos.listHealthCheckTargets(db);
|
const policy = parseHealthAggregate(target.aggregate);
|
||||||
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
|
const oks = sources.map((s) => s.result.ok);
|
||||||
|
const aggregatedOk = aggregateHealthOk(oks, policy);
|
||||||
|
const latencies = sources.map((s) => s.result.latencyMs);
|
||||||
|
const latencyMs = latencies.length
|
||||||
|
? Math.round(latencies.reduce((sum, n) => sum + n, 0) / latencies.length)
|
||||||
|
: 0;
|
||||||
|
const colo =
|
||||||
|
sources.find((s) => s.result.colo)?.result.colo ??
|
||||||
|
sources[0]?.result.colo ??
|
||||||
|
null;
|
||||||
|
const error = aggregatedOk
|
||||||
|
? null
|
||||||
|
: sources
|
||||||
|
.map((s) => s.result.error)
|
||||||
|
.filter((msg): msg is string => Boolean(msg))
|
||||||
|
.join("; ") || "health aggregate down";
|
||||||
|
const statusProvider =
|
||||||
|
sources.length > 1 ? "aggregate" : (sources[0]?.provider ?? target.provider);
|
||||||
|
|
||||||
const byPhysical = new Map<string, HealthCheckTarget[]>();
|
|
||||||
for (const target of targets) {
|
|
||||||
const key = physicalProbeKey(target);
|
|
||||||
const list = byPhysical.get(key);
|
|
||||||
if (list) list.push(target);
|
|
||||||
else byPhysical.set(key, [target]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let probeIndex = 0;
|
|
||||||
for (const group of byPhysical.values()) {
|
|
||||||
if (probeIndex > 0 && gapMs > 0) {
|
|
||||||
await sleep(gapMs);
|
|
||||||
}
|
|
||||||
probeIndex += 1;
|
|
||||||
|
|
||||||
// Prefer binding hostname for SNI when several scopes share one IP.
|
|
||||||
const representative =
|
|
||||||
group.find((t) => t.scope === "binding") ?? group[0]!;
|
|
||||||
const result = await probeTarget(representative);
|
|
||||||
|
|
||||||
for (const target of group) {
|
|
||||||
const prev = repos.getIpHealthStatusRow(
|
const prev = repos.getIpHealthStatusRow(
|
||||||
db,
|
db,
|
||||||
target.scope,
|
target.scope,
|
||||||
@@ -329,8 +359,8 @@ export async function runAllChecks(
|
|||||||
target.ip,
|
target.ip,
|
||||||
);
|
);
|
||||||
const { state, failures, successes, node } = deriveState(
|
const { state, failures, successes, node } = deriveState(
|
||||||
result.ok,
|
aggregatedOk,
|
||||||
result.latencyMs,
|
latencyMs,
|
||||||
prev
|
prev
|
||||||
? {
|
? {
|
||||||
consecutive_failures: prev.consecutive_failures,
|
consecutive_failures: prev.consecutive_failures,
|
||||||
@@ -349,10 +379,11 @@ export async function runAllChecks(
|
|||||||
target.ref_id,
|
target.ref_id,
|
||||||
target.ip,
|
target.ip,
|
||||||
state,
|
state,
|
||||||
result.latencyMs,
|
latencyMs,
|
||||||
failures,
|
failures,
|
||||||
result.error,
|
error,
|
||||||
successes,
|
successes,
|
||||||
|
{ colo, provider: statusProvider },
|
||||||
);
|
);
|
||||||
const matchedNode = repos.findNodeByIp(db, target.ip);
|
const matchedNode = repos.findNodeByIp(db, target.ip);
|
||||||
if (matchedNode && matchedNode.enabled) {
|
if (matchedNode && matchedNode.enabled) {
|
||||||
@@ -361,15 +392,130 @@ export async function runAllChecks(
|
|||||||
consecutive_failures: failures,
|
consecutive_failures: failures,
|
||||||
consecutive_successes: successes,
|
consecutive_successes: successes,
|
||||||
last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19),
|
last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
last_failure_reason: result.error,
|
last_failure_reason: error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (prevState !== state) {
|
if (prevState !== state) {
|
||||||
options.onStatusChange?.(target, prevState, state);
|
options.onStatusChange?.(target, prevState, state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function staleWorkerResult(colo: string | null): ProbeResult {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: "Cloudflare Worker: результаты устарели или KV пуст",
|
||||||
|
colo,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
// Orphan rows (old IPs / hostname keys) still feed MAX latency on group badge.
|
|
||||||
|
export async function runAllChecks(
|
||||||
|
db: Db,
|
||||||
|
options: RunAllChecksOptions,
|
||||||
|
): Promise<number> {
|
||||||
|
const targets = repos.listHealthCheckTargets(db);
|
||||||
|
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
|
||||||
|
const local = new LocalHealthCheckProvider();
|
||||||
|
const staleAfterMs = options.staleAfterMs ?? 10 * 60_000;
|
||||||
|
|
||||||
|
const byOrigin = new Map<string, HealthCheckTarget[]>();
|
||||||
|
for (const target of targets) {
|
||||||
|
const key = originProbeKey(target);
|
||||||
|
const list = byOrigin.get(key);
|
||||||
|
if (list) list.push(target);
|
||||||
|
else byOrigin.set(key, [target]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const needsCloudflare = targets.some((t) =>
|
||||||
|
targetProviders(t).includes("cloudflare"),
|
||||||
|
);
|
||||||
|
let mailboxResults = new Map<string, { ok: boolean; latencyMs: number; error: string | null }>();
|
||||||
|
let mailboxColo: string | null = null;
|
||||||
|
let mailboxStale = true;
|
||||||
|
const mailbox = options.mailbox ?? null;
|
||||||
|
if (needsCloudflare) {
|
||||||
|
const resultsDoc = mailbox ? await mailbox.getResults() : null;
|
||||||
|
mailboxResults = indexResults(resultsDoc);
|
||||||
|
mailboxStale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);
|
||||||
|
mailboxColo = resultsDoc?.colo ?? null;
|
||||||
|
if (mailbox) {
|
||||||
|
try {
|
||||||
|
const next = buildTargetsDoc(targets);
|
||||||
|
const current = await mailbox.getTargets();
|
||||||
|
if (current?.fingerprint !== next.fingerprint) {
|
||||||
|
await mailbox.putTargets(next);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ingest still proceeds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const probeCache = new Map<string, ProbeResult>();
|
||||||
|
let probeIndex = 0;
|
||||||
|
|
||||||
|
async function resolveProvider(
|
||||||
|
provider: HealthCheckProvider,
|
||||||
|
representative: HealthCheckTarget,
|
||||||
|
originKey: string,
|
||||||
|
): Promise<ProbeResult> {
|
||||||
|
const cacheKey = `${provider}|${originKey}`;
|
||||||
|
const cached = probeCache.get(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
let result: ProbeResult;
|
||||||
|
if (provider === "local") {
|
||||||
|
if (probeIndex > 0 && gapMs > 0) await sleep(gapMs);
|
||||||
|
probeIndex += 1;
|
||||||
|
result = await local.probe(representative);
|
||||||
|
} else if (provider === "cloudflare") {
|
||||||
|
const item = mailboxResults.get(originKey);
|
||||||
|
if (!mailbox) result = workerNotConfiguredResult();
|
||||||
|
else if (mailboxStale || !item) result = staleWorkerResult(mailboxColo);
|
||||||
|
else {
|
||||||
|
result = {
|
||||||
|
ok: item.ok,
|
||||||
|
latencyMs: item.latencyMs,
|
||||||
|
error: item.error,
|
||||||
|
colo: mailboxColo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!options.globalping?.token?.trim()) {
|
||||||
|
result = globalpingNotConfiguredResult();
|
||||||
|
} else {
|
||||||
|
if (probeIndex > 0 && gapMs > 0) await sleep(gapMs);
|
||||||
|
probeIndex += 1;
|
||||||
|
result = await probeWithGlobalping(representative, options.globalping);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
probeCache.set(cacheKey, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [originKey, group] of byOrigin) {
|
||||||
|
const representative =
|
||||||
|
group.find((t) => t.scope === "binding") ?? group[0]!;
|
||||||
|
const needed = new Set<HealthCheckProvider>();
|
||||||
|
for (const target of group) {
|
||||||
|
for (const provider of targetProviders(target)) needed.add(provider);
|
||||||
|
}
|
||||||
|
for (const provider of needed) {
|
||||||
|
await resolveProvider(provider, representative, originKey);
|
||||||
|
}
|
||||||
|
for (const target of group) {
|
||||||
|
const providers = targetProviders(target);
|
||||||
|
const sources = providers.map((provider) => ({
|
||||||
|
provider,
|
||||||
|
result: probeCache.get(`${provider}|${originKey}`)!,
|
||||||
|
}));
|
||||||
|
for (const source of sources) {
|
||||||
|
logSourceResult(db, target, source.provider, source.result);
|
||||||
|
}
|
||||||
|
applyAggregatedStatus(db, target, sources, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
repos.pruneStaleIpHealthStatus(db, targets);
|
repos.pruneStaleIpHealthStatus(db, targets);
|
||||||
return targets.length;
|
return targets.length;
|
||||||
}
|
}
|
||||||
@@ -392,6 +538,9 @@ export async function runDomainMonitors(
|
|||||||
expected_status: monitor.expected_status,
|
expected_status: monitor.expected_status,
|
||||||
timeout_ms: monitor.timeout_ms,
|
timeout_ms: monitor.timeout_ms,
|
||||||
verify_tls: false,
|
verify_tls: false,
|
||||||
|
provider: "local",
|
||||||
|
providers: ["local"],
|
||||||
|
aggregate: "majority",
|
||||||
};
|
};
|
||||||
let result: ProbeResult;
|
let result: ProbeResult;
|
||||||
if (monitor.type === "http") {
|
if (monitor.type === "http") {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||||
|
import {
|
||||||
|
runGlobalpingMeasurement,
|
||||||
|
type GlobalpingClientOptions,
|
||||||
|
} from "../../lib/globalping-client.js";
|
||||||
|
import type { ProbeResult } from "../health-check-service.js";
|
||||||
|
|
||||||
|
export function globalpingNotConfiguredResult(): ProbeResult {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: "Globalping: токен не задан",
|
||||||
|
colo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function probeWithGlobalping(
|
||||||
|
target: HealthCheckTarget,
|
||||||
|
options: GlobalpingClientOptions,
|
||||||
|
): Promise<ProbeResult> {
|
||||||
|
if (!options.token?.trim()) {
|
||||||
|
return globalpingNotConfiguredResult();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await runGlobalpingMeasurement(target, options);
|
||||||
|
return {
|
||||||
|
ok: result.ok,
|
||||||
|
latencyMs: result.latencyMs,
|
||||||
|
error: result.error,
|
||||||
|
colo: result.colo,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: err instanceof Error ? err.message : "Globalping: ошибка запроса",
|
||||||
|
colo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export function loadHealthProbeWorkerSource(): string {
|
||||||
|
const dir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const candidates = [
|
||||||
|
join(dir, "health-probe-worker.mjs"),
|
||||||
|
join(process.cwd(), "dist/health-probe-worker.mjs"),
|
||||||
|
join(process.cwd(), "health-probe-worker.mjs"),
|
||||||
|
join(dir, "../../../../../workers/health-probe/src/index.mjs"),
|
||||||
|
join(process.cwd(), "../../workers/health-probe/src/index.mjs"),
|
||||||
|
join(process.cwd(), "workers/health-probe/src/index.mjs"),
|
||||||
|
];
|
||||||
|
for (const path of candidates) {
|
||||||
|
if (existsSync(path)) {
|
||||||
|
return readFileSync(path, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("не найден исходник Worker health-probe");
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { getAppSettings, repos, updateAppSettings, type HealthEngineFallbacks } from "@cfdm/db";
|
||||||
|
import type { Db } from "@cfdm/db";
|
||||||
|
import { HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, targetHasProvider } from "@cfdm/shared";
|
||||||
|
import type { CloudflareClient } from "../../lib/cf-client.js";
|
||||||
|
import { AppError } from "../../errors.js";
|
||||||
|
import { loadHealthProbeWorkerSource } from "./health-probe-script.js";
|
||||||
|
import {
|
||||||
|
buildTargetsDoc,
|
||||||
|
createCloudflareKvMailbox,
|
||||||
|
toCloudflareCron,
|
||||||
|
type HealthMailbox,
|
||||||
|
} from "./mailbox.js";
|
||||||
|
|
||||||
|
export const DEFAULT_HEALTH_FALLBACKS: HealthEngineFallbacks = {
|
||||||
|
healthCheckCron: "0 */2 * * * *",
|
||||||
|
healthDegradedFailures: 1,
|
||||||
|
healthDownFailures: 2,
|
||||||
|
healthLatencyWarnMs: 1000,
|
||||||
|
healthSuccessRecoveries: 2,
|
||||||
|
healthWorkerUrl: "",
|
||||||
|
healthWorkerTokenSet: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolveAccountId(
|
||||||
|
cf: CloudflareClient,
|
||||||
|
db: Db,
|
||||||
|
cached?: string | null,
|
||||||
|
): Promise<string> {
|
||||||
|
const trimmed = cached?.trim();
|
||||||
|
if (trimmed) return trimmed;
|
||||||
|
const domains = repos.listDomains(db);
|
||||||
|
for (const domain of domains) {
|
||||||
|
if (!domain.cf_zone_id) continue;
|
||||||
|
try {
|
||||||
|
const zone = await cf.getZone(domain.cf_zone_id);
|
||||||
|
const id = zone.account?.id?.trim();
|
||||||
|
if (id) return id;
|
||||||
|
} catch {
|
||||||
|
// try next zone / accounts list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const accounts = await cf.listAccounts();
|
||||||
|
const id = accounts[0]?.id?.trim();
|
||||||
|
if (!id) {
|
||||||
|
throw AppError.cloudflare(
|
||||||
|
"Не удалось определить Cloudflare account_id. Добавьте зону или расширьте права токена (Account Settings Read).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureKvNamespace(
|
||||||
|
cf: CloudflareClient,
|
||||||
|
accountId: string,
|
||||||
|
existingId?: string | null,
|
||||||
|
): Promise<string> {
|
||||||
|
if (existingId?.trim()) return existingId.trim();
|
||||||
|
const listed = await cf.listKvNamespaces(accountId);
|
||||||
|
const found = listed.find((ns) => ns.title === HEALTH_PROBE_KV_TITLE);
|
||||||
|
if (found?.id) return found.id;
|
||||||
|
const created = await cf.createKvNamespace(accountId, HEALTH_PROBE_KV_TITLE);
|
||||||
|
if (!created.id) {
|
||||||
|
throw AppError.cloudflare("Cloudflare не вернул id KV namespace");
|
||||||
|
}
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureHealthWorker(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
fallbacks: HealthEngineFallbacks,
|
||||||
|
): Promise<{ url: string; kvNamespaceId: string; accountId: string }> {
|
||||||
|
const settings = getAppSettings(db, fallbacks);
|
||||||
|
try {
|
||||||
|
const accountId = await resolveAccountId(cf, db, settings.healthWorkerAccountId);
|
||||||
|
const kvNamespaceId = await ensureKvNamespace(
|
||||||
|
cf,
|
||||||
|
accountId,
|
||||||
|
settings.healthWorkerKvNamespaceId,
|
||||||
|
);
|
||||||
|
const source = loadHealthProbeWorkerSource();
|
||||||
|
await cf.putWorkerScript({
|
||||||
|
accountId,
|
||||||
|
scriptName: HEALTH_PROBE_SCRIPT_NAME,
|
||||||
|
source,
|
||||||
|
kvNamespaceId,
|
||||||
|
});
|
||||||
|
await cf.putWorkerSchedules(accountId, HEALTH_PROBE_SCRIPT_NAME, [
|
||||||
|
toCloudflareCron(settings.healthCheckCron),
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
await cf.enableWorkersDev(accountId, HEALTH_PROBE_SCRIPT_NAME);
|
||||||
|
} catch {
|
||||||
|
// workers.dev may already be on
|
||||||
|
}
|
||||||
|
const subdomain = await cf.getWorkersSubdomain(accountId);
|
||||||
|
const url = subdomain
|
||||||
|
? `https://${HEALTH_PROBE_SCRIPT_NAME}.${subdomain}.workers.dev`
|
||||||
|
: settings.healthWorkerUrl || `https://${HEALTH_PROBE_SCRIPT_NAME}.workers.dev`;
|
||||||
|
|
||||||
|
updateAppSettings(
|
||||||
|
db,
|
||||||
|
{
|
||||||
|
healthWorkerAccountId: accountId,
|
||||||
|
healthWorkerKvNamespaceId: kvNamespaceId,
|
||||||
|
healthWorkerUrl: url,
|
||||||
|
healthWorkerError: null,
|
||||||
|
healthWorkerDeployedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
fallbacks,
|
||||||
|
);
|
||||||
|
|
||||||
|
await syncCloudflareTargetsToKv(db, cf, fallbacks);
|
||||||
|
return { url, kvNamespaceId, accountId };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
updateAppSettings(db, { healthWorkerError: message }, fallbacks);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function maybeEnsureHealthWorker(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
fallbacks: HealthEngineFallbacks,
|
||||||
|
): Promise<void> {
|
||||||
|
const hasCloudflare = repos
|
||||||
|
.listHealthCheckTargets(db)
|
||||||
|
.some((target) => targetHasProvider(target, "cloudflare"));
|
||||||
|
if (!hasCloudflare) return;
|
||||||
|
const settings = getAppSettings(db, fallbacks);
|
||||||
|
if (settings.healthWorkerKvNamespaceId.trim() && !settings.healthWorkerError) {
|
||||||
|
await syncCloudflareTargetsToKv(db, cf, fallbacks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ensureHealthWorker(db, cf, fallbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mailboxFromSettings(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
fallbacks: HealthEngineFallbacks,
|
||||||
|
): HealthMailbox | null {
|
||||||
|
const settings = getAppSettings(db, fallbacks);
|
||||||
|
const accountId = settings.healthWorkerAccountId.trim();
|
||||||
|
const ns = settings.healthWorkerKvNamespaceId.trim();
|
||||||
|
if (!accountId || !ns) return null;
|
||||||
|
return createCloudflareKvMailbox(cf, accountId, ns);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncCloudflareTargetsToKv(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
fallbacks: HealthEngineFallbacks,
|
||||||
|
mailbox?: HealthMailbox | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const box = mailbox ?? mailboxFromSettings(db, cf, fallbacks);
|
||||||
|
if (!box) return;
|
||||||
|
const next = buildTargetsDoc(repos.listHealthCheckTargets(db));
|
||||||
|
const current = await box.getTargets();
|
||||||
|
if (current?.fingerprint === next.fingerprint) return;
|
||||||
|
await box.putTargets(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fireEnsureHealthWorker(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
fallbacks: HealthEngineFallbacks,
|
||||||
|
log?: { warn: (obj: unknown, msg: string) => void },
|
||||||
|
): void {
|
||||||
|
if (process.env.VITEST) return;
|
||||||
|
if (!cf.isConfigured) return;
|
||||||
|
const hasCloudflare = repos
|
||||||
|
.listHealthCheckTargets(db)
|
||||||
|
.some((target) => targetHasProvider(target, "cloudflare"));
|
||||||
|
if (!hasCloudflare) {
|
||||||
|
void syncCloudflareTargetsToKv(db, cf, fallbacks).catch((err) => {
|
||||||
|
log?.warn({ err }, "health worker KV sync failed");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void maybeEnsureHealthWorker(db, cf, fallbacks).catch((err) => {
|
||||||
|
log?.warn({ err }, "health worker ensure failed");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import type {
|
||||||
|
HealthCheckTarget,
|
||||||
|
HealthProbeResultItem,
|
||||||
|
HealthProbeResultsDoc,
|
||||||
|
HealthProbeTargetItem,
|
||||||
|
HealthProbeTargetsDoc,
|
||||||
|
} from "@cfdm/shared";
|
||||||
|
import { HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, targetHasProvider } from "@cfdm/shared";
|
||||||
|
import type { CloudflareClient } from "../../lib/cf-client.js";
|
||||||
|
|
||||||
|
export interface HealthMailbox {
|
||||||
|
getTargets(): Promise<HealthProbeTargetsDoc | null>;
|
||||||
|
putTargets(doc: HealthProbeTargetsDoc): Promise<void>;
|
||||||
|
getResults(): Promise<HealthProbeResultsDoc | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCloudflareKvMailbox(
|
||||||
|
cf: CloudflareClient,
|
||||||
|
accountId: string,
|
||||||
|
namespaceId: string,
|
||||||
|
): HealthMailbox {
|
||||||
|
return {
|
||||||
|
async getTargets() {
|
||||||
|
return readJson<HealthProbeTargetsDoc>(cf, accountId, namespaceId, HEALTH_KV_TARGETS_KEY);
|
||||||
|
},
|
||||||
|
async putTargets(doc) {
|
||||||
|
await cf.kvPut(accountId, namespaceId, HEALTH_KV_TARGETS_KEY, JSON.stringify(doc));
|
||||||
|
},
|
||||||
|
async getResults() {
|
||||||
|
return readJson<HealthProbeResultsDoc>(cf, accountId, namespaceId, HEALTH_KV_RESULTS_KEY);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson<T>(
|
||||||
|
cf: CloudflareClient,
|
||||||
|
accountId: string,
|
||||||
|
namespaceId: string,
|
||||||
|
key: string,
|
||||||
|
): Promise<T | null> {
|
||||||
|
const raw = await cf.kvGet(accountId, namespaceId, key);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as T;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function originProbeKey(target: HealthCheckTarget): string {
|
||||||
|
const port = target.port ?? (target.type === "http" ? 80 : 80);
|
||||||
|
const ip = String(target.ip || "").trim().toLowerCase();
|
||||||
|
if (target.type === "http") {
|
||||||
|
const path = (target.path?.trim() || "/") || "/";
|
||||||
|
const expected = target.expected_status ?? "";
|
||||||
|
return `http|${ip}|${port}|${path}|${expected}`;
|
||||||
|
}
|
||||||
|
if (target.type === "tcp") return `tcp|${ip}|${port}`;
|
||||||
|
if (target.type === "ping") {
|
||||||
|
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
if (target.type === "dns") {
|
||||||
|
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
return `${target.type}|${ip}|${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloudflareMailboxTargets(
|
||||||
|
targets: HealthCheckTarget[],
|
||||||
|
): HealthProbeTargetItem[] {
|
||||||
|
const unique = new Map<string, HealthProbeTargetItem>();
|
||||||
|
for (const target of targets) {
|
||||||
|
if (!targetHasProvider(target, "cloudflare")) continue;
|
||||||
|
if (target.type !== "tcp" && target.type !== "http") continue;
|
||||||
|
const key = originProbeKey(target);
|
||||||
|
if (unique.has(key)) continue;
|
||||||
|
unique.set(key, {
|
||||||
|
key,
|
||||||
|
ip: target.ip,
|
||||||
|
hostname: target.hostname || target.ip,
|
||||||
|
type: target.type,
|
||||||
|
port: target.port ?? (target.type === "http" ? 80 : 80),
|
||||||
|
path: target.path ?? "/",
|
||||||
|
expectedStatus: target.expected_status,
|
||||||
|
timeoutMs: target.timeout_ms ?? 3000,
|
||||||
|
verifyTls: Boolean(target.verify_tls),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...unique.values()].sort((a, b) => a.key.localeCompare(b.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fingerprintTargets(items: HealthProbeTargetItem[]): string {
|
||||||
|
return items
|
||||||
|
.map(
|
||||||
|
(item) =>
|
||||||
|
`${item.key}|${item.hostname}|${item.timeoutMs ?? ""}|${item.verifyTls ? "1" : "0"}`,
|
||||||
|
)
|
||||||
|
.join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTargetsDoc(targets: HealthCheckTarget[]): HealthProbeTargetsDoc {
|
||||||
|
const items = cloudflareMailboxTargets(targets);
|
||||||
|
return {
|
||||||
|
fingerprint: fingerprintTargets(items),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function indexResults(
|
||||||
|
doc: HealthProbeResultsDoc | null,
|
||||||
|
): Map<string, HealthProbeResultItem> {
|
||||||
|
const map = new Map<string, HealthProbeResultItem>();
|
||||||
|
if (!doc?.items) return map;
|
||||||
|
for (const item of doc.items) {
|
||||||
|
map.set(item.key, item);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isResultsStale(doc: HealthProbeResultsDoc | null, staleAfterMs: number): boolean {
|
||||||
|
if (!doc?.probedAt) return true;
|
||||||
|
const ts = Date.parse(doc.probedAt);
|
||||||
|
if (!Number.isFinite(ts)) return true;
|
||||||
|
return Date.now() - ts > staleAfterMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop seconds from toad 6-field cron for Cloudflare Workers (5-field). */
|
||||||
|
export function toCloudflareCron(expr: string): string {
|
||||||
|
const parts = expr.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length === 6) return parts.slice(1).join(" ");
|
||||||
|
if (parts.length === 5) return parts.join(" ");
|
||||||
|
throw new Error("некорректное cron-выражение");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cronStaleAfterMs(expr: string): number {
|
||||||
|
const cf = toCloudflareCron(expr);
|
||||||
|
const minute = cf.split(/\s+/)[0] ?? "*";
|
||||||
|
if (minute.startsWith("*/")) {
|
||||||
|
const n = Number(minute.slice(2));
|
||||||
|
if (Number.isFinite(n) && n > 0) return Math.max(n * 2, 5) * 60_000;
|
||||||
|
}
|
||||||
|
if (minute === "*") return 10 * 60_000;
|
||||||
|
return 10 * 60_000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function workerNotConfiguredResult(): {
|
||||||
|
ok: false;
|
||||||
|
latencyMs: number;
|
||||||
|
error: string;
|
||||||
|
colo: null;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: "Cloudflare Worker не настроен (нет KV mailbox)",
|
||||||
|
colo: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import type { Db } from "@cfdm/db";
|
|||||||
import { repos } from "@cfdm/db";
|
import { repos } from "@cfdm/db";
|
||||||
import type {
|
import type {
|
||||||
DnsRecord,
|
DnsRecord,
|
||||||
|
HealthCheckAggregate,
|
||||||
|
HealthCheckProvider,
|
||||||
HealthCheckScope,
|
HealthCheckScope,
|
||||||
HealthCheckType,
|
HealthCheckType,
|
||||||
IpHealthState,
|
IpHealthState,
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
SYNC_PENDING_PUSH,
|
SYNC_PENDING_PUSH,
|
||||||
SYNC_SYNCED,
|
SYNC_SYNCED,
|
||||||
dnsRecordNamesMatch,
|
dnsRecordNamesMatch,
|
||||||
|
isIpLiteral,
|
||||||
normalizeDnsRecordName,
|
normalizeDnsRecordName,
|
||||||
} from "@cfdm/shared";
|
} from "@cfdm/shared";
|
||||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||||
@@ -24,6 +27,7 @@ import { isValidIpv4 } from "../lib/validators.js";
|
|||||||
import * as dnsService from "./dns-service.js";
|
import * as dnsService from "./dns-service.js";
|
||||||
import * as domainService from "./domain-service.js";
|
import * as domainService from "./domain-service.js";
|
||||||
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
||||||
|
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
|
||||||
import {
|
import {
|
||||||
selectActiveIpsByMode,
|
selectActiveIpsByMode,
|
||||||
withBindingLock,
|
withBindingLock,
|
||||||
@@ -50,6 +54,9 @@ export interface ServiceDomainInput {
|
|||||||
health_check_interval_sec?: number;
|
health_check_interval_sec?: number;
|
||||||
health_check_timeout_ms?: number;
|
health_check_timeout_ms?: number;
|
||||||
health_check_verify_tls?: boolean;
|
health_check_verify_tls?: boolean;
|
||||||
|
health_check_provider?: HealthCheckProvider;
|
||||||
|
health_check_providers?: HealthCheckProvider[];
|
||||||
|
health_check_aggregate?: HealthCheckAggregate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToggleRequest {
|
export interface ToggleRequest {
|
||||||
@@ -70,6 +77,9 @@ export interface ServiceGroupBody {
|
|||||||
health_check_interval_sec?: number;
|
health_check_interval_sec?: number;
|
||||||
health_check_timeout_ms?: number;
|
health_check_timeout_ms?: number;
|
||||||
health_check_verify_tls?: boolean;
|
health_check_verify_tls?: boolean;
|
||||||
|
health_check_provider?: HealthCheckProvider;
|
||||||
|
health_check_providers?: HealthCheckProvider[];
|
||||||
|
health_check_aggregate?: HealthCheckAggregate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateServiceGroupBody {
|
export interface UpdateServiceGroupBody {
|
||||||
@@ -86,6 +96,9 @@ export interface UpdateServiceGroupBody {
|
|||||||
health_check_interval_sec?: number;
|
health_check_interval_sec?: number;
|
||||||
health_check_timeout_ms?: number;
|
health_check_timeout_ms?: number;
|
||||||
health_check_verify_tls?: boolean;
|
health_check_verify_tls?: boolean;
|
||||||
|
health_check_provider?: HealthCheckProvider;
|
||||||
|
health_check_providers?: HealthCheckProvider[];
|
||||||
|
health_check_aggregate?: HealthCheckAggregate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateServiceConfigRequest {
|
export interface UpdateServiceConfigRequest {
|
||||||
@@ -242,7 +255,11 @@ async function collectKnownZones(
|
|||||||
|
|
||||||
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||||
const service = repos.getService(db, serviceId);
|
const service = repos.getService(db, serviceId);
|
||||||
const ips = repos.listServiceIps(db, serviceId);
|
const ipRows = repos.listServiceIpRows(db, serviceId);
|
||||||
|
const ips = ipRows.map((row) => row.ip);
|
||||||
|
const ip_enabled = Object.fromEntries(
|
||||||
|
ipRows.map((row) => [row.ip, row.enabled]),
|
||||||
|
);
|
||||||
const bindings = repos.listBindingsByService(db, serviceId);
|
const bindings = repos.listBindingsByService(db, serviceId);
|
||||||
|
|
||||||
const domainViews = bindings.map((binding) => {
|
const domainViews = bindings.map((binding) => {
|
||||||
@@ -287,10 +304,24 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
|||||||
health_check_interval_sec: binding.health_check_interval_sec,
|
health_check_interval_sec: binding.health_check_interval_sec,
|
||||||
health_check_timeout_ms: binding.health_check_timeout_ms,
|
health_check_timeout_ms: binding.health_check_timeout_ms,
|
||||||
health_check_verify_tls: binding.health_check_verify_tls,
|
health_check_verify_tls: binding.health_check_verify_tls,
|
||||||
|
health_check_provider: binding.health_check_provider ?? "local",
|
||||||
|
health_check_providers: binding.health_check_providers ?? [
|
||||||
|
binding.health_check_provider ?? "local",
|
||||||
|
],
|
||||||
|
health_check_aggregate: binding.health_check_aggregate ?? "majority",
|
||||||
|
cert_monitoring: binding.cert_monitoring ?? "auto",
|
||||||
sync_status: aggregateSyncStatus(statuses),
|
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,
|
||||||
@@ -304,26 +335,108 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
|||||||
created_at: service.created_at,
|
created_at: service.created_at,
|
||||||
updated_at: service.updated_at,
|
updated_at: service.updated_at,
|
||||||
ips,
|
ips,
|
||||||
|
ip_enabled,
|
||||||
domains: domainViews,
|
domains: domainViews,
|
||||||
health_status: "unknown",
|
health_status: "unknown",
|
||||||
health_latency_ms: null,
|
health_latency_ms: null,
|
||||||
|
ip_health: [],
|
||||||
|
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
|
||||||
|
active_ips: [...activeIps],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HEALTH_RANK: Record<string, number> = {
|
||||||
|
down: 3,
|
||||||
|
degraded: 2,
|
||||||
|
unknown: 1,
|
||||||
|
up: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function cnameLookupKeys(value: string, zoneName?: string | null): string[] {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
const noDot = trimmed.replace(/\.+$/, "");
|
||||||
|
const lower = noDot.toLowerCase();
|
||||||
|
const keys = new Set([trimmed, noDot, lower]);
|
||||||
|
if (zoneName && !lower.includes(".")) {
|
||||||
|
keys.add(`${lower}.${zoneName.trim().toLowerCase().replace(/\.+$/, "")}`);
|
||||||
|
}
|
||||||
|
return [...keys];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceHealthRow = {
|
||||||
|
ip: string;
|
||||||
|
status: IpHealthState;
|
||||||
|
latency_ms: number | null;
|
||||||
|
last_checked_at: string | null;
|
||||||
|
last_error: string | null;
|
||||||
|
provider: ServiceView["ip_health"][number]["provider"];
|
||||||
|
colo: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Health rows keyed by CNAME hostname (legacy probes) applied to service IPs. */
|
||||||
|
function fallbackCnameHealth(
|
||||||
|
rows: ServiceHealthRow[],
|
||||||
|
view: ServiceView,
|
||||||
|
): ServiceHealthRow | undefined {
|
||||||
|
const cnameKeys = new Set<string>();
|
||||||
|
for (const domain of view.domains ?? []) {
|
||||||
|
const cname = domain.target_cname?.trim();
|
||||||
|
if (!cname) continue;
|
||||||
|
for (const key of cnameLookupKeys(cname, domain.zone_name)) {
|
||||||
|
cnameKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hostnameRows = rows.filter((row) => !isIpLiteral(row.ip));
|
||||||
|
if (hostnameRows.length === 0) return undefined;
|
||||||
|
const matched =
|
||||||
|
cnameKeys.size === 0
|
||||||
|
? hostnameRows
|
||||||
|
: hostnameRows.filter((row) =>
|
||||||
|
cnameLookupKeys(row.ip).some((key) => cnameKeys.has(key)),
|
||||||
|
);
|
||||||
|
const candidates = matched.length > 0 ? matched : hostnameRows;
|
||||||
|
return candidates.reduce((worst, row) =>
|
||||||
|
(HEALTH_RANK[row.status] ?? 0) > (HEALTH_RANK[worst.status] ?? 0)
|
||||||
|
? row
|
||||||
|
: worst,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function attachServiceHealth(
|
function attachServiceHealth(
|
||||||
db: Db,
|
db: Db,
|
||||||
views: ServiceView[],
|
views: ServiceView[],
|
||||||
): ServiceView[] {
|
): ServiceView[] {
|
||||||
const healthByService = repos.aggregateIpHealthByServiceIds(
|
const ids = views.map((v) => v.id);
|
||||||
db,
|
const healthByService = repos.aggregateIpHealthByServiceIds(db, ids);
|
||||||
views.map((v) => v.id),
|
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
|
||||||
);
|
|
||||||
return views.map((view) => {
|
return views.map((view) => {
|
||||||
const health = healthByService.get(view.id);
|
const health = healthByService.get(view.id);
|
||||||
|
const rows = ipHealthByService.get(view.id) ?? [];
|
||||||
|
const byIp = new Map(rows.map((row) => [row.ip, row]));
|
||||||
|
const cnameFallback = fallbackCnameHealth(rows, view);
|
||||||
|
const aRecordIps = new Set(
|
||||||
|
(view.domains ?? []).flatMap((domain) =>
|
||||||
|
domain.target_cname?.trim() ? [] : (domain.target_ips ?? []),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const ip_health = (view.ips ?? []).map((ip) => {
|
||||||
|
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
|
||||||
|
return {
|
||||||
|
ip,
|
||||||
|
status: row?.status ?? ("unknown" as const),
|
||||||
|
latency_ms: row?.latency_ms ?? null,
|
||||||
|
last_checked_at: row?.last_checked_at ?? null,
|
||||||
|
last_error: row?.last_error ?? null,
|
||||||
|
provider: row?.provider ?? "local",
|
||||||
|
colo: row?.colo ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...view,
|
...view,
|
||||||
health_status: health?.health_status ?? "unknown",
|
health_status: health?.health_status ?? "unknown",
|
||||||
health_latency_ms: health?.health_latency_ms ?? null,
|
health_latency_ms: health?.health_latency_ms ?? null,
|
||||||
|
ip_health,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -372,7 +485,7 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
|
|||||||
|
|
||||||
const groupViews = groupViewsRaw.map((group) => {
|
const groupViews = groupViewsRaw.map((group) => {
|
||||||
const services = group.services.map(
|
const services = group.services.map(
|
||||||
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null },
|
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null, ip_health: [], ip_enabled: {} },
|
||||||
);
|
);
|
||||||
const groupScopeHealth = groupHealthById.get(group.id);
|
const groupScopeHealth = groupHealthById.get(group.id);
|
||||||
// Only enabled services feed the group badge — a disabled service with a
|
// Only enabled services feed the group badge — a disabled service with a
|
||||||
@@ -401,6 +514,8 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
|
|||||||
...s,
|
...s,
|
||||||
health_status: "unknown" as const,
|
health_status: "unknown" as const,
|
||||||
health_latency_ms: null,
|
health_latency_ms: null,
|
||||||
|
ip_health: [],
|
||||||
|
ip_enabled: {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1135,7 +1250,10 @@ export async function updateConfig(
|
|||||||
input.health_check_expected_status !== undefined ||
|
input.health_check_expected_status !== undefined ||
|
||||||
input.health_check_interval_sec !== undefined ||
|
input.health_check_interval_sec !== undefined ||
|
||||||
input.health_check_timeout_ms !== undefined ||
|
input.health_check_timeout_ms !== undefined ||
|
||||||
input.health_check_verify_tls !== undefined
|
input.health_check_verify_tls !== undefined ||
|
||||||
|
input.health_check_provider !== undefined ||
|
||||||
|
input.health_check_providers !== undefined ||
|
||||||
|
input.health_check_aggregate !== undefined
|
||||||
) {
|
) {
|
||||||
repos.updateBindingLbConfig(db, binding.id, {
|
repos.updateBindingLbConfig(db, binding.id, {
|
||||||
lb_mode: input.lb_mode,
|
lb_mode: input.lb_mode,
|
||||||
@@ -1147,6 +1265,9 @@ export async function updateConfig(
|
|||||||
health_check_interval_sec: input.health_check_interval_sec,
|
health_check_interval_sec: input.health_check_interval_sec,
|
||||||
health_check_timeout_ms: input.health_check_timeout_ms,
|
health_check_timeout_ms: input.health_check_timeout_ms,
|
||||||
health_check_verify_tls: input.health_check_verify_tls,
|
health_check_verify_tls: input.health_check_verify_tls,
|
||||||
|
health_check_provider: input.health_check_provider,
|
||||||
|
health_check_providers: input.health_check_providers,
|
||||||
|
health_check_aggregate: input.health_check_aggregate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1221,6 +1342,8 @@ export async function updateConfig(
|
|||||||
|
|
||||||
void syncServiceToVpsTracker(db, id, removedBindingIds);
|
void syncServiceToVpsTracker(db, id, removedBindingIds);
|
||||||
|
|
||||||
|
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||||
|
|
||||||
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
|
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
|
||||||
return view!;
|
return view!;
|
||||||
}
|
}
|
||||||
@@ -1232,7 +1355,7 @@ export async function createGroup(
|
|||||||
): Promise<ServiceGroup> {
|
): Promise<ServiceGroup> {
|
||||||
const groupType = body.type?.trim() || "custom";
|
const groupType = body.type?.trim() || "custom";
|
||||||
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
||||||
return repos.createServiceGroup(
|
const group = repos.createServiceGroup(
|
||||||
db,
|
db,
|
||||||
body.name,
|
body.name,
|
||||||
groupType,
|
groupType,
|
||||||
@@ -1248,8 +1371,13 @@ export async function createGroup(
|
|||||||
health_check_interval_sec: body.health_check_interval_sec,
|
health_check_interval_sec: body.health_check_interval_sec,
|
||||||
health_check_timeout_ms: body.health_check_timeout_ms,
|
health_check_timeout_ms: body.health_check_timeout_ms,
|
||||||
health_check_verify_tls: body.health_check_verify_tls,
|
health_check_verify_tls: body.health_check_verify_tls,
|
||||||
|
health_check_provider: body.health_check_provider,
|
||||||
|
health_check_providers: body.health_check_providers,
|
||||||
|
health_check_aggregate: body.health_check_aggregate,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||||
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateGroup(
|
export async function updateGroup(
|
||||||
@@ -1284,6 +1412,9 @@ export async function updateGroup(
|
|||||||
health_check_interval_sec: body.health_check_interval_sec,
|
health_check_interval_sec: body.health_check_interval_sec,
|
||||||
health_check_timeout_ms: body.health_check_timeout_ms,
|
health_check_timeout_ms: body.health_check_timeout_ms,
|
||||||
health_check_verify_tls: body.health_check_verify_tls,
|
health_check_verify_tls: body.health_check_verify_tls,
|
||||||
|
health_check_provider: body.health_check_provider,
|
||||||
|
health_check_providers: body.health_check_providers,
|
||||||
|
health_check_aggregate: body.health_check_aggregate,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!domain && group.enabled) {
|
if (!domain && group.enabled) {
|
||||||
@@ -1291,6 +1422,7 @@ export async function updateGroup(
|
|||||||
group = repos.getServiceGroup(db, id);
|
group = repos.getServiceGroup(db, id);
|
||||||
}
|
}
|
||||||
await syncEnabledServicesInGroup(db, cf, id);
|
await syncEnabledServicesInGroup(db, cf, id);
|
||||||
|
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1332,6 +1464,59 @@ export async function toggleService(
|
|||||||
return enabledView!;
|
return enabledView!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleServiceIp(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
serviceId: number,
|
||||||
|
ip: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<ServiceView> {
|
||||||
|
repos.getService(db, serviceId);
|
||||||
|
const pool = repos.listServiceIps(db, serviceId);
|
||||||
|
if (!pool.includes(ip)) {
|
||||||
|
throw AppError.validation(`IP ${ip} не входит в пул адресов сервиса`);
|
||||||
|
}
|
||||||
|
|
||||||
|
repos.setServiceIpEnabled(db, serviceId, ip, enabled);
|
||||||
|
const node = repos
|
||||||
|
.listNodes(db, serviceId)
|
||||||
|
.find((entry) => entry.address === ip);
|
||||||
|
if (node) {
|
||||||
|
repos.updateNode(db, node.id, { enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindings = repos.listBindingsByService(db, serviceId);
|
||||||
|
for (const binding of bindings) {
|
||||||
|
if (binding.cname_target?.trim()) continue;
|
||||||
|
const current = repos.listBindingIpsWithMeta(db, binding.id);
|
||||||
|
const hasIp = current.some((entry) => entry.ip === ip);
|
||||||
|
if (enabled && !hasIp) {
|
||||||
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||||
|
...current,
|
||||||
|
{ ip, weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!enabled && hasIp) {
|
||||||
|
repos.replaceBindingIpsWithMeta(
|
||||||
|
db,
|
||||||
|
binding.id,
|
||||||
|
current.filter((entry) => entry.ip !== ip),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = repos.getService(db, serviceId);
|
||||||
|
if (shouldPushDns(db, service)) {
|
||||||
|
await syncServiceBindingsToDns(db, cf, serviceId);
|
||||||
|
await syncGroupDomainForService(db, cf, serviceId);
|
||||||
|
}
|
||||||
|
void syncServiceToVpsTracker(db, serviceId);
|
||||||
|
|
||||||
|
const [view] = attachServiceHealth(db, [await buildView(db, serviceId)]);
|
||||||
|
return view!;
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleGroup(
|
export async function toggleGroup(
|
||||||
db: Db,
|
db: Db,
|
||||||
cf: CloudflareClient,
|
cf: CloudflareClient,
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ function resolveIpsLocally(
|
|||||||
const binding = index.byFqdn.get(key);
|
const binding = index.byFqdn.get(key);
|
||||||
if (!binding) return [];
|
if (!binding) return [];
|
||||||
|
|
||||||
if (binding.target_ips.some(isIpLiteral)) {
|
const ips = (binding.target_ips ?? []).filter(isIpLiteral);
|
||||||
return binding.target_ips.filter(isIpLiteral);
|
if (ips.length > 0) {
|
||||||
|
return ips;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cname = binding.cname_target?.trim();
|
const cname = binding.cname_target?.trim();
|
||||||
@@ -79,7 +80,7 @@ export async function resolveBindingIpsForSync(
|
|||||||
index: BindingIpIndex,
|
index: BindingIpIndex,
|
||||||
db?: Db,
|
db?: Db,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const directIps = binding.target_ips.filter(isIpLiteral);
|
const directIps = (binding.target_ips ?? []).filter(isIpLiteral);
|
||||||
if (directIps.length > 0) {
|
if (directIps.length > 0) {
|
||||||
return [...directIps];
|
return [...directIps];
|
||||||
}
|
}
|
||||||
@@ -124,7 +125,7 @@ export async function buildServiceSyncBindingsAsync(
|
|||||||
const serviceIps = repos.listServiceIps(db, serviceId);
|
const serviceIps = repos.listServiceIps(db, serviceId);
|
||||||
const allBindings = repos.listAllBindings(db);
|
const allBindings = repos.listAllBindings(db);
|
||||||
const index = buildBindingIndex(allBindings);
|
const index = buildBindingIndex(allBindings);
|
||||||
const bindings = repos.listBindingsByService(db, serviceId);
|
const bindings = allBindings.filter((row) => row.service_id === serviceId);
|
||||||
|
|
||||||
const items: CfdmBindingSyncItem[] = [];
|
const items: CfdmBindingSyncItem[] = [];
|
||||||
for (const binding of bindings) {
|
for (const binding of bindings) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ describe("health-check probeTarget", () => {
|
|||||||
expected_status: null,
|
expected_status: null,
|
||||||
timeout_ms: 1000,
|
timeout_ms: 1000,
|
||||||
verify_tls: false,
|
verify_tls: false,
|
||||||
|
provider: "local",
|
||||||
};
|
};
|
||||||
const result = await healthCheckService.probeTarget(target);
|
const result = await healthCheckService.probeTarget(target);
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
@@ -75,6 +76,7 @@ describe("health-check probeTarget", () => {
|
|||||||
expected_status: null,
|
expected_status: null,
|
||||||
timeout_ms: 500,
|
timeout_ms: 500,
|
||||||
verify_tls: false,
|
verify_tls: false,
|
||||||
|
provider: "local",
|
||||||
};
|
};
|
||||||
const result = await healthCheckService.probeTarget(target);
|
const result = await healthCheckService.probeTarget(target);
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
@@ -107,6 +109,7 @@ describe("health-check probeTarget", () => {
|
|||||||
expected_status: 200,
|
expected_status: 200,
|
||||||
timeout_ms: 1000,
|
timeout_ms: 1000,
|
||||||
verify_tls: false,
|
verify_tls: false,
|
||||||
|
provider: "local",
|
||||||
};
|
};
|
||||||
const bindingTarget: HealthCheckTarget = {
|
const bindingTarget: HealthCheckTarget = {
|
||||||
...groupTarget,
|
...groupTarget,
|
||||||
@@ -142,6 +145,7 @@ describe("health-check probeTarget", () => {
|
|||||||
expected_status: null,
|
expected_status: null,
|
||||||
timeout_ms: 3000,
|
timeout_ms: 3000,
|
||||||
verify_tls: false,
|
verify_tls: false,
|
||||||
|
provider: "local",
|
||||||
};
|
};
|
||||||
const binding: HealthCheckTarget = {
|
const binding: HealthCheckTarget = {
|
||||||
...group,
|
...group,
|
||||||
@@ -231,4 +235,95 @@ describe("health-check state derivation via runAllChecks", () => {
|
|||||||
expect(targets).toHaveLength(1);
|
expect(targets).toHaveLength(1);
|
||||||
expect(targets[0]?.verify_tls).toBe(true);
|
expect(targets[0]?.verify_tls).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("unwraps CNAME target to origin A record IPs", async () => {
|
||||||
|
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||||
|
const { db, sqlite } = createMemoryDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||||
|
repos.insertDnsRecord(
|
||||||
|
db,
|
||||||
|
domain.id,
|
||||||
|
"A",
|
||||||
|
"ihome",
|
||||||
|
"2.59.161.102",
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
"synced",
|
||||||
|
"cf",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const service = repos.createService(db, "RW Sub", "rw-sub");
|
||||||
|
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
|
||||||
|
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
|
||||||
|
repos.updateBindingLbConfig(db, binding.id, {
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: 443,
|
||||||
|
});
|
||||||
|
|
||||||
|
const targets = repos.listHealthCheckTargets(db);
|
||||||
|
expect(targets).toHaveLength(1);
|
||||||
|
expect(targets[0]?.ip).toBe("2.59.161.102");
|
||||||
|
expect(targets[0]?.hostname).toBe("s.rkns.top");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unwraps CNAME target to service IP pool when origin DNS is empty", async () => {
|
||||||
|
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||||
|
const { db, sqlite } = createMemoryDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||||
|
const service = repos.createService(db, "RW Sub", "rw-sub");
|
||||||
|
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
|
||||||
|
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
|
||||||
|
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
|
||||||
|
repos.updateBindingLbConfig(db, binding.id, {
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: 443,
|
||||||
|
});
|
||||||
|
|
||||||
|
const targets = repos.listHealthCheckTargets(db);
|
||||||
|
expect(targets).toHaveLength(1);
|
||||||
|
expect(targets[0]?.ip).toBe("2.59.161.102");
|
||||||
|
expect(targets[0]?.hostname).toBe("s.rkns.top");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CNAME health mapped onto service IPs", () => {
|
||||||
|
it("getView copies CNAME-keyed health onto the service IP row", async () => {
|
||||||
|
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||||
|
const { getView } = await import("../src/services/service-config-service.js");
|
||||||
|
const { db, sqlite } = createMemoryDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(db, null, "rkns.top", "zone-id");
|
||||||
|
const service = repos.createService(db, "RW Sub", "rw-sub");
|
||||||
|
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
|
||||||
|
const binding = repos.insertBinding(db, domain.id, service.id, "s", null);
|
||||||
|
repos.setBindingCnameTarget(db, binding.id, "ihome.rkns.top");
|
||||||
|
repos.upsertIpHealthStatus(
|
||||||
|
db,
|
||||||
|
"binding",
|
||||||
|
binding.id,
|
||||||
|
"ihome.rkns.top",
|
||||||
|
"up",
|
||||||
|
12,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const view = await getView(db, service.id);
|
||||||
|
expect(view.health_status).toBe("up");
|
||||||
|
expect(view.ip_health).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
ip: "2.59.161.102",
|
||||||
|
status: "up",
|
||||||
|
latency_ms: 12,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { repos, type Db } from "@cfdm/db";
|
||||||
|
import * as healthCheckService from "../src/services/health-check-service.js";
|
||||||
|
import {
|
||||||
|
buildMeasurementBody,
|
||||||
|
summarizeMeasurement,
|
||||||
|
} from "../src/lib/globalping-client.js";
|
||||||
|
import { aggregateHealthOk } from "@cfdm/shared";
|
||||||
|
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||||
|
|
||||||
|
const thresholds = {
|
||||||
|
degradedFailures: 1,
|
||||||
|
downFailures: 2,
|
||||||
|
latencyWarnMs: 1000,
|
||||||
|
successRecoveries: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
function tcpTarget(overrides?: Partial<HealthCheckTarget>): HealthCheckTarget {
|
||||||
|
return {
|
||||||
|
scope: "binding",
|
||||||
|
ref_id: 1,
|
||||||
|
ip: "203.0.113.10",
|
||||||
|
hostname: "panel.example.com",
|
||||||
|
type: "tcp",
|
||||||
|
port: 443,
|
||||||
|
path: null,
|
||||||
|
expected_status: null,
|
||||||
|
timeout_ms: 400,
|
||||||
|
verify_tls: false,
|
||||||
|
provider: "globalping",
|
||||||
|
providers: ["globalping"],
|
||||||
|
aggregate: "majority",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedBinding(
|
||||||
|
db: Db,
|
||||||
|
opts: {
|
||||||
|
ip: string;
|
||||||
|
providers: Array<"local" | "cloudflare" | "globalping">;
|
||||||
|
aggregate?: "any" | "all" | "majority";
|
||||||
|
port?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||||
|
const service = repos.createService(db, "Panel", "panel");
|
||||||
|
repos.setServiceEnabled(db, service.id, true);
|
||||||
|
repos.replaceServiceIps(db, service.id, [opts.ip]);
|
||||||
|
const binding = repos.insertBinding(db, domain.id, service.id, "panel", null);
|
||||||
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||||
|
{ ip: opts.ip, weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
repos.updateBindingLbConfig(db, binding.id, {
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: opts.port ?? 1,
|
||||||
|
health_check_timeout_ms: 400,
|
||||||
|
health_check_providers: opts.providers,
|
||||||
|
health_check_aggregate: opts.aggregate ?? "majority",
|
||||||
|
});
|
||||||
|
return { service, binding, domain };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetch(handler: (url: string, init?: RequestInit) => Response): typeof fetch {
|
||||||
|
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
return handler(url, init);
|
||||||
|
}) as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("aggregateHealthOk", () => {
|
||||||
|
it("any / all / majority", () => {
|
||||||
|
expect(aggregateHealthOk([true, false], "any")).toBe(false);
|
||||||
|
expect(aggregateHealthOk([true, false], "all")).toBe(true);
|
||||||
|
expect(aggregateHealthOk([true, false], "majority")).toBe(true);
|
||||||
|
expect(aggregateHealthOk([false, false], "majority")).toBe(false);
|
||||||
|
expect(aggregateHealthOk([true, false, false], "majority")).toBe(false);
|
||||||
|
expect(aggregateHealthOk([true, true, false], "majority")).toBe(true);
|
||||||
|
expect(aggregateHealthOk([true], "majority")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("globalping mapping", () => {
|
||||||
|
it("maps CFDM TCP to ping+TCP and HTTP to http+host", () => {
|
||||||
|
const tcp = buildMeasurementBody(tcpTarget(), { limit: 3, locations: "World" });
|
||||||
|
expect(tcp.type).toBe("ping");
|
||||||
|
expect(tcp.measurementOptions.protocol).toBe("TCP");
|
||||||
|
expect(tcp.measurementOptions.port).toBe(443);
|
||||||
|
expect(tcp.inProgressUpdates).toBe(false);
|
||||||
|
|
||||||
|
const http = buildMeasurementBody(
|
||||||
|
tcpTarget({ type: "http", port: 443, path: "/health", expected_status: 200 }),
|
||||||
|
{ limit: 2, locations: "EU,US" },
|
||||||
|
);
|
||||||
|
expect(http.type).toBe("http");
|
||||||
|
expect(http.locations).toEqual([{ magic: "EU" }, { magic: "US" }]);
|
||||||
|
expect(http.measurementOptions.request).toMatchObject({
|
||||||
|
host: "panel.example.com",
|
||||||
|
path: "/health",
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes HTTP majority and TCP packet loss", () => {
|
||||||
|
const httpOk = summarizeMeasurement(
|
||||||
|
tcpTarget({ type: "http", expected_status: 200 }),
|
||||||
|
{
|
||||||
|
status: "finished",
|
||||||
|
results: [
|
||||||
|
{ probe: { city: "Frankfurt", country: "DE" }, result: { status: "finished", statusCode: 200, timings: { total: 40 } } },
|
||||||
|
{ probe: { city: "London", country: "GB" }, result: { status: "finished", statusCode: 200, timings: { total: 50 } } },
|
||||||
|
{ probe: { city: "Paris", country: "FR" }, result: { status: "finished", statusCode: 500, timings: { total: 20 } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(httpOk.ok).toBe(true);
|
||||||
|
expect(httpOk.colo).toBe("Frankfurt, DE");
|
||||||
|
|
||||||
|
const tcpFail = summarizeMeasurement(tcpTarget(), {
|
||||||
|
status: "finished",
|
||||||
|
results: [
|
||||||
|
{ result: { status: "finished", stats: { avg: 12, loss: 100 } } },
|
||||||
|
{ result: { status: "finished", stats: { avg: 11, loss: 100 } } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(tcpFail.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("globalping engine", () => {
|
||||||
|
it("POST 202 + GET finished writes colo from probe city", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const { binding } = await seedBinding(app.db, {
|
||||||
|
ip: "203.0.113.40",
|
||||||
|
providers: ["globalping"],
|
||||||
|
port: 443,
|
||||||
|
});
|
||||||
|
const fetchImpl = mockFetch((url) => {
|
||||||
|
if (url.endsWith("/v1/measurements")) {
|
||||||
|
return new Response(JSON.stringify({ id: "meas-1" }), { status: 202 });
|
||||||
|
}
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
id: "meas-1",
|
||||||
|
status: "finished",
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
probe: { city: "Amsterdam", country: "NL" },
|
||||||
|
result: { status: "finished", stats: { avg: 18, loss: 0 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
probe: { city: "Frankfurt", country: "DE" },
|
||||||
|
result: { status: "finished", stats: { avg: 22, loss: 0 } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await healthCheckService.runAllChecks(app.db, {
|
||||||
|
thresholds,
|
||||||
|
probeGapMs: 0,
|
||||||
|
globalping: {
|
||||||
|
token: "gp_test",
|
||||||
|
locations: "World",
|
||||||
|
limit: 2,
|
||||||
|
pollIntervalMs: 0,
|
||||||
|
fetchImpl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const row = repos.getIpHealthStatusRow(
|
||||||
|
app.db,
|
||||||
|
"binding",
|
||||||
|
binding.id,
|
||||||
|
"203.0.113.40",
|
||||||
|
);
|
||||||
|
expect(row?.status).toBe("up");
|
||||||
|
expect(row?.provider).toBe("globalping");
|
||||||
|
expect(row?.colo).toMatch(/Amsterdam/);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("429 fails the source and does not fall back to local", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const { binding } = await seedBinding(app.db, {
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
providers: ["globalping"],
|
||||||
|
port: 1,
|
||||||
|
});
|
||||||
|
const fetchImpl = mockFetch(() => new Response("rate limited", { status: 429 }));
|
||||||
|
await healthCheckService.runAllChecks(app.db, {
|
||||||
|
thresholds,
|
||||||
|
probeGapMs: 0,
|
||||||
|
globalping: {
|
||||||
|
token: "gp_test",
|
||||||
|
locations: "World",
|
||||||
|
limit: 1,
|
||||||
|
pollIntervalMs: 0,
|
||||||
|
fetchImpl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const row = repos.getIpHealthStatusRow(
|
||||||
|
app.db,
|
||||||
|
"binding",
|
||||||
|
binding.id,
|
||||||
|
"127.0.0.1",
|
||||||
|
);
|
||||||
|
expect(row?.last_error).toMatch(/429/i);
|
||||||
|
expect(row?.provider).toBe("globalping");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("local+globalping all keeps IP up if Globalping is ok", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const { binding, service } = await seedBinding(app.db, {
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
providers: ["local", "globalping"],
|
||||||
|
aggregate: "all",
|
||||||
|
port: 1,
|
||||||
|
});
|
||||||
|
const fetchImpl = mockFetch((url) => {
|
||||||
|
if (url.endsWith("/v1/measurements")) {
|
||||||
|
return new Response(JSON.stringify({ id: "meas-2" }), { status: 202 });
|
||||||
|
}
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
status: "finished",
|
||||||
|
results: [
|
||||||
|
{ probe: { city: "Vienna", country: "AT" }, result: { status: "finished", stats: { avg: 9, loss: 0 } } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await healthCheckService.runAllChecks(app.db, {
|
||||||
|
thresholds,
|
||||||
|
probeGapMs: 0,
|
||||||
|
globalping: {
|
||||||
|
token: "gp_test",
|
||||||
|
locations: "World",
|
||||||
|
limit: 1,
|
||||||
|
pollIntervalMs: 0,
|
||||||
|
fetchImpl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const row = repos.getIpHealthStatusRow(
|
||||||
|
app.db,
|
||||||
|
"binding",
|
||||||
|
binding.id,
|
||||||
|
"127.0.0.1",
|
||||||
|
);
|
||||||
|
expect(row?.status).toBe("up");
|
||||||
|
expect(row?.provider).toBe("aggregate");
|
||||||
|
const logs = repos.listHealthProbeLogForService(app.db, service.id);
|
||||||
|
expect(logs.map((row) => row.provider).sort()).toEqual([
|
||||||
|
"globalping",
|
||||||
|
"local",
|
||||||
|
]);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { toCloudflareCron } from "../src/services/health/mailbox.js";
|
||||||
|
import { HEALTH_PROBE_SCRIPT_NAME } from "@cfdm/shared";
|
||||||
|
|
||||||
|
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
payload: { username: "admin", password: "admin" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const { token } = res.json() as { token: string };
|
||||||
|
return { authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonOk(result: unknown, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify({ success: true, result }), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("health worker deploy", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 6-field toad cron to 5-field Cloudflare cron", () => {
|
||||||
|
expect(toCloudflareCron("0 */2 * * * *")).toBe("*/2 * * * *");
|
||||||
|
expect(toCloudflareCron("*/5 * * * *")).toBe("*/5 * * * *");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST ensure creates KV+script; 403 is not local fallback", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url =
|
||||||
|
typeof input === "string" || input instanceof URL
|
||||||
|
? String(input)
|
||||||
|
: input.url;
|
||||||
|
const method = (
|
||||||
|
init?.method ??
|
||||||
|
(typeof Request !== "undefined" && input instanceof Request
|
||||||
|
? input.method
|
||||||
|
: "GET")
|
||||||
|
).toUpperCase();
|
||||||
|
calls.push(`${method} ${url}`);
|
||||||
|
if (
|
||||||
|
(url.includes("/accounts?") || /\/accounts$/.test(url.split("?")[0] ?? "")) &&
|
||||||
|
!url.includes("/storage/") &&
|
||||||
|
!url.includes("/workers/")
|
||||||
|
) {
|
||||||
|
return jsonOk([{ id: "acc-1", name: "Test" }]);
|
||||||
|
}
|
||||||
|
if (url.includes("/storage/kv/namespaces") && method === "GET" && !url.includes("/values/")) {
|
||||||
|
return jsonOk([]);
|
||||||
|
}
|
||||||
|
if (url.includes("/storage/kv/namespaces") && method === "POST") {
|
||||||
|
return jsonOk({ id: "kv-1", title: "cfdm-health-probe" });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
url.includes(`/workers/scripts/${HEALTH_PROBE_SCRIPT_NAME}`) &&
|
||||||
|
method === "PUT" &&
|
||||||
|
!url.includes("/schedules")
|
||||||
|
) {
|
||||||
|
return jsonOk({ id: "script-1" });
|
||||||
|
}
|
||||||
|
if (url.includes("/schedules") && method === "PUT") {
|
||||||
|
return jsonOk([{ cron: "*/2 * * * *" }]);
|
||||||
|
}
|
||||||
|
if (url.includes("/subdomain") && method === "POST") {
|
||||||
|
return jsonOk({ enabled: true });
|
||||||
|
}
|
||||||
|
if (url.includes("/workers/subdomain") && method === "GET") {
|
||||||
|
return jsonOk({ subdomain: "example" });
|
||||||
|
}
|
||||||
|
if (url.includes("/values/") && method === "GET") {
|
||||||
|
return new Response("null", { status: 404 });
|
||||||
|
}
|
||||||
|
if (url.includes("/values/") && method === "PUT") {
|
||||||
|
return jsonOk(null);
|
||||||
|
}
|
||||||
|
return jsonOk({});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null, cloudflareApiToken: "cf-token" },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/settings/health/worker/ensure",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as {
|
||||||
|
healthWorkerStatus: string;
|
||||||
|
healthWorkerUrl: string;
|
||||||
|
healthWorkerKvNamespaceId: string;
|
||||||
|
healthWorkerError: string | null;
|
||||||
|
};
|
||||||
|
expect(body.healthWorkerStatus).toBe("ready");
|
||||||
|
expect(body.healthWorkerKvNamespaceId).toBe("kv-1");
|
||||||
|
expect(body.healthWorkerUrl).toContain("cfdm-health-probe.example.workers.dev");
|
||||||
|
expect(body.healthWorkerError).toBeNull();
|
||||||
|
expect(calls.some((c) => c.includes("/workers/scripts/"))).toBe(true);
|
||||||
|
await app.close();
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
it("POST ensure 403 stores error, does not probe as local", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("/storage/kv/namespaces")) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: false,
|
||||||
|
errors: [{ code: 10000, message: "Authentication error" }],
|
||||||
|
}),
|
||||||
|
{ status: 403, headers: { "content-type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.includes("/accounts")) {
|
||||||
|
return jsonOk([{ id: "acc-1" }]);
|
||||||
|
}
|
||||||
|
return jsonOk({});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null, cloudflareApiToken: "zone-only" },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/settings/health/worker/ensure",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
const again = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
const body = again.json() as {
|
||||||
|
healthWorkerStatus: string;
|
||||||
|
healthWorkerError: string | null;
|
||||||
|
};
|
||||||
|
expect(body.healthWorkerStatus).toBe("error");
|
||||||
|
expect(body.healthWorkerError).toMatch(/Workers Scripts Write|токен/i);
|
||||||
|
await app.close();
|
||||||
|
}, 20_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { repos, type Db } from "@cfdm/db";
|
||||||
|
import * as healthCheckService from "../src/services/health-check-service.js";
|
||||||
|
import type { HealthMailbox } from "../src/services/health/mailbox.js";
|
||||||
|
import { originProbeKey } from "../src/services/health/mailbox.js";
|
||||||
|
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||||
|
|
||||||
|
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
payload: { username: "admin", password: "admin" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const { token } = res.json() as { token: string };
|
||||||
|
return { authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedBinding(
|
||||||
|
db: Db,
|
||||||
|
opts: { provider: "local" | "cloudflare"; ip: string },
|
||||||
|
) {
|
||||||
|
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||||
|
const service = repos.createService(db, "Panel", "panel");
|
||||||
|
repos.setServiceEnabled(db, service.id, true);
|
||||||
|
repos.replaceServiceIps(db, service.id, [opts.ip]);
|
||||||
|
const binding = repos.insertBinding(db, domain.id, service.id, "panel", null);
|
||||||
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||||
|
{ ip: opts.ip, weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
repos.updateBindingLbConfig(db, binding.id, {
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: 1,
|
||||||
|
health_check_timeout_ms: 400,
|
||||||
|
health_check_provider: opts.provider,
|
||||||
|
});
|
||||||
|
return { service, binding, domain };
|
||||||
|
}
|
||||||
|
|
||||||
|
const thresholds = {
|
||||||
|
degradedFailures: 1,
|
||||||
|
downFailures: 2,
|
||||||
|
latencyWarnMs: 1000,
|
||||||
|
successRecoveries: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
function memoryMailbox(opts?: {
|
||||||
|
resultsOk?: boolean;
|
||||||
|
colo?: string;
|
||||||
|
probedAt?: string;
|
||||||
|
}): HealthMailbox {
|
||||||
|
let targets: unknown = null;
|
||||||
|
return {
|
||||||
|
async getTargets() {
|
||||||
|
return targets as never;
|
||||||
|
},
|
||||||
|
async putTargets(doc) {
|
||||||
|
targets = doc;
|
||||||
|
},
|
||||||
|
async getResults() {
|
||||||
|
if (!opts) return null;
|
||||||
|
const dummy: HealthCheckTarget = {
|
||||||
|
scope: "binding",
|
||||||
|
ref_id: 1,
|
||||||
|
ip: "203.0.113.10",
|
||||||
|
hostname: "panel.example.com",
|
||||||
|
type: "tcp",
|
||||||
|
port: 1,
|
||||||
|
path: null,
|
||||||
|
expected_status: null,
|
||||||
|
timeout_ms: 400,
|
||||||
|
verify_tls: false,
|
||||||
|
provider: "cloudflare",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
probedAt: opts.probedAt ?? new Date().toISOString(),
|
||||||
|
colo: opts.colo ?? "AMS",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: originProbeKey(dummy),
|
||||||
|
ok: opts.resultsOk !== false,
|
||||||
|
latencyMs: 42,
|
||||||
|
error: opts.resultsOk === false ? "down" : null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("health-check XOR worker mailbox", () => {
|
||||||
|
it("lists only local providers when no cloudflare bindings", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
await seedBinding(app.db, {
|
||||||
|
provider: "local",
|
||||||
|
ip: "10.0.0.1",
|
||||||
|
});
|
||||||
|
const targets = repos.listHealthCheckTargets(app.db);
|
||||||
|
expect(targets.length).toBeGreaterThan(0);
|
||||||
|
expect(targets.every((t) => t.provider === "local")).toBe(true);
|
||||||
|
expect(targets.some((t) => t.provider === "cloudflare")).toBe(false);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cloudflare without mailbox does not fall back to local", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const { binding } = await seedBinding(app.db, {
|
||||||
|
provider: "cloudflare",
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
});
|
||||||
|
await healthCheckService.runAllChecks(app.db, {
|
||||||
|
thresholds,
|
||||||
|
probeGapMs: 0,
|
||||||
|
mailbox: null,
|
||||||
|
});
|
||||||
|
const row = repos.getIpHealthStatusRow(
|
||||||
|
app.db,
|
||||||
|
"binding",
|
||||||
|
binding.id,
|
||||||
|
"127.0.0.1",
|
||||||
|
);
|
||||||
|
expect(row?.last_error).toMatch(/Worker не настроен/i);
|
||||||
|
expect(row?.provider).toBe("cloudflare");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("KV results write colo and last_checked_at without HTTP /probe", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const { service, binding } = await seedBinding(app.db, {
|
||||||
|
provider: "cloudflare",
|
||||||
|
ip: "203.0.113.10",
|
||||||
|
});
|
||||||
|
const targets = repos.listHealthCheckTargets(app.db);
|
||||||
|
const cfTarget = targets.find((t) => t.ip === "203.0.113.10")!;
|
||||||
|
const mailbox: HealthMailbox = {
|
||||||
|
async getTargets() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
async putTargets() {
|
||||||
|
/* fingerprint sync */
|
||||||
|
},
|
||||||
|
async getResults() {
|
||||||
|
return {
|
||||||
|
probedAt: new Date().toISOString(),
|
||||||
|
colo: "AMS",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: originProbeKey(cfTarget),
|
||||||
|
ok: true,
|
||||||
|
latencyMs: 42,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await healthCheckService.runAllChecks(app.db, {
|
||||||
|
thresholds,
|
||||||
|
probeGapMs: 0,
|
||||||
|
mailbox,
|
||||||
|
});
|
||||||
|
const row = repos.getIpHealthStatusRow(
|
||||||
|
app.db,
|
||||||
|
"binding",
|
||||||
|
binding.id,
|
||||||
|
"203.0.113.10",
|
||||||
|
);
|
||||||
|
expect(row?.status).toBe("up");
|
||||||
|
expect(row?.colo).toBe("AMS");
|
||||||
|
expect(row?.last_checked_at).toBeTruthy();
|
||||||
|
expect(row?.provider).toBe("cloudflare");
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v1/services/${service.id}`,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as {
|
||||||
|
ip_health: Array<{
|
||||||
|
ip: string;
|
||||||
|
colo: string | null;
|
||||||
|
last_checked_at: string | null;
|
||||||
|
provider: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
const ipRow = body.ip_health.find((item) => item.ip === "203.0.113.10");
|
||||||
|
expect(ipRow?.colo).toBe("AMS");
|
||||||
|
expect(ipRow?.provider).toBe("cloudflare");
|
||||||
|
|
||||||
|
const logRes = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v1/services/${service.id}/health-log`,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(logRes.statusCode).toBe(200);
|
||||||
|
const logBody = logRes.json() as { items: Array<{ colo: string | null }> };
|
||||||
|
expect(logBody.items[0]?.colo).toBe("AMS");
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stale KV results are recorded, not local probe", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const { binding } = await seedBinding(app.db, {
|
||||||
|
provider: "cloudflare",
|
||||||
|
ip: "203.0.113.20",
|
||||||
|
});
|
||||||
|
await healthCheckService.runAllChecks(app.db, {
|
||||||
|
thresholds,
|
||||||
|
probeGapMs: 0,
|
||||||
|
mailbox: memoryMailbox({
|
||||||
|
resultsOk: true,
|
||||||
|
colo: "SIN",
|
||||||
|
probedAt: new Date(Date.now() - 60 * 60_000).toISOString(),
|
||||||
|
}),
|
||||||
|
staleAfterMs: 60_000,
|
||||||
|
});
|
||||||
|
const row = repos.getIpHealthStatusRow(
|
||||||
|
app.db,
|
||||||
|
"binding",
|
||||||
|
binding.id,
|
||||||
|
"203.0.113.20",
|
||||||
|
);
|
||||||
|
expect(row?.last_error).toMatch(/устарели|KV/i);
|
||||||
|
expect(row?.provider).toBe("cloudflare");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,7 @@ describe("service groups health enrichment", () => {
|
|||||||
const service = repos.createService(app.db, "Panel", "panel");
|
const service = repos.createService(app.db, "Panel", "panel");
|
||||||
repos.setServiceGroup(app.db, service.id, group.id);
|
repos.setServiceGroup(app.db, service.id, group.id);
|
||||||
repos.setServiceEnabled(app.db, service.id, true);
|
repos.setServiceEnabled(app.db, service.id, true);
|
||||||
|
repos.replaceServiceIps(app.db, service.id, ["1.2.3.4"]);
|
||||||
const binding = repos.insertBinding(
|
const binding = repos.insertBinding(
|
||||||
app.db,
|
app.db,
|
||||||
domain.id,
|
domain.id,
|
||||||
@@ -85,6 +86,11 @@ describe("service groups health enrichment", () => {
|
|||||||
id: number;
|
id: number;
|
||||||
health_status: string;
|
health_status: string;
|
||||||
health_latency_ms: number | null;
|
health_latency_ms: number | null;
|
||||||
|
ip_health: Array<{
|
||||||
|
ip: string;
|
||||||
|
status: string;
|
||||||
|
latency_ms: number | null;
|
||||||
|
}>;
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -92,6 +98,17 @@ describe("service groups health enrichment", () => {
|
|||||||
expect(groupView).toBeDefined();
|
expect(groupView).toBeDefined();
|
||||||
expect(groupView!.services[0]?.health_status).toBe("degraded");
|
expect(groupView!.services[0]?.health_status).toBe("degraded");
|
||||||
expect(groupView!.services[0]?.health_latency_ms).toBe(120);
|
expect(groupView!.services[0]?.health_latency_ms).toBe(120);
|
||||||
|
expect(groupView!.services[0]?.ip_health).toEqual([
|
||||||
|
{
|
||||||
|
ip: "1.2.3.4",
|
||||||
|
status: "degraded",
|
||||||
|
latency_ms: 120,
|
||||||
|
last_checked_at: expect.any(String),
|
||||||
|
last_error: null,
|
||||||
|
provider: "local",
|
||||||
|
colo: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
// group worst = degraded (from service) over up (group scope)
|
// group worst = degraded (from service) over up (group scope)
|
||||||
expect(groupView!.health_status).toBe("degraded");
|
expect(groupView!.health_status).toBe("degraded");
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { serviceGroupsResponseSchema } from "@cfdm/shared";
|
import { serviceGroupsResponseSchema, updateServiceConfigSchema } from "@cfdm/shared";
|
||||||
import { repos } from "@cfdm/db";
|
import { repos } from "@cfdm/db";
|
||||||
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||||
import { buildApp } from "../src/app.js";
|
import { buildApp } from "../src/app.js";
|
||||||
@@ -51,6 +51,26 @@ async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("create service then list groups", () => {
|
describe("create service then list groups", () => {
|
||||||
|
it("accepts sqlite-shaped health fields on service config PATCH", () => {
|
||||||
|
const parsed = updateServiceConfigSchema.parse({
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
fqdn: "gw.example.com",
|
||||||
|
target_ips: ["1.2.3.4"],
|
||||||
|
health_check_enabled: 1,
|
||||||
|
health_check_verify_tls: 0,
|
||||||
|
health_check_providers: '["local","cloudflare"]',
|
||||||
|
health_check_aggregate: "majority",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(parsed.domains?.[0]?.health_check_enabled).toBe(true);
|
||||||
|
expect(parsed.domains?.[0]?.health_check_verify_tls).toBe(false);
|
||||||
|
expect(parsed.domains?.[0]?.health_check_providers).toEqual([
|
||||||
|
"local",
|
||||||
|
"cloudflare",
|
||||||
|
]);
|
||||||
|
});
|
||||||
it("create + updateConfig then listGroupViews parses with shared Zod schema", async () => {
|
it("create + updateConfig then listGroupViews parses with shared Zod schema", async () => {
|
||||||
const app = await buildApp({
|
const app = await buildApp({
|
||||||
config: { ...loadConfig(), staticDir: null },
|
config: { ...loadConfig(), staticDir: null },
|
||||||
@@ -123,11 +143,75 @@ 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.some((s) => s.id === created.id),
|
?.services.find((s) => s.id === created.id);
|
||||||
).toBe(true);
|
expect(listed).toBeDefined();
|
||||||
|
expect(listed?.lb_mode).toBe("round_robin");
|
||||||
|
expect(listed?.active_ips).toEqual(["1.2.3.4"]);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH /services/:id persists health providers and aggregate", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const cf = mockCf();
|
||||||
|
|
||||||
|
repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/services",
|
||||||
|
headers,
|
||||||
|
payload: { name: "GW", slug: "gw" },
|
||||||
|
});
|
||||||
|
expect(createRes.statusCode).toBe(200);
|
||||||
|
const created = createRes.json() as { id: number };
|
||||||
|
|
||||||
|
await updateConfig(app.db, cf, created.id, {
|
||||||
|
ips: ["1.2.3.4"],
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
fqdn: "gw.example.com",
|
||||||
|
target_ips: ["1.2.3.4"],
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_interval_sec: 30,
|
||||||
|
health_check_timeout_ms: 3000,
|
||||||
|
health_check_providers: ["local", "cloudflare"],
|
||||||
|
health_check_aggregate: "majority",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stored = repos.listBindingsByService(app.db, created.id)[0]!;
|
||||||
|
expect(stored.health_check_enabled).toBe(true);
|
||||||
|
expect(Array.isArray(stored.health_check_providers)).toBe(true);
|
||||||
|
expect(stored.health_check_providers).toEqual(["local", "cloudflare"]);
|
||||||
|
expect(stored.health_check_aggregate).toBe("majority");
|
||||||
|
|
||||||
|
const getRes = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v1/services/${created.id}`,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(getRes.statusCode).toBe(200);
|
||||||
|
const view = getRes.json() as {
|
||||||
|
domains: Array<{
|
||||||
|
health_check_enabled: boolean;
|
||||||
|
health_check_providers: string[];
|
||||||
|
health_check_aggregate: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
expect(view.domains[0]?.health_check_enabled).toBe(true);
|
||||||
|
expect(view.domains[0]?.health_check_providers).toEqual([
|
||||||
|
"local",
|
||||||
|
"cloudflare",
|
||||||
|
]);
|
||||||
|
expect(view.domains[0]?.health_check_aggregate).toBe("majority");
|
||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
@@ -165,4 +249,94 @@ describe("create service then list groups", () => {
|
|||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("PATCH /services/:id/ips/toggle keeps IP in pool and removes it from A-binding", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const cf = mockCf();
|
||||||
|
|
||||||
|
repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||||
|
const group = repos.createServiceGroup(
|
||||||
|
app.db,
|
||||||
|
"VPN",
|
||||||
|
"vpn",
|
||||||
|
null,
|
||||||
|
"vpn.example.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/services",
|
||||||
|
headers,
|
||||||
|
payload: {
|
||||||
|
name: "Panel",
|
||||||
|
slug: "panel-ip-toggle",
|
||||||
|
service_group_id: group.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createRes.statusCode).toBe(200);
|
||||||
|
const created = createRes.json() as { id: number };
|
||||||
|
|
||||||
|
await updateConfig(app.db, cf, created.id, {
|
||||||
|
ips: ["1.2.3.4", "5.6.7.8"],
|
||||||
|
service_group_id: group.id,
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
fqdn: "panel.example.com",
|
||||||
|
target_ips: ["1.2.3.4", "5.6.7.8"],
|
||||||
|
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
||||||
|
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
||||||
|
lb_mode: "round_robin",
|
||||||
|
health_check_enabled: false,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: 443,
|
||||||
|
health_check_path: null,
|
||||||
|
health_check_expected_status: null,
|
||||||
|
health_check_interval_sec: 30,
|
||||||
|
health_check_timeout_ms: 3000,
|
||||||
|
health_check_verify_tls: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// HTTP toggle uses request.server.cf; disable DNS push so the test
|
||||||
|
// does not call the real Cloudflare client.
|
||||||
|
repos.setServiceEnabled(app.db, created.id, false);
|
||||||
|
|
||||||
|
const offRes = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/v1/services/${created.id}/ips/toggle`,
|
||||||
|
headers,
|
||||||
|
payload: { ip: "1.2.3.4", enabled: false },
|
||||||
|
});
|
||||||
|
expect(offRes.statusCode, JSON.stringify(offRes.json())).toBe(200);
|
||||||
|
const offView = offRes.json() as {
|
||||||
|
ips: string[];
|
||||||
|
ip_enabled: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
expect(offView.ips).toEqual(expect.arrayContaining(["1.2.3.4", "5.6.7.8"]));
|
||||||
|
expect(offView.ip_enabled["1.2.3.4"]).toBe(false);
|
||||||
|
expect(offView.ip_enabled["5.6.7.8"]).toBe(true);
|
||||||
|
|
||||||
|
const binding = repos.listBindingsByService(app.db, created.id)[0]!;
|
||||||
|
expect(repos.listBindingIps(app.db, binding.id)).toEqual(["5.6.7.8"]);
|
||||||
|
|
||||||
|
const onRes = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/v1/services/${created.id}/ips/toggle`,
|
||||||
|
headers,
|
||||||
|
payload: { ip: "1.2.3.4", enabled: true },
|
||||||
|
});
|
||||||
|
expect(onRes.statusCode).toBe(200);
|
||||||
|
const onView = onRes.json() as { ip_enabled: Record<string, boolean> };
|
||||||
|
expect(onView.ip_enabled["1.2.3.4"]).toBe(true);
|
||||||
|
expect(repos.listBindingIps(app.db, binding.id)).toEqual(
|
||||||
|
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
|
||||||
|
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
payload: { username: "admin", password: "admin" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const { token } = res.json() as { token: string };
|
||||||
|
return { authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("settings health engine", () => {
|
||||||
|
it("GET /api/v1/settings returns env fallbacks for health fields", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: {
|
||||||
|
...loadConfig(),
|
||||||
|
staticDir: null,
|
||||||
|
healthCheckCron: "*/30 * * * * *",
|
||||||
|
healthDegradedFailures: 3,
|
||||||
|
healthDownFailures: 4,
|
||||||
|
healthLatencyWarnMs: 1500,
|
||||||
|
healthSuccessRecoveries: 5,
|
||||||
|
},
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as {
|
||||||
|
healthCheckCron: string;
|
||||||
|
healthDegradedFailures: number;
|
||||||
|
healthDownFailures: number;
|
||||||
|
healthLatencyWarnMs: number;
|
||||||
|
healthSuccessRecoveries: number;
|
||||||
|
healthWorkerStatus?: string;
|
||||||
|
};
|
||||||
|
expect(body.healthCheckCron).toBe("*/30 * * * * *");
|
||||||
|
expect(body.healthDegradedFailures).toBe(3);
|
||||||
|
expect(body.healthDownFailures).toBe(4);
|
||||||
|
expect(body.healthLatencyWarnMs).toBe(1500);
|
||||||
|
expect(body.healthSuccessRecoveries).toBe(5);
|
||||||
|
expect(body.healthWorkerStatus).toBe("missing");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH persists health engine settings", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
payload: {
|
||||||
|
healthCheckCron: "0 */5 * * * *",
|
||||||
|
healthDegradedFailures: 2,
|
||||||
|
healthDownFailures: 4,
|
||||||
|
healthLatencyWarnMs: 800,
|
||||||
|
healthSuccessRecoveries: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as {
|
||||||
|
healthCheckCron: string;
|
||||||
|
healthDegradedFailures: number;
|
||||||
|
healthDownFailures: number;
|
||||||
|
healthLatencyWarnMs: number;
|
||||||
|
healthSuccessRecoveries: number;
|
||||||
|
};
|
||||||
|
expect(body.healthCheckCron).toBe("0 */5 * * * *");
|
||||||
|
expect(body.healthDegradedFailures).toBe(2);
|
||||||
|
expect(body.healthDownFailures).toBe(4);
|
||||||
|
expect(body.healthLatencyWarnMs).toBe(800);
|
||||||
|
expect(body.healthSuccessRecoveries).toBe(3);
|
||||||
|
|
||||||
|
const again = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(again.json()).toMatchObject({
|
||||||
|
healthCheckCron: "0 */5 * * * *",
|
||||||
|
healthDegradedFailures: 2,
|
||||||
|
healthDownFailures: 4,
|
||||||
|
healthLatencyWarnMs: 800,
|
||||||
|
healthSuccessRecoveries: 3,
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH rejects invalid cron", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
payload: { healthCheckCron: "not-a-cron" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json()).toMatchObject({
|
||||||
|
error: { code: "VALIDATION_ERROR" },
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH rejects down < degraded", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
payload: {
|
||||||
|
healthDegradedFailures: 5,
|
||||||
|
healthDownFailures: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH worker URL; token is not returned in GET", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
payload: {
|
||||||
|
healthWorkerUrl: "https://cfdm-health-probe.example.workers.dev",
|
||||||
|
healthWorkerToken: "super-secret",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as {
|
||||||
|
healthWorkerUrl: string;
|
||||||
|
healthWorkerTokenSet: boolean;
|
||||||
|
healthWorkerToken?: string;
|
||||||
|
};
|
||||||
|
expect(body.healthWorkerUrl).toBe(
|
||||||
|
"https://cfdm-health-probe.example.workers.dev",
|
||||||
|
);
|
||||||
|
expect(body.healthWorkerTokenSet).toBe(true);
|
||||||
|
expect(body.healthWorkerToken).toBeUndefined();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET exposes Globalping flags without the token; PATCH persists locations/limit", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const initial = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(initial.statusCode).toBe(200);
|
||||||
|
const before = initial.json() as {
|
||||||
|
globalpingTokenSet?: boolean;
|
||||||
|
globalpingLocations?: string;
|
||||||
|
globalpingLimit?: number;
|
||||||
|
globalpingToken?: string;
|
||||||
|
};
|
||||||
|
expect(before.globalpingTokenSet).toBe(false);
|
||||||
|
expect(before.globalpingLocations).toBe("World");
|
||||||
|
expect(before.globalpingLimit).toBe(3);
|
||||||
|
expect(before.globalpingToken).toBeUndefined();
|
||||||
|
|
||||||
|
const patched = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
payload: {
|
||||||
|
globalpingToken: "gp_secret",
|
||||||
|
globalpingLocations: "EU,US",
|
||||||
|
globalpingLimit: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(patched.statusCode).toBe(200);
|
||||||
|
const body = patched.json() as {
|
||||||
|
globalpingTokenSet: boolean;
|
||||||
|
globalpingLocations: string;
|
||||||
|
globalpingLimit: number;
|
||||||
|
globalpingToken?: string;
|
||||||
|
};
|
||||||
|
expect(body.globalpingTokenSet).toBe(true);
|
||||||
|
expect(body.globalpingLocations).toBe("EU,US");
|
||||||
|
expect(body.globalpingLimit).toBe(5);
|
||||||
|
expect(body.globalpingToken).toBeUndefined();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -134,6 +134,19 @@ describe("resolveBindingIpsForSync", () => {
|
|||||||
expect(ips).toEqual(["203.0.113.10"]);
|
expect(ips).toEqual(["203.0.113.10"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats missing target_ips as empty instead of throwing", async () => {
|
||||||
|
const cname = binding({
|
||||||
|
id: 2,
|
||||||
|
hostname: "imsk",
|
||||||
|
zone_name: "rkns.top",
|
||||||
|
cname_target: "ihome.rkns.top",
|
||||||
|
});
|
||||||
|
delete (cname as { target_ips?: string[] }).target_ips;
|
||||||
|
const index = { byFqdn: new Map([["imsk.rkns.top", cname]]) };
|
||||||
|
const ips = await resolveBindingIpsForSync(cname, ["198.51.100.9"], index);
|
||||||
|
expect(ips).toEqual(["198.51.100.9"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("prefers service IPs over empty CNAME resolution chain", async () => {
|
it("prefers service IPs over empty CNAME resolution chain", async () => {
|
||||||
const cname = binding({
|
const cname = binding({
|
||||||
id: 2,
|
id: 2,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { copyFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/server.ts"],
|
||||||
|
format: ["esm"],
|
||||||
|
dts: true,
|
||||||
|
async onSuccess() {
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
copyFileSync(
|
||||||
|
join(root, "workers/health-probe/src/index.mjs"),
|
||||||
|
join(dirname(fileURLToPath(import.meta.url)), "dist/health-probe-worker.mjs"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
environment: "node",
|
environment: "node",
|
||||||
include: ["test/**/*.test.ts"],
|
include: ["test/**/*.test.ts"],
|
||||||
|
testTimeout: 20_000,
|
||||||
typecheck: {
|
typecheck: {
|
||||||
tsconfig: "./tsconfig.test.json",
|
tsconfig: "./tsconfig.test.json",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: 'Статус',
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ interface HealthCheckBadgeProps {
|
|||||||
latencyMs?: number | null
|
latencyMs?: number | null
|
||||||
lastCheckedAt?: string | null
|
lastCheckedAt?: string | null
|
||||||
lastError?: string | null
|
lastError?: string | null
|
||||||
|
colo?: string | null
|
||||||
|
provider?: 'local' | 'cloudflare' | string | null
|
||||||
title?: string
|
title?: string
|
||||||
showLatency?: boolean
|
showLatency?: boolean
|
||||||
size?: 'xs' | 'sm'
|
size?: 'xs' | 'sm'
|
||||||
@@ -65,6 +67,8 @@ export function HealthCheckBadge({
|
|||||||
latencyMs,
|
latencyMs,
|
||||||
lastCheckedAt,
|
lastCheckedAt,
|
||||||
lastError,
|
lastError,
|
||||||
|
colo,
|
||||||
|
provider,
|
||||||
title,
|
title,
|
||||||
showLatency = false,
|
showLatency = false,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
@@ -79,6 +83,9 @@ export function HealthCheckBadge({
|
|||||||
tooltipParts.push(`Статус: ${label}`)
|
tooltipParts.push(`Статус: ${label}`)
|
||||||
if (latencyMs != null) tooltipParts.push(`Задержка: ${latencyMs} мс`)
|
if (latencyMs != null) tooltipParts.push(`Задержка: ${latencyMs} мс`)
|
||||||
if (lastCheckedAt) tooltipParts.push(`Проверка: ${formatDate(lastCheckedAt)}`)
|
if (lastCheckedAt) tooltipParts.push(`Проверка: ${formatDate(lastCheckedAt)}`)
|
||||||
|
if (colo) tooltipParts.push(`Colo: ${colo}`)
|
||||||
|
if (provider === 'cloudflare') tooltipParts.push('Провайдер: Cloudflare Worker')
|
||||||
|
if (provider === 'local') tooltipParts.push('Провайдер: Local')
|
||||||
if (lastError) tooltipParts.push(`Ошибка: ${lastError}`)
|
if (lastError) tooltipParts.push(`Ошибка: ${lastError}`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -17,15 +17,22 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} 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 { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { ButtonGroup } from '@cfdm/ui/components/button-group'
|
import { ButtonGroup } from '@cfdm/ui/components/button-group'
|
||||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
import { CableIcon, GlobeIcon } from 'lucide-react'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
HealthAggregateTiles,
|
||||||
|
HealthSourceTiles,
|
||||||
|
type HealthAggregate,
|
||||||
|
type HealthProvider,
|
||||||
|
} from '@/components/reui-kit/health-source-tiles'
|
||||||
|
import { parseHealthProviders } from '@cfdm/shared'
|
||||||
|
|
||||||
export type LbMode = 'round_robin' | 'failover' | 'weighted'
|
export type LbMode = 'round_robin' | 'failover' | 'weighted'
|
||||||
export type HealthCheckType = 'tcp' | 'http'
|
export type HealthCheckType = 'tcp' | 'http'
|
||||||
export type HealthProvider = 'local' | 'cloudflare'
|
export type { HealthProvider, HealthAggregate }
|
||||||
|
|
||||||
export interface HealthCheckConfig {
|
export interface HealthCheckConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -37,6 +44,8 @@ export interface HealthCheckConfig {
|
|||||||
timeout_ms: number
|
timeout_ms: number
|
||||||
verify_tls: boolean
|
verify_tls: boolean
|
||||||
provider: HealthProvider
|
provider: HealthProvider
|
||||||
|
providers: HealthProvider[]
|
||||||
|
aggregate: HealthAggregate
|
||||||
method?: string | null
|
method?: string | null
|
||||||
retries?: number
|
retries?: number
|
||||||
consecutive_fails?: number
|
consecutive_fails?: number
|
||||||
@@ -53,62 +62,6 @@ const defaultLbModeOptions = [
|
|||||||
{ value: 'weighted', label: 'Weighted (веса)' },
|
{ value: 'weighted', label: 'Weighted (веса)' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const healthCheckTypes = [
|
|
||||||
{ value: 'tcp', label: 'TCP connect' },
|
|
||||||
{ value: 'http', label: 'HTTP' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const cloudflareTypes = [
|
|
||||||
{ value: 'tcp', label: 'TCP' },
|
|
||||||
{ value: 'http', label: 'HTTP' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export function HealthProviderToggle({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
id,
|
|
||||||
}: {
|
|
||||||
value: HealthProvider
|
|
||||||
onChange: (next: HealthProvider) => void
|
|
||||||
id?: string
|
|
||||||
}) {
|
|
||||||
const provider = value || 'local'
|
|
||||||
return (
|
|
||||||
<ButtonGroup className="w-full" id={id}>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
variant={provider === 'local' ? 'secondary' : 'outline'}
|
|
||||||
aria-pressed={provider === 'local'}
|
|
||||||
onClick={() => onChange('local')}
|
|
||||||
>
|
|
||||||
Local
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
className="flex-1"
|
|
||||||
variant={provider === 'cloudflare' ? 'secondary' : 'outline'}
|
|
||||||
aria-pressed={provider === 'cloudflare'}
|
|
||||||
onClick={() => onChange('cloudflare')}
|
|
||||||
>
|
|
||||||
Cloudflare
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HealthCheckConfigFieldsProps {
|
|
||||||
value: LbAndHealthConfig
|
|
||||||
onChange: (next: LbAndHealthConfig) => void
|
|
||||||
lbModeLabel?: string
|
|
||||||
lbModeOptions?: { value: string; label: string }[]
|
|
||||||
idPrefix?: string
|
|
||||||
showLbMode?: boolean
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function CompactNumberField({
|
function CompactNumberField({
|
||||||
id,
|
id,
|
||||||
value,
|
value,
|
||||||
@@ -150,11 +103,24 @@ export function HealthCheckConfigFields({
|
|||||||
idPrefix = 'health',
|
idPrefix = 'health',
|
||||||
showLbMode = true,
|
showLbMode = true,
|
||||||
className,
|
className,
|
||||||
}: HealthCheckConfigFieldsProps) {
|
}: {
|
||||||
|
value: LbAndHealthConfig
|
||||||
|
onChange: (next: LbAndHealthConfig) => void
|
||||||
|
lbModeLabel?: string
|
||||||
|
lbModeOptions?: { value: string; label: string }[]
|
||||||
|
idPrefix?: string
|
||||||
|
showLbMode?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
function patch(next: Partial<LbAndHealthConfig>) {
|
function patch(next: Partial<LbAndHealthConfig>) {
|
||||||
onChange({ ...value, ...next })
|
onChange({ ...value, ...next })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const providers = parseHealthProviders(
|
||||||
|
value.providers,
|
||||||
|
value.provider ?? 'local',
|
||||||
|
)
|
||||||
|
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'
|
||||||
|
|
||||||
@@ -189,30 +155,39 @@ export function HealthCheckConfigFields({
|
|||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Провайдер health-check"
|
title="Провайдер health-check"
|
||||||
description="Local TCP/HTTP или Cloudflare Health Checks API"
|
description="Кто пробирует цель. Можно выбрать несколько источников."
|
||||||
labelFor={`${idPrefix}-provider`}
|
labelFor={`${idPrefix}-provider`}
|
||||||
compact
|
compact
|
||||||
|
stacked
|
||||||
className={rowClass}
|
className={rowClass}
|
||||||
|
contentClassName="min-w-0"
|
||||||
>
|
>
|
||||||
<HealthProviderToggle
|
<HealthSourceTiles
|
||||||
id={`${idPrefix}-provider`}
|
value={providers}
|
||||||
value={value.provider ?? 'local'}
|
onChange={(next) =>
|
||||||
onChange={(provider) =>
|
|
||||||
patch({
|
patch({
|
||||||
provider,
|
providers: next,
|
||||||
enabled: provider === 'cloudflare' ? true : value.enabled,
|
provider: next[0] ?? 'local',
|
||||||
|
enabled: next.includes('cloudflare') ? true : value.enabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
{value.provider === 'cloudflare' ? (
|
|
||||||
<Alert>
|
{providers.length > 1 ? (
|
||||||
<AlertTitle>Cloudflare Health Checks</AlertTitle>
|
<SettingRow
|
||||||
<AlertDescription>
|
title="Агрегация"
|
||||||
Поля соответствуют официальному API зоны. Если план не позволяет Health
|
description="Как свести результаты источников в один статус IP для failover"
|
||||||
Checks, API вернёт ошибку — останется Local. Workers не используются.
|
compact
|
||||||
</AlertDescription>
|
stacked
|
||||||
</Alert>
|
className={rowClass}
|
||||||
|
contentClassName="min-w-0"
|
||||||
|
>
|
||||||
|
<HealthAggregateTiles
|
||||||
|
value={aggregate}
|
||||||
|
onChange={(next) => patch({ aggregate: next })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
@@ -242,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`}>
|
||||||
<Select
|
<ButtonGroup id={`${idPrefix}-type`} className="w-full min-w-0">
|
||||||
modal={false}
|
<Button
|
||||||
value={value.type}
|
type="button"
|
||||||
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })}
|
size="sm"
|
||||||
|
variant={value.type === 'tcp' ? 'secondary' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
aria-pressed={value.type === 'tcp'}
|
||||||
|
onClick={() => patch({ type: 'tcp' })}
|
||||||
>
|
>
|
||||||
<SelectTrigger id={`${idPrefix}-type`} className="w-full">
|
<CableIcon data-icon="inline-start" />
|
||||||
<SelectValue placeholder="Тип" />
|
TCP
|
||||||
</SelectTrigger>
|
</Button>
|
||||||
<SelectContent>
|
<Button
|
||||||
{(value.provider === 'cloudflare'
|
type="button"
|
||||||
? cloudflareTypes
|
size="sm"
|
||||||
: healthCheckTypes
|
variant={value.type === 'http' ? 'secondary' : 'outline'}
|
||||||
).map((item) => (
|
className="flex-1"
|
||||||
<SelectItem key={item.value} value={item.value}>
|
aria-pressed={value.type === 'http'}
|
||||||
{item.label}
|
onClick={() => patch({ type: 'http' })}
|
||||||
</SelectItem>
|
>
|
||||||
))}
|
<GlobeIcon data-icon="inline-start" />
|
||||||
</SelectContent>
|
HTTP
|
||||||
</Select>
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
</FormFieldSimple>
|
</FormFieldSimple>
|
||||||
|
|
||||||
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
|
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
|
||||||
@@ -322,19 +302,6 @@ export function HealthCheckConfigFields({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<FormFieldSimple label="Интервал, сек" htmlFor={`${idPrefix}-interval`}>
|
|
||||||
<CompactNumberField
|
|
||||||
id={`${idPrefix}-interval`}
|
|
||||||
value={value.interval_sec}
|
|
||||||
min={5}
|
|
||||||
max={3600}
|
|
||||||
placeholder="30"
|
|
||||||
onValueChange={(next) =>
|
|
||||||
patch({ interval_sec: next ?? 30 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormFieldSimple>
|
|
||||||
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
|
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
|
||||||
<CompactNumberField
|
<CompactNumberField
|
||||||
id={`${idPrefix}-timeout`}
|
id={`${idPrefix}-timeout`}
|
||||||
@@ -348,58 +315,6 @@ export function HealthCheckConfigFields({
|
|||||||
/>
|
/>
|
||||||
</FormFieldSimple>
|
</FormFieldSimple>
|
||||||
</div>
|
</div>
|
||||||
{value.provider === 'cloudflare' ? (
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<FormFieldSimple
|
|
||||||
label="Retries"
|
|
||||||
htmlFor={`${idPrefix}-retries`}
|
|
||||||
hint="Cloudflare retries"
|
|
||||||
>
|
|
||||||
<CompactNumberField
|
|
||||||
id={`${idPrefix}-retries`}
|
|
||||||
value={value.retries ?? 2}
|
|
||||||
min={0}
|
|
||||||
max={10}
|
|
||||||
placeholder="2"
|
|
||||||
onValueChange={(retries) => patch({ retries: retries ?? 2 })}
|
|
||||||
/>
|
|
||||||
</FormFieldSimple>
|
|
||||||
<FormFieldSimple
|
|
||||||
label="Successes"
|
|
||||||
htmlFor={`${idPrefix}-successes`}
|
|
||||||
hint="consecutive_successes"
|
|
||||||
>
|
|
||||||
<CompactNumberField
|
|
||||||
id={`${idPrefix}-successes`}
|
|
||||||
value={value.consecutive_successes ?? 2}
|
|
||||||
min={1}
|
|
||||||
max={20}
|
|
||||||
placeholder="2"
|
|
||||||
onValueChange={(consecutive_successes) =>
|
|
||||||
patch({ consecutive_successes: consecutive_successes ?? 2 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormFieldSimple>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{value.provider === 'cloudflare' && isHttp ? (
|
|
||||||
<FormFieldSimple label="HTTP method" htmlFor={`${idPrefix}-method`}>
|
|
||||||
<Select
|
|
||||||
modal={false}
|
|
||||||
value={value.method ?? 'GET'}
|
|
||||||
onValueChange={(v) => patch({ method: v ?? 'GET' })}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={`${idPrefix}-method`} className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="GET">GET</SelectItem>
|
|
||||||
<SelectItem value="HEAD">HEAD</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormFieldSimple>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,19 +23,27 @@ export interface HealthTimelineEvent {
|
|||||||
latency_ms?: number | null
|
latency_ms?: number | null
|
||||||
error?: string | null
|
error?: string | null
|
||||||
checked_at: string
|
checked_at: string
|
||||||
|
colo?: string | null
|
||||||
|
provider?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -63,6 +71,8 @@ export function HealthTimeline({ events }: HealthTimelineProps) {
|
|||||||
<HealthCheckBadge
|
<HealthCheckBadge
|
||||||
status={event.status}
|
status={event.status}
|
||||||
latencyMs={event.latency_ms}
|
latencyMs={event.latency_ms}
|
||||||
|
colo={event.colo}
|
||||||
|
provider={event.provider}
|
||||||
size="xs"
|
size="xs"
|
||||||
showLatency
|
showLatency
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -88,7 +88,11 @@ export function ServiceKanbanCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<span className="text-muted-foreground text-xs">IP</span>
|
<span className="text-muted-foreground text-xs">IP</span>
|
||||||
<ServiceIpList copyable ips={service.ips ?? []} />
|
<ServiceIpList
|
||||||
|
copyable
|
||||||
|
ips={service.ips ?? []}
|
||||||
|
ipHealth={service.ip_health ?? []}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link, useNavigate } from '@tanstack/react-router'
|
|||||||
import {
|
import {
|
||||||
FolderTreeIcon,
|
FolderTreeIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
|
HeartPulseIcon,
|
||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
@@ -57,6 +58,12 @@ const NAV_ITEMS = [
|
|||||||
keywords: ['certificates', 'ssl', 'tls'],
|
keywords: ['certificates', 'ssl', 'tls'],
|
||||||
icon: ShieldCheckIcon,
|
icon: ShieldCheckIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/settings/health',
|
||||||
|
label: 'Health-check',
|
||||||
|
keywords: ['health', 'health-check', 'cron', 'пороги', 'настройки'],
|
||||||
|
icon: HeartPulseIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/integrations',
|
to: '/settings/integrations',
|
||||||
label: 'Настройки',
|
label: 'Настройки',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { Fragment, useMemo } from 'react'
|
||||||
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
||||||
import { useMemo } from 'react'
|
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
@@ -12,72 +12,12 @@ import { Separator } from '@cfdm/ui/components/separator'
|
|||||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||||
|
import { getBreadcrumbs } from '@/lib/breadcrumbs'
|
||||||
|
|
||||||
export interface RouteBreadcrumbLoaderData {
|
export interface RouteBreadcrumbLoaderData {
|
||||||
breadcrumb?: string
|
breadcrumb?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeTitles: Record<string, string> = {
|
|
||||||
'/': 'Панель управления',
|
|
||||||
'/domains': 'Домены',
|
|
||||||
'/groups': 'Группы доменов',
|
|
||||||
'/services': 'Сервисы',
|
|
||||||
'/certificates': 'Сертификаты',
|
|
||||||
'/settings/appearance': 'Внешний вид',
|
|
||||||
'/settings/integrations': 'Интеграции',
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBreadcrumbs(
|
|
||||||
pathname: string,
|
|
||||||
dynamicLabels: Record<string, string>,
|
|
||||||
) {
|
|
||||||
if (pathname === '/') {
|
|
||||||
return [{ label: 'Панель управления', href: '/' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.match(/^\/groups\/\d+$/)) {
|
|
||||||
return [
|
|
||||||
{ label: 'Группы доменов', href: '/groups' },
|
|
||||||
{ label: dynamicLabels[pathname] ?? 'Группа', href: pathname },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.match(/^\/domains\/\d+\/dns$/)) {
|
|
||||||
const domainId = pathname.split('/')[2]
|
|
||||||
const domainPath = `/domains/${domainId}`
|
|
||||||
return [
|
|
||||||
{ label: 'Домены', href: '/domains' },
|
|
||||||
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
|
|
||||||
{ label: 'DNS', href: pathname },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.match(/^\/domains\/\d+$/)) {
|
|
||||||
return [
|
|
||||||
{ label: 'Домены', href: '/domains' },
|
|
||||||
{ label: dynamicLabels[pathname] ?? 'Обзор домена', href: pathname },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathname.startsWith('/settings')) {
|
|
||||||
return [
|
|
||||||
{ label: 'Настройки', href: '/settings/appearance' },
|
|
||||||
...(pathname === '/settings/integrations'
|
|
||||||
? [{ label: 'Интеграции', href: pathname }]
|
|
||||||
: pathname === '/settings/appearance'
|
|
||||||
? [{ label: 'Внешний вид', href: pathname }]
|
|
||||||
: []),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = routeTitles[pathname]
|
|
||||||
if (title) {
|
|
||||||
return [{ label: title, href: pathname }]
|
|
||||||
}
|
|
||||||
|
|
||||||
return [{ label: 'Панель управления', href: '/' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
function useDynamicBreadcrumbLabels() {
|
function useDynamicBreadcrumbLabels() {
|
||||||
const matches = useMatches()
|
const matches = useMatches()
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
@@ -96,7 +36,10 @@ function useDynamicBreadcrumbLabels() {
|
|||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
const dynamicLabels = useDynamicBreadcrumbLabels()
|
const dynamicLabels = useDynamicBreadcrumbLabels()
|
||||||
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
|
const crumbs = useMemo(
|
||||||
|
() => getBreadcrumbs(pathname, dynamicLabels),
|
||||||
|
[pathname, dynamicLabels],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||||
@@ -107,10 +50,10 @@ export function SiteHeader() {
|
|||||||
{crumbs.map((crumb, index) => {
|
{crumbs.map((crumb, index) => {
|
||||||
const isLast = index === crumbs.length - 1
|
const isLast = index === crumbs.length - 1
|
||||||
return (
|
return (
|
||||||
<span key={crumb.href} className="contents">
|
<Fragment key={`${index}-${crumb.href}`}>
|
||||||
{index > 0 && (
|
{index > 0 ? (
|
||||||
<BreadcrumbSeparator className="hidden md:block" />
|
<BreadcrumbSeparator className="hidden md:block" />
|
||||||
)}
|
) : null}
|
||||||
<BreadcrumbItem
|
<BreadcrumbItem
|
||||||
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
|
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
|
||||||
>
|
>
|
||||||
@@ -122,7 +65,7 @@ export function SiteHeader() {
|
|||||||
</BreadcrumbLink>
|
</BreadcrumbLink>
|
||||||
)}
|
)}
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</span>
|
</Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import type { KeyboardEvent, ReactNode } from 'react'
|
||||||
|
import { ServerIcon, LayersIcon, ShieldAlertIcon, ScaleIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@cfdm/ui/components/item'
|
||||||
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from '@cfdm/ui/components/toggle-group'
|
||||||
|
import { parseHealthProviders, type HealthCheckAggregate, type HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
import type { HealthLogStatus } from '@/lib/health-log'
|
||||||
|
|
||||||
|
export type HealthProvider = HealthCheckProvider
|
||||||
|
export type HealthAggregate = HealthCheckAggregate
|
||||||
|
|
||||||
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||||
|
|
||||||
|
/** Official Cloudflare mark (Simple Icons). */
|
||||||
|
function CloudflareMark() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Official Globalping mark (globalping.io favicon). */
|
||||||
|
function GlobalpingMark() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 26 26" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M10.354 6.081a2.636 2.636 0 0 1 4.32.257 31.5 31.5 0 0 1 8.55-1.349A12.97 12.97 0 0 0 3.247 4.42a33.6 33.6 0 0 1 7.107 1.661m13.647.003-.764.02a30.6 30.6 0 0 0-8.235 1.3q.005.08 0 .16c0 .6-.222 1.178-.624 1.624a42 42 0 0 1 2.382 3.695q.478.835.884 1.645h.134c.348-.003.689.102.975.302q3.286-2.798 5.83-7.517l.052-.094m-11.16 2.616a2.65 2.65 0 0 1-2.997-.644c-3.276 1.658-6.702 4.284-9.022 8.45 1.579.6 3.232.982 4.914 1.135a1.566 1.566 0 0 1 2.811.026h.14a16 16 0 0 0 6.935-2.188 1.5 1.5 0 0 1-.097-.52 1.53 1.53 0 0 1 .475-1.105 39 39 0 0 0-3.16-5.154Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M18.866 24.463c.28-2.216-.22-4.582-1.06-6.825h-.019a1.66 1.66 0 0 1-.812-.211 17 17 0 0 1-7.622 2.467h-.14a1.563 1.563 0 0 1-2.902-.026 19 19 0 0 1-4.94-1.086 13 13 0 0 0 17.479 5.801zm6.324-15.97a26.5 26.5 0 0 1-5.82 7.218 1.4 1.4 0 0 1 .049.374 1.55 1.55 0 0 1-.56 1.18c.907 2.417 1.3 4.644 1.174 6.659a12.98 12.98 0 0 0 5.158-15.431Zm-15.258-.28a2.35 2.35 0 0 1-.045-1.108 33 33 0 0 0-7.46-1.658 12.97 12.97 0 0 0-1.829 11.45c2.106-3.682 5.268-6.627 9.334-8.684"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HEALTH_PROVIDER_ITEMS: Array<{
|
||||||
|
id: HealthProvider
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
icon: ReactNode
|
||||||
|
iconClassName: string
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: 'local',
|
||||||
|
title: 'Local',
|
||||||
|
description: 'TCP/HTTP с сервера API',
|
||||||
|
icon: <ServerIcon />,
|
||||||
|
iconClassName: 'text-info [&_svg]:text-current',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cloudflare',
|
||||||
|
title: 'Cloudflare',
|
||||||
|
description: 'Worker на edge, KV mailbox',
|
||||||
|
icon: <CloudflareMark />,
|
||||||
|
iconClassName: 'text-warning [&_svg]:text-current',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'globalping',
|
||||||
|
title: 'Globalping',
|
||||||
|
description: 'Пробы из сети globalping.io',
|
||||||
|
icon: <GlobalpingMark />,
|
||||||
|
iconClassName: 'text-success [&_svg]:text-current',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const AGGREGATE_ITEMS: Array<{
|
||||||
|
id: HealthAggregate
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
icon: ReactNode
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: 'any',
|
||||||
|
title: 'Any',
|
||||||
|
description: 'Down, если хотя бы один источник Down',
|
||||||
|
icon: <ShieldAlertIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'all',
|
||||||
|
title: 'All',
|
||||||
|
description: 'Down, только если все выбранные Down',
|
||||||
|
icon: <LayersIcon />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'majority',
|
||||||
|
title: 'Majority',
|
||||||
|
description: 'Down по большинству (2 → оба, 3 → ≥2)',
|
||||||
|
icon: <ScaleIcon />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function handleTileKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
onActivate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChoicePanel({
|
||||||
|
selected,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
iconClassName,
|
||||||
|
role,
|
||||||
|
trailing,
|
||||||
|
onActivate,
|
||||||
|
}: {
|
||||||
|
selected: boolean
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
icon: ReactNode
|
||||||
|
iconClassName?: string
|
||||||
|
role: 'checkbox' | 'radio'
|
||||||
|
trailing?: ReactNode
|
||||||
|
onActivate: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<FramePanel
|
||||||
|
fit
|
||||||
|
role={role}
|
||||||
|
aria-checked={selected}
|
||||||
|
aria-pressed={selected}
|
||||||
|
tabIndex={0}
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 cursor-pointer transition-colors',
|
||||||
|
'hover:bg-muted/40 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
|
||||||
|
selected && 'bg-muted/40',
|
||||||
|
)}
|
||||||
|
onClick={onActivate}
|
||||||
|
onKeyDown={(event) => handleTileKeyDown(onActivate, event)}
|
||||||
|
>
|
||||||
|
<Item size="sm" className="w-full min-w-0 border-0 p-0">
|
||||||
|
<ItemMedia>
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn('size-10.5', iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</IconTile>
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent className="min-w-0 gap-0.5">
|
||||||
|
<ItemTitle className="w-full min-w-0">{title}</ItemTitle>
|
||||||
|
<ItemDescription>{description}</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
{trailing ? (
|
||||||
|
<ItemActions className="shrink-0">{trailing}</ItemActions>
|
||||||
|
) : null}
|
||||||
|
</Item>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChoiceFrame({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Frame stacked spacing="sm" className="w-full min-w-0">
|
||||||
|
{children}
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Мультивыбор источников проб (Local / Cloudflare / Globalping).
|
||||||
|
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12
|
||||||
|
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
||||||
|
*/
|
||||||
|
export function HealthSourceTiles({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: HealthProvider[]
|
||||||
|
onChange: (next: HealthProvider[]) => void
|
||||||
|
}) {
|
||||||
|
const selected = parseHealthProviders(value)
|
||||||
|
|
||||||
|
function toggle(id: HealthProvider) {
|
||||||
|
if (selected.includes(id)) {
|
||||||
|
if (selected.length === 1) return
|
||||||
|
onChange(selected.filter((item) => item !== id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange([...selected, id])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChoiceFrame>
|
||||||
|
{HEALTH_PROVIDER_ITEMS.map((item) => (
|
||||||
|
<ChoicePanel
|
||||||
|
key={item.id}
|
||||||
|
selected={selected.includes(item.id)}
|
||||||
|
title={item.title}
|
||||||
|
description={item.description}
|
||||||
|
icon={item.icon}
|
||||||
|
iconClassName={item.iconClassName}
|
||||||
|
role="checkbox"
|
||||||
|
trailing={
|
||||||
|
selected.includes(item.id) ? (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
Выбрано
|
||||||
|
</Badge>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
onActivate={() => toggle(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ChoiceFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Правило агрегации (ровно одно): any / all / majority.
|
||||||
|
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/settings-5
|
||||||
|
*/
|
||||||
|
export function HealthAggregateTiles({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: HealthAggregate
|
||||||
|
onChange: (next: HealthAggregate) => void
|
||||||
|
}) {
|
||||||
|
const selected = value || 'majority'
|
||||||
|
return (
|
||||||
|
<ChoiceFrame>
|
||||||
|
{AGGREGATE_ITEMS.map((item) => (
|
||||||
|
<ChoicePanel
|
||||||
|
key={item.id}
|
||||||
|
selected={selected === item.id}
|
||||||
|
title={item.title}
|
||||||
|
description={item.description}
|
||||||
|
icon={item.icon}
|
||||||
|
role="radio"
|
||||||
|
trailing={
|
||||||
|
selected === item.id ? (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
Выбрано
|
||||||
|
</Badge>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
onActivate={() => onChange(item.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ChoiceFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProviders(
|
||||||
|
active: HealthProvider[],
|
||||||
|
id: HealthProvider,
|
||||||
|
): HealthProvider[] {
|
||||||
|
if (active.includes(id)) {
|
||||||
|
if (active.length === 1) return active
|
||||||
|
return active.filter((item) => item !== id)
|
||||||
|
}
|
||||||
|
return [...active, id]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компактный мультивыбор типа пробы (toolbar в stacked Frame).
|
||||||
|
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/chart-17
|
||||||
|
* Docs: https://reui.io/docs/components/base/icon-tile · https://reui.io/docs/components/base/badge
|
||||||
|
*/
|
||||||
|
export function HealthSourceFilterBar({
|
||||||
|
enabled,
|
||||||
|
selected,
|
||||||
|
statuses,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
enabled: HealthProvider[]
|
||||||
|
selected: HealthProvider[]
|
||||||
|
statuses: Partial<Record<HealthProvider, HealthLogStatus>>
|
||||||
|
onChange: (next: HealthProvider[]) => void
|
||||||
|
}) {
|
||||||
|
const visible = HEALTH_PROVIDER_ITEMS.filter((item) => enabled.includes(item.id))
|
||||||
|
if (visible.length === 0) return null
|
||||||
|
|
||||||
|
const active = selected.length > 0 ? selected : enabled
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="@container min-w-0 w-full">
|
||||||
|
<ToggleGroup
|
||||||
|
multiple
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="flex w-full min-w-0 flex-wrap justify-start"
|
||||||
|
value={active}
|
||||||
|
aria-label="Тип пробы"
|
||||||
|
onValueChange={(next) => {
|
||||||
|
const values = next.filter((value): value is HealthProvider =>
|
||||||
|
visible.some((item) => item.id === value),
|
||||||
|
)
|
||||||
|
if (values.length === 0) return
|
||||||
|
onChange(values)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{visible.map((item) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={item.id}
|
||||||
|
value={item.id}
|
||||||
|
aria-label={item.title}
|
||||||
|
title={item.title}
|
||||||
|
className="max-w-full min-w-0 flex-none justify-start gap-1.5 @[16rem]:min-w-[8.5rem]"
|
||||||
|
>
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="xs"
|
||||||
|
className={item.iconClassName}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</IconTile>
|
||||||
|
<span className="hidden min-w-0 truncate @[16rem]:inline">
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
<HealthCheckBadge
|
||||||
|
status={statuses[item.id] ?? 'unknown'}
|
||||||
|
provider={item.id}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only status tiles for enabled probe sources; click filters the monitor.
|
||||||
|
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12
|
||||||
|
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
||||||
|
*/
|
||||||
|
export function HealthProviderStatusTiles({
|
||||||
|
enabled,
|
||||||
|
selected,
|
||||||
|
statuses,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
enabled: HealthProvider[]
|
||||||
|
selected: HealthProvider[]
|
||||||
|
statuses: Partial<Record<HealthProvider, HealthLogStatus>>
|
||||||
|
onChange: (next: HealthProvider[]) => void
|
||||||
|
}) {
|
||||||
|
const visible = HEALTH_PROVIDER_ITEMS.filter((item) => enabled.includes(item.id))
|
||||||
|
if (visible.length === 0) return null
|
||||||
|
|
||||||
|
const active = selected.length > 0 ? selected : enabled
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChoiceFrame>
|
||||||
|
{visible.map((item) => (
|
||||||
|
<ChoicePanel
|
||||||
|
key={item.id}
|
||||||
|
selected={active.includes(item.id)}
|
||||||
|
title={item.title}
|
||||||
|
description={item.description}
|
||||||
|
icon={item.icon}
|
||||||
|
iconClassName={item.iconClassName}
|
||||||
|
role="checkbox"
|
||||||
|
trailing={
|
||||||
|
<HealthCheckBadge
|
||||||
|
status={statuses[item.id] ?? 'unknown'}
|
||||||
|
provider={item.id}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onActivate={() => onChange(toggleProviders(active, item.id))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ChoiceFrame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
|
||||||
|
export { ServiceHealthMonitor } from './service-health-monitor'
|
||||||
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
export { 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'
|
||||||
@@ -18,3 +20,12 @@ export { OpsDashboard } from './ops-dashboard'
|
|||||||
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
||||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||||
|
export {
|
||||||
|
HealthSourceTiles,
|
||||||
|
HealthAggregateTiles,
|
||||||
|
HealthProviderStatusTiles,
|
||||||
|
HealthSourceFilterBar,
|
||||||
|
type HealthProvider,
|
||||||
|
type HealthAggregate,
|
||||||
|
} from './health-source-tiles'
|
||||||
|
export { ServiceAddressBlock } from './service-address-block'
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function resolveFooter(item: KpiStatItem): ReactNode {
|
|||||||
if (item.footer) return item.footer
|
if (item.footer) return item.footer
|
||||||
if (typeof item.hint === 'string') {
|
if (typeof item.hint === 'string') {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" size="sm">
|
<Badge variant="outline" size="sm" className="max-w-[min(100%,11rem)] truncate">
|
||||||
{item.hint}
|
{item.hint}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
@@ -81,30 +81,39 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
|||||||
const valueVariant = item.variant ?? 'default'
|
const valueVariant = item.variant ?? 'default'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-full items-start gap-3">
|
<div className="@container relative z-10 flex h-full min-w-0 items-start gap-3">
|
||||||
{item.icon ? (
|
{item.icon ? (
|
||||||
<IconTile
|
<IconTile
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
className={cn('size-10.5 shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</IconTile>
|
</IconTile>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||||
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
<div className="text-muted-foreground min-w-0 truncate text-sm font-medium">
|
||||||
{footer ? <div className="shrink-0">{footer}</div> : null}
|
{item.label}
|
||||||
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<div className="hidden min-w-0 max-w-[min(100%,11rem)] shrink-0 @[20rem]:block">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-2xl leading-none font-bold tabular-nums',
|
'min-w-0 break-all text-2xl leading-none font-bold tabular-nums',
|
||||||
VALUE_VARIANT_CLASS[valueVariant],
|
VALUE_VARIANT_CLASS[valueVariant],
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.value}
|
{item.value}
|
||||||
</div>
|
</div>
|
||||||
|
{footer ? (
|
||||||
|
<div className="min-w-0 max-w-full @[20rem]:hidden">{footer}</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -116,7 +125,7 @@ function panelClassName(item: KpiStatItem, className?: string) {
|
|||||||
const selected = isSelected(item)
|
const selected = isSelected(item)
|
||||||
|
|
||||||
return cn(
|
return cn(
|
||||||
'relative isolate flex h-full flex-col',
|
'relative isolate flex h-full min-w-0 flex-col',
|
||||||
clickable &&
|
clickable &&
|
||||||
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||||
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||||
|
|||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import { useState, type KeyboardEvent } from 'react'
|
||||||
|
import { PlusIcon, ServerIcon, Trash2Icon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
||||||
|
import { isValidIpv4 } from '@/components/tagged-input'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import { parseFqdn } from '@/lib/parse-fqdn'
|
||||||
|
import {
|
||||||
|
addAddressNode,
|
||||||
|
emptyBindingDraft,
|
||||||
|
removeAddressNode,
|
||||||
|
withPoolIps,
|
||||||
|
type AddressBlockState,
|
||||||
|
type ServiceBindingDraft,
|
||||||
|
} from '@/lib/service-address'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Field, FieldLabel } from '@cfdm/ui/components/field'
|
||||||
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupInput,
|
||||||
|
} from '@cfdm/ui/components/input-group'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemGroup,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@cfdm/ui/components/item'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@cfdm/ui/components/select'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единый блок адресов сервиса: общий FQDN + пул IP с опциональным доп. доменом.
|
||||||
|
* Preview: https://reui.io/preview/base/settings-3
|
||||||
|
* Preview: https://reui.io/preview/base/list-9
|
||||||
|
* Preview: https://reui.io/preview/base/form-7
|
||||||
|
* Docs: https://reui.io/docs/components/base/frame
|
||||||
|
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||||
|
* Docs: https://reui.io/docs/components/base/badge
|
||||||
|
*/
|
||||||
|
export function ServiceAddressBlock({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
zoneHints,
|
||||||
|
}: {
|
||||||
|
value: AddressBlockState
|
||||||
|
onChange: (next: AddressBlockState) => void
|
||||||
|
zoneHints: string[]
|
||||||
|
}) {
|
||||||
|
const [pendingIp, setPendingIp] = useState('')
|
||||||
|
const [ipInvalid, setIpInvalid] = useState(false)
|
||||||
|
const [otherOpen, setOtherOpen] = useState(value.otherBindings.length > 0)
|
||||||
|
|
||||||
|
const pool = value.nodes.map((node) => node.ip)
|
||||||
|
const parsedCommon = parseFqdn(value.commonFqdn, zoneHints)
|
||||||
|
const showOthers = otherOpen || value.otherBindings.length > 0
|
||||||
|
const pendingTrimmed = pendingIp.trim()
|
||||||
|
const pendingInvalid =
|
||||||
|
ipInvalid && pendingTrimmed.length > 0 && !isValidIpv4(pendingTrimmed)
|
||||||
|
|
||||||
|
function handleCommonFqdn(next: string) {
|
||||||
|
onChange({ ...value, commonFqdn: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryAddIp(raw: string) {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setIpInvalid(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!isValidIpv4(trimmed) || pool.includes(trimmed)) {
|
||||||
|
setIpInvalid(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange(addAddressNode(value, trimmed))
|
||||||
|
setPendingIp('')
|
||||||
|
setIpInvalid(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePendingKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
tryAddIp(pendingIp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNodeFqdn(ip: string, extraFqdn: string) {
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
nodes: value.nodes.map((node) =>
|
||||||
|
node.ip === ip ? { ...node, extraFqdn } : node,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveIp(ip: string) {
|
||||||
|
onChange(removeAddressNode(value, ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddOther() {
|
||||||
|
setOtherOpen(true)
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
otherBindings: [...value.otherBindings, withPoolIps(emptyBindingDraft(), pool)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOtherChange(index: number, next: ServiceBindingDraft) {
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
otherBindings: value.otherBindings.map((item, i) => (i === index ? next : item)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveOther(index: number) {
|
||||||
|
const otherBindings = value.otherBindings.filter((_, i) => i !== index)
|
||||||
|
onChange({ ...value, otherBindings })
|
||||||
|
if (otherBindings.length === 0) setOtherOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame stacked dense spacing="sm" className="w-full min-w-0">
|
||||||
|
<FramePanel fit className="flex flex-col gap-3">
|
||||||
|
<FrameHeader className="px-0 pt-0">
|
||||||
|
<FrameTitle>Адреса</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Общий FQDN на весь пул · у каждого IP свой доп. домен
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="service-common-fqdn">Общий домен (FQDN)</FieldLabel>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
|
id="service-common-fqdn"
|
||||||
|
className="font-mono"
|
||||||
|
value={value.commonFqdn}
|
||||||
|
placeholder={zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'}
|
||||||
|
onChange={(event) => handleCommonFqdn(event.target.value)}
|
||||||
|
/>
|
||||||
|
{parsedCommon ? (
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
<Badge variant="outline" size="xs" className="font-mono">
|
||||||
|
{parsedCommon.zoneName}
|
||||||
|
</Badge>
|
||||||
|
</InputGroupAddon>
|
||||||
|
) : value.commonFqdn.trim() ? (
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
<Badge variant="warning-light" size="xs">
|
||||||
|
зона не найдена
|
||||||
|
</Badge>
|
||||||
|
</InputGroupAddon>
|
||||||
|
) : null}
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
</FramePanel>
|
||||||
|
|
||||||
|
<FramePanel fit className="flex flex-col gap-3">
|
||||||
|
<FrameHeader className="px-0 pt-0">
|
||||||
|
<FrameTitle>IP-адреса</FrameTitle>
|
||||||
|
</FrameHeader>
|
||||||
|
{value.nodes.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ServerIcon}
|
||||||
|
title="Добавьте IP пула"
|
||||||
|
description="IPv4 сервиса. Для каждого адреса можно указать доп. FQDN."
|
||||||
|
stackedIcon={false}
|
||||||
|
centered={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ItemGroup className="gap-2">
|
||||||
|
{value.nodes.map((node) => {
|
||||||
|
const parsedExtra = parseFqdn(node.extraFqdn, zoneHints)
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
key={node.ip}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="items-stretch"
|
||||||
|
>
|
||||||
|
<ItemMedia>
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="xs"
|
||||||
|
className="text-info"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<ServerIcon />
|
||||||
|
</IconTile>
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent className="flex min-w-0 flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ItemTitle className="font-mono">{node.ip}</ItemTitle>
|
||||||
|
<ItemActions className="ml-auto shrink-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Удалить ${node.ip}`}
|
||||||
|
onClick={() => handleRemoveIp(node.ip)}
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
</Button>
|
||||||
|
</ItemActions>
|
||||||
|
</div>
|
||||||
|
<Field className="gap-1.5">
|
||||||
|
<FieldLabel
|
||||||
|
htmlFor={`service-ip-extra-${node.ip}`}
|
||||||
|
className="text-muted-foreground text-xs"
|
||||||
|
>
|
||||||
|
Доп. FQDN
|
||||||
|
</FieldLabel>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
|
id={`service-ip-extra-${node.ip}`}
|
||||||
|
className="font-mono"
|
||||||
|
value={node.extraFqdn}
|
||||||
|
placeholder={
|
||||||
|
zoneHints[0]
|
||||||
|
? `необязательно · spb.${zoneHints[0]}`
|
||||||
|
: 'необязательно · spb.example.com'
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleNodeFqdn(node.ip, event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{parsedExtra ? (
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
<Badge variant="outline" size="xs" className="font-mono">
|
||||||
|
{parsedExtra.zoneName}
|
||||||
|
</Badge>
|
||||||
|
</InputGroupAddon>
|
||||||
|
) : node.extraFqdn.trim() ? (
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
<Badge variant="warning-light" size="xs">
|
||||||
|
зона не найдена
|
||||||
|
</Badge>
|
||||||
|
</InputGroupAddon>
|
||||||
|
) : null}
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ItemGroup>
|
||||||
|
)}
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
|
id="service-pool-ip-add"
|
||||||
|
className="font-mono"
|
||||||
|
value={pendingIp}
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
aria-invalid={pendingInvalid || undefined}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPendingIp(event.target.value)
|
||||||
|
setIpInvalid(false)
|
||||||
|
}}
|
||||||
|
onKeyDown={handlePendingKeyDown}
|
||||||
|
onBlur={() => tryAddIp(pendingIp)}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
<InputGroupButton size="sm" onClick={() => tryAddIp(pendingIp)}>
|
||||||
|
Добавить
|
||||||
|
</InputGroupButton>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
{showOthers ? null : (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddOther}
|
||||||
|
>
|
||||||
|
<PlusIcon data-icon="inline-start" />
|
||||||
|
Другой FQDN
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
|
||||||
|
{showOthers ? (
|
||||||
|
<FramePanel fit className="flex flex-col gap-3">
|
||||||
|
<FrameHeader className="flex flex-row items-start justify-between gap-2 px-0 pt-0">
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<FrameTitle>Другие FQDN</FrameTitle>
|
||||||
|
<FrameDescription>CNAME и A не 1:1 с IP пула</FrameDescription>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={handleAddOther}>
|
||||||
|
<PlusIcon data-icon="inline-start" />
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
</FrameHeader>
|
||||||
|
{value.otherBindings.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Нет дополнительных FQDN</p>
|
||||||
|
) : (
|
||||||
|
<ItemGroup className="gap-2">
|
||||||
|
{value.otherBindings.map((binding, index) => {
|
||||||
|
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
key={`other-binding-${index}`}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="items-stretch"
|
||||||
|
>
|
||||||
|
<ItemContent className="flex w-full min-w-0 flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{parsedZone ? (
|
||||||
|
<Badge variant="outline" size="xs" className="font-mono">
|
||||||
|
{parsedZone.zoneName}
|
||||||
|
</Badge>
|
||||||
|
) : binding.fqdn.trim() ? (
|
||||||
|
<Badge variant="warning-light" size="xs">
|
||||||
|
зона не найдена
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">FQDN</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="ml-auto shrink-0"
|
||||||
|
aria-label="Удалить FQDN"
|
||||||
|
onClick={() => handleRemoveOther(index)}
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_7.5rem]">
|
||||||
|
<Input
|
||||||
|
id={`other-fqdn-${index}`}
|
||||||
|
className="font-mono"
|
||||||
|
value={binding.fqdn}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleOtherChange(index, {
|
||||||
|
...binding,
|
||||||
|
fqdn: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
zoneHints[0] ? `api.${zoneHints[0]}` : 'api.ivx.su'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
items={[
|
||||||
|
{ label: 'A (IP)', value: 'A' },
|
||||||
|
{ label: 'CNAME', value: 'CNAME' },
|
||||||
|
]}
|
||||||
|
value={binding.record_type}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
const recordType = (next ?? 'A') as 'A' | 'CNAME'
|
||||||
|
handleOtherChange(index, {
|
||||||
|
...binding,
|
||||||
|
record_type: recordType,
|
||||||
|
target_ips: recordType === 'A' ? binding.target_ips : [],
|
||||||
|
target_cname:
|
||||||
|
recordType === 'CNAME' ? binding.target_cname : '',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`other-type-${index}`} className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="A">A (IP)</SelectItem>
|
||||||
|
<SelectItem value="CNAME">CNAME</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{binding.record_type === 'CNAME' ? (
|
||||||
|
<Input
|
||||||
|
id={`other-cname-${index}`}
|
||||||
|
value={binding.target_cname}
|
||||||
|
placeholder="mmsk.rkns.top"
|
||||||
|
onChange={(event) =>
|
||||||
|
handleOtherChange(index, {
|
||||||
|
...binding,
|
||||||
|
target_cname: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ServiceBindingIpInput
|
||||||
|
id={`other-ip-${index}`}
|
||||||
|
value={binding.target_ips}
|
||||||
|
pool={pool}
|
||||||
|
onChange={(targetIps) =>
|
||||||
|
handleOtherChange(index, {
|
||||||
|
...binding,
|
||||||
|
target_ips: targetIps,
|
||||||
|
target_ip_weights: Object.fromEntries(
|
||||||
|
targetIps.map((ip) => [
|
||||||
|
ip,
|
||||||
|
binding.target_ip_weights[ip] ?? 1,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
target_ip_priorities: Object.fromEntries(
|
||||||
|
targetIps.map((ip) => [
|
||||||
|
ip,
|
||||||
|
binding.target_ip_priorities[ip] ?? 1,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ItemGroup>
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
) : null}
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { HealthTimeline } from '@/components/health/health-timeline'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { UptimeChart, UPTIME_PERIODS, type UptimePeriodKey } from '@/components/reui-kit/uptime-chart'
|
||||||
|
import { HealthSourceFilterBar } from '@/components/reui-kit/health-source-tiles'
|
||||||
|
import {
|
||||||
|
collapseStatusChanges,
|
||||||
|
filterByPeriod,
|
||||||
|
filterByProviders,
|
||||||
|
type HealthLogProbe,
|
||||||
|
type HealthLogStatus,
|
||||||
|
} from '@/lib/health-log'
|
||||||
|
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единый блок мониторинга: компактный мультивыбор типа пробы (list-9 / ToggleGroup)
|
||||||
|
* + график (chart-17) + таймлайн смен статуса.
|
||||||
|
*
|
||||||
|
* Preview: https://reui.io/preview/base/list-9
|
||||||
|
* Preview: https://reui.io/preview/base/chart-17
|
||||||
|
* Preview: https://reui.io/preview/base/solution-ai-ops-1
|
||||||
|
* Docs: https://reui.io/docs/components/base/frame
|
||||||
|
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||||
|
* Docs: https://reui.io/docs/components/base/timeline
|
||||||
|
* Docs: https://reui.io/docs/components/base/badge
|
||||||
|
*/
|
||||||
|
export function ServiceHealthMonitor({
|
||||||
|
items,
|
||||||
|
enabledProviders,
|
||||||
|
statuses,
|
||||||
|
isLoading = false,
|
||||||
|
}: {
|
||||||
|
items: HealthLogProbe[]
|
||||||
|
enabledProviders: readonly HealthCheckProvider[]
|
||||||
|
statuses: Partial<Record<HealthCheckProvider, HealthLogStatus>>
|
||||||
|
isLoading?: boolean
|
||||||
|
}) {
|
||||||
|
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
|
||||||
|
const [selected, setSelected] = useState<HealthCheckProvider[] | null>(null)
|
||||||
|
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||||
|
|
||||||
|
const enabled = useMemo(
|
||||||
|
() => [...enabledProviders],
|
||||||
|
[enabledProviders],
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeProviders = useMemo((): HealthCheckProvider[] => {
|
||||||
|
const picked = (selected ?? enabled).filter((provider) =>
|
||||||
|
enabled.includes(provider),
|
||||||
|
)
|
||||||
|
return picked.length > 0 ? picked : enabled
|
||||||
|
}, [enabled, selected])
|
||||||
|
|
||||||
|
const periodItems = useMemo(
|
||||||
|
() => filterByPeriod(items, days),
|
||||||
|
[items, days],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => filterByProviders(periodItems, activeProviders),
|
||||||
|
[periodItems, activeProviders],
|
||||||
|
)
|
||||||
|
|
||||||
|
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
||||||
|
<FramePanel>
|
||||||
|
<HealthSourceFilterBar
|
||||||
|
enabled={enabled}
|
||||||
|
selected={activeProviders}
|
||||||
|
statuses={statuses}
|
||||||
|
onChange={setSelected}
|
||||||
|
/>
|
||||||
|
</FramePanel>
|
||||||
|
|
||||||
|
<UptimeChart
|
||||||
|
items={filtered}
|
||||||
|
isLoading={isLoading}
|
||||||
|
period={period}
|
||||||
|
onPeriodChange={setPeriod}
|
||||||
|
skipPeriodFilter
|
||||||
|
embedded
|
||||||
|
hideHeader
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FramePanel className="flex flex-col gap-3">
|
||||||
|
<FrameHeader className="px-0 py-0">
|
||||||
|
<FrameTitle>Смены статуса</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Только переходы up / degraded / down · Cloudflare = Worker, не Health
|
||||||
|
Checks API
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<HealthTimeline
|
||||||
|
events={changes.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
hostname: row.ip,
|
||||||
|
type: row.provider,
|
||||||
|
status: row.status,
|
||||||
|
latency_ms: row.latency_ms,
|
||||||
|
error: row.error,
|
||||||
|
checked_at: row.checked_at,
|
||||||
|
colo: row.colo,
|
||||||
|
provider: row.provider,
|
||||||
|
}))}
|
||||||
|
emptyTitle="Нет смен статуса"
|
||||||
|
emptyDescription="События появятся при переходе up / degraded / down"
|
||||||
|
/>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||||
import { PaletteIcon, SettingsIcon } from 'lucide-react'
|
import { HeartPulseIcon, PaletteIcon, SettingsIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
|
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
@@ -21,6 +21,12 @@ const DEFAULT_TABS: SettingsTabConfig[] = [
|
|||||||
label: 'Внешний вид',
|
label: 'Внешний вид',
|
||||||
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
|
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'health',
|
||||||
|
to: '/settings/health',
|
||||||
|
label: 'Health-check',
|
||||||
|
icon: <HeartPulseIcon className="size-4" aria-hidden="true" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'integrations',
|
id: 'integrations',
|
||||||
to: '/settings/integrations',
|
to: '/settings/integrations',
|
||||||
@@ -37,7 +43,7 @@ interface SettingsShellProps {
|
|||||||
|
|
||||||
export function SettingsShell({
|
export function SettingsShell({
|
||||||
title = 'Настройки',
|
title = 'Настройки',
|
||||||
description = 'Внешний вид и интеграции',
|
description = 'Внешний вид, health-check и интеграции',
|
||||||
tabs = DEFAULT_TABS,
|
tabs = DEFAULT_TABS,
|
||||||
}: SettingsShellProps) {
|
}: SettingsShellProps) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { toAlignedSeries, type UptimeProbe } from './uptime-chart'
|
||||||
|
|
||||||
|
function probe(
|
||||||
|
overrides: Partial<UptimeProbe> & Pick<UptimeProbe, 'id'>,
|
||||||
|
): UptimeProbe {
|
||||||
|
return {
|
||||||
|
status: 'up',
|
||||||
|
ok: true,
|
||||||
|
latency_ms: 10,
|
||||||
|
checked_at: '2026-01-01T00:00:00.000Z',
|
||||||
|
provider: 'local',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('toAlignedSeries', () => {
|
||||||
|
it('puts mixed-source probes in one 60s bucket instead of a sawtooth series', () => {
|
||||||
|
const { points, keys } = toAlignedSeries([
|
||||||
|
probe({
|
||||||
|
id: 1,
|
||||||
|
provider: 'local',
|
||||||
|
latency_ms: 4,
|
||||||
|
checked_at: '2026-01-01T00:00:10.000Z',
|
||||||
|
}),
|
||||||
|
probe({
|
||||||
|
id: 2,
|
||||||
|
provider: 'cloudflare',
|
||||||
|
latency_ms: 284,
|
||||||
|
checked_at: '2026-01-01T00:00:12.000Z',
|
||||||
|
}),
|
||||||
|
probe({
|
||||||
|
id: 3,
|
||||||
|
provider: 'globalping',
|
||||||
|
latency_ms: 38,
|
||||||
|
checked_at: '2026-01-01T00:00:40.000Z',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(points).toHaveLength(1)
|
||||||
|
expect(points[0]?.local).toBe(4)
|
||||||
|
expect(points[0]?.cloudflare).toBe(284)
|
||||||
|
expect(points[0]?.globalping).toBe(38)
|
||||||
|
expect(keys).toEqual(['local', 'cloudflare', 'globalping'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not plot down probes as latency 0', () => {
|
||||||
|
const { points } = toAlignedSeries([
|
||||||
|
probe({
|
||||||
|
id: 1,
|
||||||
|
status: 'down',
|
||||||
|
ok: false,
|
||||||
|
latency_ms: 12,
|
||||||
|
provider: 'local',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(points).toHaveLength(1)
|
||||||
|
expect(points[0]?.local).toBeNull()
|
||||||
|
expect(points[0]?.localOk).toBe(false)
|
||||||
|
expect(points[0]?.ok).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits probes that fall into adjacent minutes', () => {
|
||||||
|
const { points } = toAlignedSeries([
|
||||||
|
probe({ id: 1, latency_ms: 10, checked_at: '2026-01-01T00:00:50.000Z' }),
|
||||||
|
probe({ id: 2, latency_ms: 20, checked_at: '2026-01-01T00:01:10.000Z' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(points).toHaveLength(2)
|
||||||
|
expect(points[0]?.local).toBe(10)
|
||||||
|
expect(points[1]?.local).toBe(20)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
import { useId, useMemo, useState } from 'react'
|
||||||
|
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
|
||||||
|
import { Area, ComposedChart, Line, XAxis, YAxis } from 'recharts'
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import { filterByPeriod, probeTime } from '@/lib/health-log'
|
||||||
|
import { formatDate } from '@/lib/format'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
type ChartConfig,
|
||||||
|
} from '@cfdm/ui/components/chart'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs).
|
||||||
|
* Preview: https://reui.io/preview/base/chart-17
|
||||||
|
* Frame: https://reui.io/docs/components/base/frame
|
||||||
|
* Chart: shadcn Chart + Recharts ComposedChart
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface UptimeProbe {
|
||||||
|
id: number
|
||||||
|
status: 'up' | 'down' | 'degraded' | 'unknown'
|
||||||
|
ok: boolean
|
||||||
|
latency_ms: number | null
|
||||||
|
checked_at: string
|
||||||
|
provider?: HealthCheckProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UptimePeriodKey = '5D' | '2W' | '1M'
|
||||||
|
|
||||||
|
export const UPTIME_PERIODS: { key: UptimePeriodKey; label: string; days: number }[] = [
|
||||||
|
{ key: '5D', label: '5D', days: 5 },
|
||||||
|
{ key: '2W', label: '2W', days: 14 },
|
||||||
|
{ key: '1M', label: '1M', days: 30 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const UPTIME_BUCKET_MS = 60_000
|
||||||
|
|
||||||
|
export const UPTIME_PROVIDER_KEYS = ['local', 'cloudflare', 'globalping'] as const
|
||||||
|
|
||||||
|
export type UptimeProviderKey = (typeof UPTIME_PROVIDER_KEYS)[number]
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
local: {
|
||||||
|
label: 'Local',
|
||||||
|
color: 'var(--info)',
|
||||||
|
},
|
||||||
|
cloudflare: {
|
||||||
|
label: 'Cloudflare',
|
||||||
|
color: 'var(--warning)',
|
||||||
|
},
|
||||||
|
globalping: {
|
||||||
|
label: 'Globalping',
|
||||||
|
color: 'var(--success)',
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig
|
||||||
|
|
||||||
|
export interface AlignedChartPoint {
|
||||||
|
period: string
|
||||||
|
at: string
|
||||||
|
ok: boolean
|
||||||
|
local?: number | null
|
||||||
|
cloudflare?: number | null
|
||||||
|
globalping?: number | null
|
||||||
|
localOk?: boolean
|
||||||
|
cloudflareOk?: boolean
|
||||||
|
globalpingOk?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProviderKey(value: string | undefined): value is UptimeProviderKey {
|
||||||
|
return value === 'local' || value === 'cloudflare' || value === 'globalping'
|
||||||
|
}
|
||||||
|
|
||||||
|
function bucketStart(time: number): number {
|
||||||
|
return Math.floor(time / UPTIME_BUCKET_MS) * UPTIME_BUCKET_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerOf(item: UptimeProbe): UptimeProviderKey {
|
||||||
|
return isProviderKey(item.provider) ? item.provider : 'local'
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeOk(item: UptimeProbe): boolean {
|
||||||
|
return item.ok && item.status !== 'down'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Align mixed-source probes onto a 60s time axis so Local/CF/GP do not zigzag. */
|
||||||
|
export function toAlignedSeries(items: UptimeProbe[]): {
|
||||||
|
points: AlignedChartPoint[]
|
||||||
|
keys: UptimeProviderKey[]
|
||||||
|
} {
|
||||||
|
const buckets = new Map<number, AlignedChartPoint>()
|
||||||
|
const used = new Set<UptimeProviderKey>()
|
||||||
|
|
||||||
|
const sorted = [...items].sort(
|
||||||
|
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const item of sorted) {
|
||||||
|
const key = providerOf(item)
|
||||||
|
used.add(key)
|
||||||
|
const start = bucketStart(probeTime(item.checked_at))
|
||||||
|
let row = buckets.get(start)
|
||||||
|
if (!row) {
|
||||||
|
row = {
|
||||||
|
period: formatDate(item.checked_at),
|
||||||
|
at: item.checked_at,
|
||||||
|
ok: true,
|
||||||
|
}
|
||||||
|
buckets.set(start, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = probeOk(item)
|
||||||
|
row[`${key}Ok`] = ok
|
||||||
|
row[key] = ok ? item.latency_ms : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = [...buckets.entries()]
|
||||||
|
.sort((a, b) => a[0] - b[0])
|
||||||
|
.map(([, row]) => {
|
||||||
|
const present = UPTIME_PROVIDER_KEYS.filter((key) => row[`${key}Ok`] !== undefined)
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
ok: present.length === 0 ? row.ok : present.every((key) => row[`${key}Ok`] !== false),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const keys = UPTIME_PROVIDER_KEYS.filter((key) => used.has(key))
|
||||||
|
return { points, keys }
|
||||||
|
}
|
||||||
|
|
||||||
|
function uptimePercent(points: AlignedChartPoint[]): number | null {
|
||||||
|
if (points.length === 0) return null
|
||||||
|
const okCount = points.filter((point) => point.ok).length
|
||||||
|
return (okCount / points.length) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function deltaPercent(points: AlignedChartPoint[]): number | null {
|
||||||
|
if (points.length < 4) return null
|
||||||
|
const mid = Math.floor(points.length / 2)
|
||||||
|
const prev = uptimePercent(points.slice(0, mid))
|
||||||
|
const next = uptimePercent(points.slice(mid))
|
||||||
|
if (prev == null || next == null) return null
|
||||||
|
return next - prev
|
||||||
|
}
|
||||||
|
|
||||||
|
function UptimeDelta({ delta }: { delta: number }) {
|
||||||
|
if (Math.abs(delta) < 0.05) {
|
||||||
|
return <span className="text-muted-foreground">без изменений за период</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta > 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TrendingUpIcon className="text-success size-4" aria-hidden="true" />
|
||||||
|
<span className="text-success font-medium">+{delta.toFixed(1)} п.п.</span>
|
||||||
|
<span className="text-muted-foreground">с начала периода</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TrendingDownIcon className="text-destructive size-4" aria-hidden="true" />
|
||||||
|
<span className="text-destructive font-medium">{delta.toFixed(1)} п.п.</span>
|
||||||
|
<span className="text-muted-foreground">с начала периода</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function probeUptimePercent(items: UptimeProbe[]): number | null {
|
||||||
|
return uptimePercent(toAlignedSeries(items).points)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lastProbeLatency(items: UptimeProbe[]): number | null {
|
||||||
|
if (items.length === 0) return null
|
||||||
|
const latest = [...items].sort(
|
||||||
|
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at),
|
||||||
|
)[0]
|
||||||
|
return latest?.latency_ms ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(value: number | null): string {
|
||||||
|
if (value == null) return '—'
|
||||||
|
return `${value.toFixed(value >= 99.95 ? 2 : 1)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPing(value: unknown, ok: boolean | undefined): string {
|
||||||
|
if (ok === false) return '—'
|
||||||
|
const ping = typeof value === 'number' ? value : Number(value)
|
||||||
|
return Number.isFinite(ping) ? `${ping} мс` : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UptimeChartProps {
|
||||||
|
items: UptimeProbe[]
|
||||||
|
isLoading?: boolean
|
||||||
|
period?: UptimePeriodKey
|
||||||
|
onPeriodChange?: (period: UptimePeriodKey) => void
|
||||||
|
skipPeriodFilter?: boolean
|
||||||
|
embedded?: boolean
|
||||||
|
/** dashboard-4: chrome живёт в родительском FrameHeader (переключатель серий). */
|
||||||
|
hideHeader?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UptimeChart({
|
||||||
|
items,
|
||||||
|
isLoading = false,
|
||||||
|
period: periodProp,
|
||||||
|
onPeriodChange,
|
||||||
|
skipPeriodFilter = false,
|
||||||
|
embedded = false,
|
||||||
|
hideHeader = false,
|
||||||
|
}: UptimeChartProps) {
|
||||||
|
const gradientId = useId().replace(/:/g, '')
|
||||||
|
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
|
||||||
|
const [hovered, setHovered] = useState<AlignedChartPoint | null>(null)
|
||||||
|
const period = periodProp ?? internalPeriod
|
||||||
|
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||||
|
|
||||||
|
function handlePeriodChange(next: UptimePeriodKey) {
|
||||||
|
onPeriodChange?.(next)
|
||||||
|
if (periodProp == null) setInternalPeriod(next)
|
||||||
|
setHovered(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { points, keys } = useMemo(
|
||||||
|
() => toAlignedSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
|
||||||
|
[items, days, skipPeriodFilter],
|
||||||
|
)
|
||||||
|
const uptime = uptimePercent(points)
|
||||||
|
const delta = deltaPercent(points)
|
||||||
|
const lastOk = points.at(-1)?.ok ?? true
|
||||||
|
const tileClass = lastOk ? 'text-success' : 'text-destructive'
|
||||||
|
const single = keys.length <= 1
|
||||||
|
const areaKey = keys[0] ?? 'local'
|
||||||
|
const hoverPings = hovered
|
||||||
|
? keys.map((key) => {
|
||||||
|
const ok = hovered[`${key}Ok`]
|
||||||
|
const label = chartConfig[key].label
|
||||||
|
return `${label} ${formatPing(hovered[key], ok)}`
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
function syncHover(state: {
|
||||||
|
activeTooltipIndex?: unknown
|
||||||
|
activeIndex?: unknown
|
||||||
|
}) {
|
||||||
|
const index = Number(state.activeTooltipIndex ?? state.activeIndex)
|
||||||
|
if (!Number.isFinite(index)) return
|
||||||
|
setHovered(points[index] ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const panel = (
|
||||||
|
<FramePanel className="flex flex-col gap-6 overflow-visible">
|
||||||
|
{hideHeader ? null : (
|
||||||
|
<div className="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
className={`size-10.5 ${tileClass}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<ActivityIcon />
|
||||||
|
</IconTile>
|
||||||
|
<div className="flex flex-col justify-center">
|
||||||
|
<h3 className="text-base font-semibold">Uptime</h3>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Пробы health-check за период
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TooltipProvider delay={150}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
aria-label="О графике uptime"
|
||||||
|
className="text-muted-foreground/70 -mr-1"
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InfoIcon data-icon="inline-start" aria-hidden="true" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" sideOffset={8}>
|
||||||
|
<p>Доля успешных проб и задержка (мс) по журналу health-log.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="bg-muted h-40 w-full animate-pulse rounded-xl" />
|
||||||
|
) : points.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ActivityIcon}
|
||||||
|
title="Нет проб за период"
|
||||||
|
description="Результаты появятся после health-check"
|
||||||
|
stackedIcon={false}
|
||||||
|
centered={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="text-foreground text-3xl font-semibold tabular-nums">
|
||||||
|
{formatUptime(uptime)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{delta == null ? (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{points.length} проб
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<UptimeDelta delta={delta} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hoverPings.length > 0 ? (
|
||||||
|
<p className="text-muted-foreground min-h-4 min-w-0 text-xs tabular-nums">
|
||||||
|
<span className="text-foreground font-medium">Пинг</span>
|
||||||
|
{' · '}
|
||||||
|
{formatDate(hovered?.at)}
|
||||||
|
{' · '}
|
||||||
|
{hoverPings.join(' · ')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground min-h-4 text-xs">
|
||||||
|
Наведите на точку графика
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="h-40 w-full overflow-visible">
|
||||||
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className="[&_.recharts-tooltip-wrapper]:z-50 [&_.recharts-wrapper]:overflow-visible h-full w-full overflow-visible rounded-b-xl"
|
||||||
|
initialDimension={{ width: 320, height: 160 }}
|
||||||
|
>
|
||||||
|
<ComposedChart
|
||||||
|
data={points}
|
||||||
|
margin={{ top: 24, left: 8, right: 8, bottom: 8 }}
|
||||||
|
accessibilityLayer
|
||||||
|
onMouseMove={syncHover}
|
||||||
|
onMouseLeave={() => setHovered(null)}
|
||||||
|
onClick={syncHover}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="5%"
|
||||||
|
stopColor={`var(--color-${areaKey})`}
|
||||||
|
stopOpacity={0.8}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="95%"
|
||||||
|
stopColor={`var(--color-${areaKey})`}
|
||||||
|
stopOpacity={0.1}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis dataKey="at" hide />
|
||||||
|
<YAxis hide domain={['auto', 'auto']} />
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
|
||||||
|
filterNull={false}
|
||||||
|
shared
|
||||||
|
isAnimationActive={false}
|
||||||
|
allowEscapeViewBox={{ x: true, y: true }}
|
||||||
|
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
labelFormatter={(_label, payload) => {
|
||||||
|
const at = (payload?.[0]?.payload as AlignedChartPoint | undefined)?.at
|
||||||
|
return at ? formatDate(at) : String(_label ?? '')
|
||||||
|
}}
|
||||||
|
formatter={(value, name, item) => {
|
||||||
|
const key = String(name)
|
||||||
|
const row = item.payload as AlignedChartPoint | undefined
|
||||||
|
const ok =
|
||||||
|
key === 'local' || key === 'cloudflare' || key === 'globalping'
|
||||||
|
? row?.[`${key}Ok`]
|
||||||
|
: row?.ok
|
||||||
|
const label = chartConfig[key as UptimeProviderKey]?.label ?? key
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-between gap-4">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{ok === false ? `${label} · Down` : `Пинг · ${label}`}
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||||
|
{formatPing(value, ok)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{single ? (
|
||||||
|
<Area
|
||||||
|
dataKey={areaKey}
|
||||||
|
name={areaKey}
|
||||||
|
type="monotone"
|
||||||
|
fill={`url(#${gradientId})`}
|
||||||
|
stroke={`var(--color-${areaKey})`}
|
||||||
|
strokeWidth={2}
|
||||||
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
|
||||||
|
activeDot={{
|
||||||
|
r: 6,
|
||||||
|
stroke: 'var(--background)',
|
||||||
|
strokeWidth: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
keys.map((key) => (
|
||||||
|
<Line
|
||||||
|
key={key}
|
||||||
|
dataKey={key}
|
||||||
|
name={key}
|
||||||
|
type="monotone"
|
||||||
|
stroke={`var(--color-${key})`}
|
||||||
|
strokeWidth={2}
|
||||||
|
connectNulls={false}
|
||||||
|
isAnimationActive={false}
|
||||||
|
dot={{ r: 3, strokeWidth: 1, stroke: 'var(--background)' }}
|
||||||
|
activeDot={{
|
||||||
|
r: 6,
|
||||||
|
stroke: 'var(--background)',
|
||||||
|
strokeWidth: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ComposedChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={period}
|
||||||
|
onValueChange={(value) => handlePeriodChange(value as UptimePeriodKey)}
|
||||||
|
>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
{UPTIME_PERIODS.map((entry) => (
|
||||||
|
<TabsTrigger key={entry.key} value={entry.key} className="flex-1">
|
||||||
|
{entry.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (embedded) return panel
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame spacing="sm" className="min-w-0 w-full">
|
||||||
|
{panel}
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link2Icon, PlusIcon, Trash2Icon } from 'lucide-react'
|
import { Trash2Icon } from 'lucide-react'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
import { ServiceAddressBlock } from '@/components/reui-kit/service-address-block'
|
||||||
import { EmptyState } from '@/components/empty-state'
|
|
||||||
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
|
||||||
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
|
||||||
import {
|
import {
|
||||||
HealthCheckConfigFields,
|
HealthCheckConfigFields,
|
||||||
type LbAndHealthConfig,
|
type LbAndHealthConfig,
|
||||||
type LbMode,
|
|
||||||
type HealthCheckType,
|
|
||||||
} from '@/components/health-check-config-fields'
|
} from '@/components/health-check-config-fields'
|
||||||
import type {
|
import type {
|
||||||
CreateServiceWithConfigInput,
|
CreateServiceWithConfigInput,
|
||||||
@@ -18,8 +13,16 @@ import type {
|
|||||||
ServiceView,
|
ServiceView,
|
||||||
UpdateServiceConfigInput,
|
UpdateServiceConfigInput,
|
||||||
} from '@/lib/schemas'
|
} from '@/lib/schemas'
|
||||||
import { bindingToFqdn, parseFqdn } from '@/lib/parse-fqdn'
|
import {
|
||||||
import { Badge } from '@/components/reui/badge'
|
DEFAULT_BINDING_HEALTH,
|
||||||
|
emptyAddressBlock,
|
||||||
|
hydrateAddressBlock,
|
||||||
|
toBindingDrafts,
|
||||||
|
toDomainsPayload,
|
||||||
|
type AddressBlockState,
|
||||||
|
type BindingHealthConfig,
|
||||||
|
type ServiceBindingDraft,
|
||||||
|
} from '@/lib/service-address'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
@@ -29,16 +32,9 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@cfdm/ui/components/sheet'
|
} from '@cfdm/ui/components/sheet'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
|
||||||
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
import {
|
|
||||||
Item,
|
|
||||||
ItemContent,
|
|
||||||
ItemGroup,
|
|
||||||
} from '@cfdm/ui/components/item'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { TabsContent } from '@cfdm/ui/components/tabs'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -47,40 +43,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
|
|
||||||
interface BindingHealthConfig {
|
export type { ServiceBindingDraft }
|
||||||
enabled: boolean
|
|
||||||
type: HealthCheckType
|
|
||||||
port: number | null
|
|
||||||
path: string | null
|
|
||||||
expected_status: number | null
|
|
||||||
interval_sec: number
|
|
||||||
timeout_ms: number
|
|
||||||
verify_tls: boolean
|
|
||||||
provider: 'local' | 'cloudflare'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServiceBindingDraft {
|
|
||||||
fqdn: string
|
|
||||||
record_type: 'A' | 'CNAME'
|
|
||||||
target_ips: string[]
|
|
||||||
target_cname: string
|
|
||||||
lb_mode: LbMode
|
|
||||||
health: BindingHealthConfig
|
|
||||||
target_ip_weights: Record<string, number>
|
|
||||||
target_ip_priorities: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultHealth: BindingHealthConfig = {
|
|
||||||
enabled: false,
|
|
||||||
type: 'tcp',
|
|
||||||
port: null,
|
|
||||||
path: null,
|
|
||||||
expected_status: null,
|
|
||||||
interval_sec: 30,
|
|
||||||
timeout_ms: 3000,
|
|
||||||
verify_tls: false,
|
|
||||||
provider: 'local',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceEditSheetProps {
|
interface ServiceEditSheetProps {
|
||||||
mode: 'create' | 'edit'
|
mode: 'create' | 'edit'
|
||||||
@@ -97,67 +60,20 @@ interface ServiceEditSheetProps {
|
|||||||
onDelete?: (id: number) => void
|
onDelete?: (id: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
|
||||||
return (service.domains ?? []).map((binding) => ({
|
return {
|
||||||
fqdn: bindingToFqdn(binding),
|
enabled: next.enabled,
|
||||||
record_type: binding.record_type ?? (binding.target_cname ? 'CNAME' : 'A'),
|
type: next.type,
|
||||||
target_ips: binding.target_ips ?? [],
|
port: next.port,
|
||||||
target_cname: binding.target_cname ?? '',
|
path: next.path,
|
||||||
lb_mode: binding.lb_mode,
|
expected_status: next.expected_status,
|
||||||
health: {
|
interval_sec: next.interval_sec,
|
||||||
enabled: binding.health_check_enabled,
|
timeout_ms: next.timeout_ms,
|
||||||
type: binding.health_check_type === 'http' ? 'http' : 'tcp',
|
verify_tls: next.verify_tls,
|
||||||
port: binding.health_check_port,
|
provider: next.provider,
|
||||||
path: binding.health_check_path,
|
providers: next.providers,
|
||||||
expected_status: binding.health_check_expected_status,
|
aggregate: next.aggregate,
|
||||||
interval_sec: binding.health_check_interval_sec,
|
|
||||||
timeout_ms: binding.health_check_timeout_ms,
|
|
||||||
verify_tls: binding.health_check_verify_tls ?? false,
|
|
||||||
provider: 'local',
|
|
||||||
},
|
|
||||||
target_ip_weights: binding.target_ip_weights ?? {},
|
|
||||||
target_ip_priorities: binding.target_ip_priorities ?? {},
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
|
||||||
return bindings
|
|
||||||
.filter((binding) => {
|
|
||||||
if (!binding.fqdn.trim()) return false
|
|
||||||
if (binding.record_type === 'CNAME') return Boolean(binding.target_cname.trim())
|
|
||||||
return binding.target_ips.length > 0
|
|
||||||
})
|
|
||||||
.map((binding) =>
|
|
||||||
binding.record_type === 'CNAME'
|
|
||||||
? {
|
|
||||||
fqdn: binding.fqdn.trim(),
|
|
||||||
target_cname: binding.target_cname.trim(),
|
|
||||||
lb_mode: binding.lb_mode,
|
|
||||||
health_check_enabled: binding.health.enabled,
|
|
||||||
health_check_type: binding.health.type,
|
|
||||||
health_check_port: binding.health.port,
|
|
||||||
health_check_path: binding.health.path,
|
|
||||||
health_check_expected_status: binding.health.expected_status,
|
|
||||||
health_check_interval_sec: binding.health.interval_sec,
|
|
||||||
health_check_timeout_ms: binding.health.timeout_ms,
|
|
||||||
health_check_verify_tls: binding.health.verify_tls,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
fqdn: binding.fqdn.trim(),
|
|
||||||
target_ips: binding.target_ips,
|
|
||||||
target_ip_weights: binding.target_ip_weights,
|
|
||||||
target_ip_priorities: binding.target_ip_priorities,
|
|
||||||
lb_mode: binding.lb_mode,
|
|
||||||
health_check_enabled: binding.health.enabled,
|
|
||||||
health_check_type: binding.health.type,
|
|
||||||
health_check_port: binding.health.port,
|
|
||||||
health_check_path: binding.health.path,
|
|
||||||
health_check_expected_status: binding.health.expected_status,
|
|
||||||
health_check_interval_sec: binding.health.interval_sec,
|
|
||||||
health_check_timeout_ms: binding.health.timeout_ms,
|
|
||||||
health_check_verify_tls: binding.health.verify_tls,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceEditSheet({
|
export function ServiceEditSheet({
|
||||||
@@ -177,12 +93,13 @@ export function ServiceEditSheet({
|
|||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [slug, setSlug] = useState('')
|
const [slug, setSlug] = useState('')
|
||||||
const [serviceGroupId, setServiceGroupId] = useState('none')
|
const [serviceGroupId, setServiceGroupId] = useState('none')
|
||||||
const [ips, setIps] = useState<string[]>([])
|
const [address, setAddress] = useState<AddressBlockState>(() => emptyAddressBlock())
|
||||||
const [commonFqdn, setCommonFqdn] = useState('')
|
const [health, setHealth] = useState<BindingHealthConfig>(() => ({
|
||||||
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
|
...DEFAULT_BINDING_HEALTH,
|
||||||
|
}))
|
||||||
|
const [lbMode, setLbMode] = useState<LbAndHealthConfig['lb_mode']>('round_robin')
|
||||||
const [lbWeight, setLbWeight] = useState(1)
|
const [lbWeight, setLbWeight] = useState(1)
|
||||||
const [lbPriority, setLbPriority] = useState(1)
|
const [lbPriority, setLbPriority] = useState(1)
|
||||||
const [activeTab, setActiveTab] = useState('general')
|
|
||||||
|
|
||||||
const groupItems = useMemo(
|
const groupItems = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -192,19 +109,20 @@ export function ServiceEditSheet({
|
|||||||
[groups],
|
[groups],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Reset only when the sheet opens or the service id changes.
|
||||||
|
// Health polling replaces `service` by identity and would wipe unsaved settings.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setActiveTab('general')
|
|
||||||
if (mode === 'edit' && service) {
|
if (mode === 'edit' && service) {
|
||||||
setName(service.name)
|
setName(service.name)
|
||||||
setSlug(service.slug)
|
setSlug(service.slug)
|
||||||
setServiceGroupId(
|
setServiceGroupId(
|
||||||
service.service_group_id != null ? String(service.service_group_id) : 'none',
|
service.service_group_id != null ? String(service.service_group_id) : 'none',
|
||||||
)
|
)
|
||||||
setIps(service.ips ?? [])
|
|
||||||
const drafts = toBindingDrafts(service)
|
const drafts = toBindingDrafts(service)
|
||||||
setBindings(drafts)
|
setAddress(hydrateAddressBlock(drafts, service.ips ?? []))
|
||||||
setCommonFqdn(drafts[0]?.fqdn ?? '')
|
setHealth(drafts[0]?.health ?? { ...DEFAULT_BINDING_HEALTH })
|
||||||
|
setLbMode(drafts[0]?.lb_mode ?? service.lb_mode ?? 'round_robin')
|
||||||
setLbWeight(service.lb_weight ?? 1)
|
setLbWeight(service.lb_weight ?? 1)
|
||||||
setLbPriority(service.lb_priority ?? 1)
|
setLbPriority(service.lb_priority ?? 1)
|
||||||
return
|
return
|
||||||
@@ -215,195 +133,44 @@ export function ServiceEditSheet({
|
|||||||
setServiceGroupId(
|
setServiceGroupId(
|
||||||
defaultGroupId != null ? String(defaultGroupId) : 'none',
|
defaultGroupId != null ? String(defaultGroupId) : 'none',
|
||||||
)
|
)
|
||||||
setIps([])
|
setAddress(emptyAddressBlock())
|
||||||
setCommonFqdn('')
|
setHealth({ ...DEFAULT_BINDING_HEALTH })
|
||||||
setBindings([])
|
setLbMode('round_robin')
|
||||||
setLbWeight(1)
|
setLbWeight(1)
|
||||||
setLbPriority(1)
|
setLbPriority(1)
|
||||||
}
|
}
|
||||||
}, [open, mode, service, defaultGroupId])
|
}, [open, mode, service?.id, defaultGroupId])
|
||||||
|
|
||||||
const zoneHints = useMemo(
|
const zoneHints = useMemo(
|
||||||
() => knownDomains.map((domain) => domain.zone_name),
|
() => knownDomains.map((domain) => domain.zone_name),
|
||||||
[knownDomains],
|
[knownDomains],
|
||||||
)
|
)
|
||||||
|
|
||||||
function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
|
function handlePrimaryHealthChange(next: LbAndHealthConfig) {
|
||||||
return {
|
setLbMode(next.lb_mode)
|
||||||
fqdn,
|
setHealth(healthFromConfig(next))
|
||||||
record_type: 'A',
|
|
||||||
target_ips: [],
|
|
||||||
target_cname: '',
|
|
||||||
lb_mode: 'round_robin',
|
|
||||||
health: { ...defaultHealth },
|
|
||||||
target_ip_weights: {},
|
|
||||||
target_ip_priorities: {},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAddBinding() {
|
const primaryHealthValue: LbAndHealthConfig = {
|
||||||
setBindings((current) => [
|
lb_mode: lbMode,
|
||||||
...current,
|
...health,
|
||||||
emptyBindingDraft(current.length === 0 ? commonFqdn : ''),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemoveBinding(index: number) {
|
|
||||||
setBindings((current) => {
|
|
||||||
const next = current.filter((_, i) => i !== index)
|
|
||||||
if (index === 0) {
|
|
||||||
setCommonFqdn(next[0]?.fqdn ?? '')
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCommonFqdnChange(value: string) {
|
|
||||||
setCommonFqdn(value)
|
|
||||||
setBindings((current) => {
|
|
||||||
if (current.length === 0) return current
|
|
||||||
return current.map((item, i) => (i === 0 ? { ...item, fqdn: value } : item))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFqdnChange(index: number, fqdn: string) {
|
|
||||||
if (index === 0) setCommonFqdn(fqdn)
|
|
||||||
setBindings((current) =>
|
|
||||||
current.map((item, i) => (i === index ? { ...item, fqdn } : item)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRecordTypeChange(index: number, recordType: 'A' | 'CNAME') {
|
|
||||||
setBindings((current) =>
|
|
||||||
current.map((item, i) =>
|
|
||||||
i === index
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
record_type: recordType,
|
|
||||||
target_ips: recordType === 'A' ? item.target_ips : [],
|
|
||||||
target_cname: recordType === 'CNAME' ? item.target_cname : '',
|
|
||||||
}
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCnameChange(index: number, value: string) {
|
|
||||||
setBindings((current) =>
|
|
||||||
current.map((item, i) => (i === index ? { ...item, target_cname: value } : item)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleIpsChange(index: number, targetIps: string[]) {
|
|
||||||
setBindings((current) =>
|
|
||||||
current.map((item, i) =>
|
|
||||||
i === index
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
target_ips: targetIps,
|
|
||||||
target_ip_weights: Object.fromEntries(
|
|
||||||
targetIps.map((ip) => [ip, item.target_ip_weights[ip] ?? 1]),
|
|
||||||
),
|
|
||||||
target_ip_priorities: Object.fromEntries(
|
|
||||||
targetIps.map((ip) => [ip, item.target_ip_priorities[ip] ?? 1]),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBindingMetaChange(
|
|
||||||
index: number,
|
|
||||||
ip: string,
|
|
||||||
meta: { weight?: number; priority?: number },
|
|
||||||
) {
|
|
||||||
setBindings((current) =>
|
|
||||||
current.map((item, i) => {
|
|
||||||
if (i !== index) return item
|
|
||||||
const weights = { ...item.target_ip_weights }
|
|
||||||
const priorities = { ...item.target_ip_priorities }
|
|
||||||
if (meta.weight !== undefined) weights[ip] = meta.weight
|
|
||||||
if (meta.priority !== undefined) priorities[ip] = meta.priority
|
|
||||||
return { ...item, target_ip_weights: weights, target_ip_priorities: priorities }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBindingHealthChange(index: number, next: LbAndHealthConfig) {
|
|
||||||
setBindings((current) =>
|
|
||||||
current.map((item, i) =>
|
|
||||||
i === index
|
|
||||||
? {
|
|
||||||
...item,
|
|
||||||
lb_mode: next.lb_mode,
|
|
||||||
health: {
|
|
||||||
enabled: next.enabled,
|
|
||||||
type: next.type,
|
|
||||||
port: next.port,
|
|
||||||
path: next.path,
|
|
||||||
expected_status: next.expected_status,
|
|
||||||
interval_sec: next.interval_sec,
|
|
||||||
timeout_ms: next.timeout_ms,
|
|
||||||
verify_tls: next.verify_tls,
|
|
||||||
provider: next.provider ?? 'local',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: item,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveServiceGroupId(): number | null {
|
function resolveServiceGroupId(): number | null {
|
||||||
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
|
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncCommonDomain(current: ServiceBindingDraft[]): ServiceBindingDraft[] {
|
|
||||||
const trimmed = commonFqdn.trim()
|
|
||||||
if (!trimmed) return current
|
|
||||||
if (current.length === 0) {
|
|
||||||
const draft = emptyBindingDraft(trimmed)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...draft,
|
|
||||||
target_ips: ips,
|
|
||||||
target_ip_weights: Object.fromEntries(ips.map((ip) => [ip, 1])),
|
|
||||||
target_ip_priorities: Object.fromEntries(ips.map((ip) => [ip, 1])),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
return current.map((item, index) => {
|
|
||||||
if (index !== 0) return item
|
|
||||||
const next = { ...item, fqdn: trimmed }
|
|
||||||
if (
|
|
||||||
next.record_type === 'A' &&
|
|
||||||
next.target_ips.length === 0 &&
|
|
||||||
ips.length > 0
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...next,
|
|
||||||
target_ips: ips,
|
|
||||||
target_ip_weights: Object.fromEntries(
|
|
||||||
ips.map((ip) => [ip, item.target_ip_weights[ip] ?? 1]),
|
|
||||||
),
|
|
||||||
target_ip_priorities: Object.fromEntries(
|
|
||||||
ips.map((ip) => [ip, item.target_ip_priorities[ip] ?? 1]),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
const syncedBindings = syncCommonDomain(bindings)
|
const ips = address.nodes.map((node) => node.ip)
|
||||||
const domains = buildDomainsPayload(syncedBindings)
|
const domains = toDomainsPayload(address, {
|
||||||
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
|
lb_mode: lbMode,
|
||||||
|
health,
|
||||||
|
})
|
||||||
|
const normalizedFqdns = domains.map((item) => item.fqdn.trim().toLowerCase())
|
||||||
const hasDuplicateFqdn =
|
const hasDuplicateFqdn =
|
||||||
new Set(normalizedFqdns).size !== normalizedFqdns.length
|
new Set(normalizedFqdns).size !== normalizedFqdns.length
|
||||||
if (hasDuplicateFqdn) {
|
if (hasDuplicateFqdn) {
|
||||||
toast.error('Укажите уникальные FQDN — дубликаты привязок недопустимы')
|
toast.error('Укажите уникальные FQDN — дубликаты привязок недопустимы')
|
||||||
setActiveTab('bindings')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const groupId = resolveServiceGroupId()
|
const groupId = resolveServiceGroupId()
|
||||||
@@ -443,6 +210,7 @@ export function ServiceEditSheet({
|
|||||||
const canSubmit = isCreate
|
const canSubmit = isCreate
|
||||||
? name.trim().length > 0 && slug.trim().length > 0
|
? name.trim().length > 0 && slug.trim().length > 0
|
||||||
: Boolean(service)
|
: Boolean(service)
|
||||||
|
const addressResetKey = `${mode}-${service?.id ?? 'new'}-${open ? 'open' : 'closed'}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
@@ -450,28 +218,16 @@ export function ServiceEditSheet({
|
|||||||
<SheetHeader className="shrink-0 border-b pb-4">
|
<SheetHeader className="shrink-0 border-b pb-4">
|
||||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Общий домен и IP задаются у сервиса. Дополнительные FQDN — на вкладке
|
Общий домен и пул IP — в одном блоке. У каждого адреса можно указать
|
||||||
привязок; зона определяется из FQDN автоматически.
|
свой доп. FQDN.
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-4">
|
||||||
<CountedLineTabs
|
<section className="flex flex-col gap-3">
|
||||||
tabs={[
|
<h3 className="text-sm font-medium">Сервис</h3>
|
||||||
{ id: 'general', label: 'Основное' },
|
<FieldGroup className="flex flex-col gap-3">
|
||||||
{
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
id: 'bindings',
|
|
||||||
label: 'Привязки',
|
|
||||||
count: bindings.length > 0 ? bindings.length : undefined,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={setActiveTab}
|
|
||||||
className="flex w-full flex-col gap-4"
|
|
||||||
listClassName="mb-0 w-full"
|
|
||||||
>
|
|
||||||
<TabsContent value="general" className="flex flex-col gap-4">
|
|
||||||
<FieldGroup className="flex flex-col gap-4">
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
|
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
@@ -490,6 +246,7 @@ export function ServiceEditSheet({
|
|||||||
onChange={(e) => setSlug(e.target.value)}
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
</div>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
|
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -509,192 +266,24 @@ export function ServiceEditSheet({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="edit-service-common-domain">
|
|
||||||
Общий домен (FQDN)
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="edit-service-common-domain"
|
|
||||||
className="font-mono"
|
|
||||||
value={commonFqdn}
|
|
||||||
placeholder={
|
|
||||||
zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'
|
|
||||||
}
|
|
||||||
onChange={(e) => handleCommonFqdnChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
|
||||||
<TaggedInput
|
|
||||||
id="edit-service-ips"
|
|
||||||
value={ips}
|
|
||||||
onChange={setIps}
|
|
||||||
placeholder="192.168.1.1"
|
|
||||||
validate={isValidIpv4}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</TabsContent>
|
</section>
|
||||||
|
|
||||||
<TabsContent value="bindings" className="flex flex-col gap-4">
|
<ServiceAddressBlock
|
||||||
<div className="flex items-center justify-between gap-2">
|
key={addressResetKey}
|
||||||
<p className="text-sm text-muted-foreground">
|
value={address}
|
||||||
Несколько FQDN в разных зонах → IP или CNAME для DNS Cloudflare
|
onChange={setAddress}
|
||||||
</p>
|
zoneHints={zoneHints}
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
/>
|
||||||
<PlusIcon data-icon="inline-start" />
|
|
||||||
Добавить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{bindings.length === 0 ? (
|
<section className="flex flex-col gap-3">
|
||||||
<EmptyState
|
<h3 className="text-sm font-medium">Health check</h3>
|
||||||
icon={Link2Icon}
|
|
||||||
title="Нет привязок"
|
|
||||||
description="Необязательно. Можно добавить несколько FQDN: api.ivx.su и www.other.su — зоны определятся автоматически."
|
|
||||||
centered={false}
|
|
||||||
action={
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
|
||||||
<PlusIcon data-icon="inline-start" />
|
|
||||||
Добавить привязку
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ItemGroup className="gap-2">
|
|
||||||
{bindings.map((binding, index) => {
|
|
||||||
const showLbBlock =
|
|
||||||
(binding.record_type === 'A' && binding.target_ips.length > 0) ||
|
|
||||||
(binding.record_type === 'CNAME' && binding.target_cname.trim().length > 0)
|
|
||||||
const showMeta =
|
|
||||||
binding.record_type === 'A' &&
|
|
||||||
binding.target_ips.length > 1 &&
|
|
||||||
binding.lb_mode !== 'round_robin'
|
|
||||||
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
|
|
||||||
return (
|
|
||||||
<Item key={`binding-${index}`} variant="outline" className="items-stretch">
|
|
||||||
<ItemContent className="w-full flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
Привязка {index + 1}
|
|
||||||
</span>
|
|
||||||
{parsedZone ? (
|
|
||||||
<Badge variant="outline" size="xs" className="font-mono">
|
|
||||||
{parsedZone.zoneName}
|
|
||||||
</Badge>
|
|
||||||
) : binding.fqdn.trim() ? (
|
|
||||||
<Badge variant="warning-light" size="xs">
|
|
||||||
зона не найдена
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="shrink-0"
|
|
||||||
aria-label="Удалить привязку"
|
|
||||||
onClick={() => handleRemoveBinding(index)}
|
|
||||||
>
|
|
||||||
<Trash2Icon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Field className="min-w-0">
|
|
||||||
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id={`binding-fqdn-${index}`}
|
|
||||||
className="font-mono"
|
|
||||||
value={binding.fqdn}
|
|
||||||
onChange={(event) =>
|
|
||||||
handleFqdnChange(index, event.target.value)
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel>
|
|
||||||
<Select
|
|
||||||
items={[
|
|
||||||
{ label: 'A (IP)', value: 'A' },
|
|
||||||
{ label: 'CNAME', value: 'CNAME' },
|
|
||||||
]}
|
|
||||||
value={binding.record_type}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
handleRecordTypeChange(index, (value ?? 'A') as 'A' | 'CNAME')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={`binding-type-${index}`} className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="A">A (IP)</SelectItem>
|
|
||||||
<SelectItem value="CNAME">CNAME</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
{binding.record_type === 'CNAME' ? (
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={`binding-cname-${index}`}>
|
|
||||||
CNAME-цель
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id={`binding-cname-${index}`}
|
|
||||||
value={binding.target_cname}
|
|
||||||
placeholder="mmsk.rkns.top"
|
|
||||||
onChange={(event) => handleCnameChange(index, event.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
) : (
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
|
|
||||||
<ServiceBindingIpInput
|
|
||||||
id={`binding-ip-${index}`}
|
|
||||||
value={binding.target_ips}
|
|
||||||
pool={ips}
|
|
||||||
onChange={(targetIps) => handleIpsChange(index, targetIps)}
|
|
||||||
showMeta={showLbBlock && showMeta}
|
|
||||||
weights={binding.target_ip_weights}
|
|
||||||
priorities={binding.target_ip_priorities}
|
|
||||||
onMetaChange={(ip, meta) =>
|
|
||||||
handleBindingMetaChange(index, ip, meta)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showLbBlock ? (
|
|
||||||
<HealthCheckConfigFields
|
<HealthCheckConfigFields
|
||||||
value={{
|
idPrefix="service-health"
|
||||||
lb_mode: binding.lb_mode,
|
value={primaryHealthValue}
|
||||||
enabled: binding.health.enabled,
|
onChange={handlePrimaryHealthChange}
|
||||||
type: binding.health.type,
|
|
||||||
port: binding.health.port,
|
|
||||||
path: binding.health.path,
|
|
||||||
expected_status: binding.health.expected_status,
|
|
||||||
interval_sec: binding.health.interval_sec,
|
|
||||||
timeout_ms: binding.health.timeout_ms,
|
|
||||||
verify_tls: binding.health.verify_tls,
|
|
||||||
provider: binding.health.provider ?? 'local',
|
|
||||||
}}
|
|
||||||
onChange={(next) => handleBindingHealthChange(index, next)}
|
|
||||||
lbModeLabel="Режим балансировки"
|
|
||||||
showLbMode={
|
|
||||||
binding.record_type === 'A' && binding.target_ips.length > 1
|
|
||||||
}
|
|
||||||
idPrefix={`binding-${index}-health`}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
</section>
|
||||||
</ItemContent>
|
|
||||||
</Item>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ItemGroup>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</CountedLineTabs>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
|
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
|
||||||
|
|||||||
@@ -26,9 +26,11 @@ interface ServiceCatalogSectionProps {
|
|||||||
group: ServiceGroupView | null
|
group: ServiceGroupView | null
|
||||||
services: ServiceView[]
|
services: ServiceView[]
|
||||||
togglingId: number | null
|
togglingId: number | null
|
||||||
|
togglingIp: { serviceId: number; ip: string } | null
|
||||||
onEditService: (service: ServiceView) => void
|
onEditService: (service: ServiceView) => void
|
||||||
onDeleteService: (service: ServiceView) => void
|
onDeleteService: (service: ServiceView) => void
|
||||||
onToggleService: (serviceId: number, enabled: boolean) => void
|
onToggleService: (serviceId: number, enabled: boolean) => void
|
||||||
|
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
|
||||||
onEditGroup: (group: ServiceGroupView) => void
|
onEditGroup: (group: ServiceGroupView) => void
|
||||||
onDeleteGroup: (group: ServiceGroupView) => void
|
onDeleteGroup: (group: ServiceGroupView) => void
|
||||||
onAddServiceToGroup: (groupId: number | null) => void
|
onAddServiceToGroup: (groupId: number | null) => void
|
||||||
@@ -38,9 +40,11 @@ export function ServiceCatalogSection({
|
|||||||
group,
|
group,
|
||||||
services,
|
services,
|
||||||
togglingId,
|
togglingId,
|
||||||
|
togglingIp,
|
||||||
onEditService,
|
onEditService,
|
||||||
onDeleteService,
|
onDeleteService,
|
||||||
onToggleService,
|
onToggleService,
|
||||||
|
onToggleServiceIp,
|
||||||
onEditGroup,
|
onEditGroup,
|
||||||
onDeleteGroup,
|
onDeleteGroup,
|
||||||
onAddServiceToGroup,
|
onAddServiceToGroup,
|
||||||
@@ -50,7 +54,10 @@ export function ServiceCatalogSection({
|
|||||||
const groupId = group?.id ?? null
|
const groupId = group?.id ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex w-full flex-col gap-2" aria-labelledby={`group-${groupId ?? 'none'}`}>
|
<section
|
||||||
|
className="@container flex w-full flex-col gap-2"
|
||||||
|
aria-labelledby={`group-${groupId ?? 'none'}`}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
<IconTile
|
<IconTile
|
||||||
@@ -139,15 +146,19 @@ export function ServiceCatalogSection({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="grid grid-cols-1 gap-2 @xl:grid-cols-2 @4xl:grid-cols-3">
|
||||||
{services.map((service) => (
|
{services.map((service) => (
|
||||||
<ServiceUnitCard
|
<ServiceUnitCard
|
||||||
key={service.id}
|
key={service.id}
|
||||||
service={service}
|
service={service}
|
||||||
togglingId={togglingId}
|
togglingId={togglingId}
|
||||||
|
togglingIp={
|
||||||
|
togglingIp?.serviceId === service.id ? togglingIp.ip : null
|
||||||
|
}
|
||||||
onEditService={onEditService}
|
onEditService={onEditService}
|
||||||
onDeleteService={onDeleteService}
|
onDeleteService={onDeleteService}
|
||||||
onToggleService={onToggleService}
|
onToggleService={onToggleService}
|
||||||
|
onToggleServiceIp={onToggleServiceIp}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,22 @@
|
|||||||
import { CheckIcon, CopyIcon } from 'lucide-react'
|
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
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 {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -15,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,
|
||||||
@@ -119,6 +132,16 @@ const VISIBLE_IP_LIMIT = 6
|
|||||||
|
|
||||||
interface ServiceIpListProps {
|
interface ServiceIpListProps {
|
||||||
ips: string[]
|
ips: string[]
|
||||||
|
ipHealth?: ServiceView['ip_health']
|
||||||
|
ipEnabled?: Record<string, boolean>
|
||||||
|
togglingIp?: string | null
|
||||||
|
ipToggleDisabled?: boolean
|
||||||
|
onToggleIp?: (ip: string, enabled: boolean) => void
|
||||||
|
/** Invisible icon-sm slot so IP Switch lines up with the card overflow menu. */
|
||||||
|
alignWithMenu?: boolean
|
||||||
|
lbMode?: ServiceView['lb_mode']
|
||||||
|
activeIps?: string[]
|
||||||
|
ipWeights?: Record<string, number>
|
||||||
className?: string
|
className?: string
|
||||||
emptyLabel?: string
|
emptyLabel?: string
|
||||||
copyable?: boolean
|
copyable?: boolean
|
||||||
@@ -127,6 +150,15 @@ interface ServiceIpListProps {
|
|||||||
|
|
||||||
export function ServiceIpList({
|
export function ServiceIpList({
|
||||||
ips,
|
ips,
|
||||||
|
ipHealth = [],
|
||||||
|
ipEnabled = {},
|
||||||
|
togglingIp = null,
|
||||||
|
ipToggleDisabled = false,
|
||||||
|
onToggleIp,
|
||||||
|
alignWithMenu = false,
|
||||||
|
lbMode,
|
||||||
|
activeIps = [],
|
||||||
|
ipWeights = {},
|
||||||
className,
|
className,
|
||||||
emptyLabel = 'Нет IP',
|
emptyLabel = 'Нет IP',
|
||||||
copyable = false,
|
copyable = false,
|
||||||
@@ -140,20 +172,88 @@ export function ServiceIpList({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const visible = ips.slice(0, VISIBLE_IP_LIMIT)
|
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
|
||||||
|
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
|
||||||
const extraCount = ips.length - visible.length
|
const extraCount = ips.length - visible.length
|
||||||
const copyValue = ips.join('\n')
|
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 items-center gap-1.5', className)}>
|
<ItemGroup className={cn('gap-1', className)}>
|
||||||
|
{visible.map((ip) => {
|
||||||
|
const health = healthByIp.get(ip)
|
||||||
|
const enabled = ipEnabled[ip] !== false
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
key={ip}
|
||||||
|
size="sm"
|
||||||
|
className="w-full min-w-0 flex-nowrap border-0 p-0"
|
||||||
|
>
|
||||||
|
<ItemMedia>
|
||||||
|
<HealthCheckBadge
|
||||||
|
status={health?.status ?? 'unknown'}
|
||||||
|
latencyMs={health?.latency_ms}
|
||||||
|
lastCheckedAt={health?.last_checked_at}
|
||||||
|
lastError={health?.last_error}
|
||||||
|
colo={health?.colo}
|
||||||
|
provider={health?.provider}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</ItemMedia>
|
||||||
|
<ItemContent className="min-w-0 gap-0">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
<TruncatedText
|
<TruncatedText
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground min-w-0 font-mono text-xs',
|
'min-w-0 font-mono text-xs',
|
||||||
|
enabled
|
||||||
|
? 'text-muted-foreground'
|
||||||
|
: 'text-muted-foreground/60',
|
||||||
textClassName,
|
textClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{visible.join(' · ')}
|
{ip}
|
||||||
</TruncatedText>
|
</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}
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
{extraCount > 0 ? (
|
{extraCount > 0 ? (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -162,7 +262,7 @@ export function ServiceIpList({
|
|||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="shrink-0 tabular-nums"
|
className="w-fit shrink-0 tabular-nums"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -170,7 +270,7 @@ export function ServiceIpList({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-xs">
|
<TooltipContent className="max-w-xs">
|
||||||
<ul className="flex flex-col gap-0.5 font-mono text-xs">
|
<ul className="flex flex-col gap-0.5 font-mono text-xs">
|
||||||
{ips.map((ip) => (
|
{ips.slice(VISIBLE_IP_LIMIT).map((ip) => (
|
||||||
<li key={ip}>{ip}</li>
|
<li key={ip}>{ip}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -178,7 +278,6 @@ export function ServiceIpList({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : null}
|
) : null}
|
||||||
{copyable ? <CopyFqdnButton value={copyValue} /> : null}
|
</ItemGroup>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
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 { HealthCheckBadge } from '@/components/health-check-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'
|
||||||
import { IconTile } from '@/components/reui/icon-tile'
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
import {
|
import {
|
||||||
ServiceFqdnList,
|
CopyFqdnButton,
|
||||||
ServiceIpList,
|
ServiceIpList,
|
||||||
} from '@/components/services/service-fqdn-list'
|
} from '@/components/services/service-fqdn-list'
|
||||||
|
import { serviceDisplayFqdn, 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 {
|
import {
|
||||||
@@ -23,37 +29,117 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@cfdm/ui/components/dropdown-menu'
|
} from '@cfdm/ui/components/dropdown-menu'
|
||||||
import { Separator } from '@cfdm/ui/components/separator'
|
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 {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact service card — settings-8 DNA (Badge + copy + Switch + menu).
|
||||||
|
* Preview: https://reui.io/preview/base/settings-8
|
||||||
|
* Frame: https://reui.io/docs/components/base/frame
|
||||||
|
* IconTile: https://reui.io/docs/components/base/icon-tile
|
||||||
|
* Header fill: FramePanel `bg-muted` (overrides `--frame-panel-bg`; see frame.tsx).
|
||||||
|
*/
|
||||||
|
|
||||||
|
type LbMode = ServiceView['lb_mode']
|
||||||
|
|
||||||
|
const LB_MODE_META: Record<
|
||||||
|
LbMode,
|
||||||
|
{ icon: LucideIcon; className: string; label: string }
|
||||||
|
> = {
|
||||||
|
round_robin: {
|
||||||
|
icon: Repeat2Icon,
|
||||||
|
className: 'text-info',
|
||||||
|
label: 'Round Robin',
|
||||||
|
},
|
||||||
|
failover: {
|
||||||
|
icon: GitForkIcon,
|
||||||
|
className: 'text-warning',
|
||||||
|
label: 'Failover (приоритет)',
|
||||||
|
},
|
||||||
|
weighted: {
|
||||||
|
icon: ScaleIcon,
|
||||||
|
className: 'text-info',
|
||||||
|
label: 'Weighted (веса)',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LbModeTile({ mode }: { mode: LbMode }) {
|
||||||
|
const meta = LB_MODE_META[mode]
|
||||||
|
const Icon = meta.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="xs"
|
||||||
|
className={cn('shrink-0', meta.className)}
|
||||||
|
aria-label={meta.label}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{meta.label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface ServiceUnitCardProps {
|
interface ServiceUnitCardProps {
|
||||||
service: ServiceView
|
service: ServiceView
|
||||||
togglingId: number | null
|
togglingId: number | null
|
||||||
|
togglingIp: string | null
|
||||||
onEditService: (service: ServiceView) => void
|
onEditService: (service: ServiceView) => void
|
||||||
onDeleteService: (service: ServiceView) => void
|
onDeleteService: (service: ServiceView) => void
|
||||||
onToggleService: (serviceId: number, enabled: boolean) => void
|
onToggleService: (serviceId: number, enabled: boolean) => void
|
||||||
|
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceUnitCard({
|
export function ServiceUnitCard({
|
||||||
service,
|
service,
|
||||||
togglingId,
|
togglingId,
|
||||||
|
togglingIp,
|
||||||
onEditService,
|
onEditService,
|
||||||
onDeleteService,
|
onDeleteService,
|
||||||
onToggleService,
|
onToggleService,
|
||||||
|
onToggleServiceIp,
|
||||||
}: ServiceUnitCardProps) {
|
}: ServiceUnitCardProps) {
|
||||||
|
const fqdns = serviceDisplayFqdns(service)
|
||||||
|
const primaryDomain = serviceDisplayFqdn(service)
|
||||||
|
const extraCount = Math.max(0, fqdns.length - 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className="w-full">
|
<Frame stacked spacing="sm" className="h-full min-w-0">
|
||||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
<FramePanel fit className="bg-muted">
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
<Item size="sm" className="w-full min-w-0 flex-nowrap border-0 p-0">
|
||||||
|
<ItemMedia>
|
||||||
<IconTile
|
<IconTile
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
className="size-10.5 text-muted-foreground"
|
size="sm"
|
||||||
|
className="text-foreground"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<ServerIcon />
|
<ServerIcon />
|
||||||
</IconTile>
|
</IconTile>
|
||||||
<div className="flex min-w-0 flex-col gap-px">
|
</ItemMedia>
|
||||||
<FrameTitle className="min-w-0 truncate">
|
<ItemContent className="min-w-0 gap-px">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<FrameTitle className="min-w-0 truncate text-base font-semibold">
|
||||||
<Link
|
<Link
|
||||||
to="/services/$serviceId"
|
to="/services/$serviceId"
|
||||||
params={{ serviceId: String(service.id) }}
|
params={{ serviceId: String(service.id) }}
|
||||||
@@ -62,17 +148,42 @@ export function ServiceUnitCard({
|
|||||||
{service.name}
|
{service.name}
|
||||||
</Link>
|
</Link>
|
||||||
</FrameTitle>
|
</FrameTitle>
|
||||||
<FrameDescription className="truncate font-mono">
|
<LbModeTile mode={service.lb_mode} />
|
||||||
{service.slug}
|
</div>
|
||||||
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
||||||
|
{primaryDomain}
|
||||||
</FrameDescription>
|
</FrameDescription>
|
||||||
</div>
|
{extraCount > 0 ? (
|
||||||
</div>
|
<TooltipProvider>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<Tooltip>
|
||||||
<HealthCheckBadge
|
<TooltipTrigger
|
||||||
status={service.health_status ?? 'unknown'}
|
render={
|
||||||
latencyMs={service.health_latency_ms}
|
<Badge
|
||||||
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
|
className="shrink-0 tabular-nums"
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+{extraCount}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<ul className="flex flex-col gap-0.5 font-mono text-xs">
|
||||||
|
{fqdns.map((fqdn) => (
|
||||||
|
<li key={fqdn}>{fqdn}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : null}
|
||||||
|
{primaryDomain !== '—' ? (
|
||||||
|
<CopyFqdnButton value={fqdns.join('\n')} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ItemContent>
|
||||||
|
<ItemActions className="ml-auto shrink-0 gap-1">
|
||||||
<Switch
|
<Switch
|
||||||
size="sm"
|
size="sm"
|
||||||
checked={service.enabled}
|
checked={service.enabled}
|
||||||
@@ -119,44 +230,27 @@ export function ServiceUnitCard({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</ItemActions>
|
||||||
</FrameHeader>
|
</Item>
|
||||||
|
</FramePanel>
|
||||||
|
|
||||||
<FramePanel className="p-0 shadow-none!">
|
<FramePanel className="flex min-w-0 flex-col">
|
||||||
<Separator />
|
|
||||||
<ServiceLabeledRow label="Общий домен">
|
|
||||||
<ServiceFqdnList
|
|
||||||
copyable
|
|
||||||
service={service}
|
|
||||||
emptyLabel="Не задан"
|
|
||||||
textClassName="text-foreground text-sm"
|
|
||||||
/>
|
|
||||||
</ServiceLabeledRow>
|
|
||||||
<Separator />
|
|
||||||
<ServiceLabeledRow label="IP">
|
|
||||||
<ServiceIpList
|
<ServiceIpList
|
||||||
copyable
|
copyable
|
||||||
|
alignWithMenu
|
||||||
ips={service.ips ?? []}
|
ips={service.ips ?? []}
|
||||||
emptyLabel="Нет IP"
|
ipHealth={service.ip_health ?? []}
|
||||||
textClassName="text-foreground text-sm"
|
ipEnabled={service.ip_enabled ?? {}}
|
||||||
|
ipToggleDisabled={togglingId === service.id}
|
||||||
|
togglingIp={togglingIp}
|
||||||
|
lbMode={service.lb_mode}
|
||||||
|
activeIps={service.active_ips}
|
||||||
|
ipWeights={service.domains[0]?.target_ip_weights}
|
||||||
|
onToggleIp={(ip, enabled) =>
|
||||||
|
onToggleServiceIp(service.id, ip, enabled)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ServiceLabeledRow>
|
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServiceLabeledRow({
|
|
||||||
label,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
children: ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 flex-col gap-1 px-(--frame-panel-header-px) py-(--frame-panel-header-py) sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
|
||||||
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
|
|
||||||
<div className="min-w-0 sm:flex sm:justify-end">{children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { useMemo, useState, type ReactNode } from 'react'
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
FilterIcon,
|
|
||||||
FolderPlusIcon,
|
FolderPlusIcon,
|
||||||
FunnelXIcon,
|
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
SearchIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { Filters, type Filter } from '@/components/reui/filters'
|
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
@@ -16,21 +14,13 @@ import {
|
|||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import {
|
|
||||||
applyFiltersToData,
|
|
||||||
getActiveFilters,
|
|
||||||
} from '@/components/reui-kit/filter-utils'
|
|
||||||
import { ServiceCatalogSection } from '@/components/services/service-catalog-section'
|
import { ServiceCatalogSection } from '@/components/services/service-catalog-section'
|
||||||
import {
|
import {
|
||||||
SERVICE_TABS,
|
|
||||||
createDefaultServiceFilters,
|
|
||||||
serviceFilterFieldValue,
|
|
||||||
serviceTabFilter,
|
serviceTabFilter,
|
||||||
useServiceFilterFields,
|
|
||||||
type ServiceCatalogRow,
|
type ServiceCatalogRow,
|
||||||
} from '@/components/columns/services-columns'
|
} from '@/components/columns/services-columns'
|
||||||
|
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
||||||
import type {
|
import type {
|
||||||
ServiceGroupView,
|
ServiceGroupView,
|
||||||
ServiceGroupsResponse,
|
ServiceGroupsResponse,
|
||||||
@@ -43,23 +33,29 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@cfdm/ui/components/dropdown-menu'
|
} from '@cfdm/ui/components/dropdown-menu'
|
||||||
import { Separator } from '@cfdm/ui/components/separator'
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupText,
|
||||||
|
} from '@cfdm/ui/components/input-group'
|
||||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
|
|
||||||
const HEALTH_TABS = [
|
|
||||||
{ id: 'health-ok', label: 'OK' },
|
|
||||||
{ id: 'health-slow', label: 'Slow' },
|
|
||||||
{ id: 'health-down', label: 'Down' },
|
|
||||||
{ id: 'health-unknown', label: '—' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const ALL_TABS = [...SERVICE_TABS, ...HEALTH_TABS] as const
|
|
||||||
|
|
||||||
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
|
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
|
||||||
if (domainId == null) return true
|
if (domainId == null) return true
|
||||||
return service.domains.some((d) => d.domain_id === domainId)
|
return service.domains.some((d) => d.domain_id === domainId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serviceMatchesQuery(service: ServiceView, query: string) {
|
||||||
|
const needle = query.trim().toLowerCase()
|
||||||
|
if (!needle) return true
|
||||||
|
if (service.name.toLowerCase().includes(needle)) return true
|
||||||
|
if (service.slug.toLowerCase().includes(needle)) return true
|
||||||
|
return serviceDisplayFqdns(service).some((fqdn) =>
|
||||||
|
fqdn.toLowerCase().includes(needle),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function toCatalogRow(
|
function toCatalogRow(
|
||||||
service: ServiceView,
|
service: ServiceView,
|
||||||
groupId: number | null,
|
groupId: number | null,
|
||||||
@@ -77,18 +73,6 @@ function toCatalogRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function catalogTabFilter(row: ServiceCatalogRow, tabId: string) {
|
|
||||||
if (tabId.startsWith('health-')) {
|
|
||||||
const status = row.service.health_status ?? 'unknown'
|
|
||||||
if (tabId === 'health-ok') return status === 'up'
|
|
||||||
if (tabId === 'health-slow') return status === 'degraded'
|
|
||||||
if (tabId === 'health-down') return status === 'down'
|
|
||||||
if (tabId === 'health-unknown') return status === 'unknown'
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return serviceTabFilter(row, tabId)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupUnitData {
|
interface GroupUnitData {
|
||||||
id: string
|
id: string
|
||||||
group: ServiceGroupView | null
|
group: ServiceGroupView | null
|
||||||
@@ -176,11 +160,13 @@ interface ServicesGroupedCatalogProps {
|
|||||||
primaryAction?: ReactNode
|
primaryAction?: ReactNode
|
||||||
hideHeader?: boolean
|
hideHeader?: boolean
|
||||||
togglingId: number | null
|
togglingId: number | null
|
||||||
|
togglingIp: { serviceId: number; ip: string } | null
|
||||||
activeTab?: string
|
activeTab?: string
|
||||||
onTabChange?: (tabId: string) => void
|
onTabChange?: (tabId: string) => void
|
||||||
onEditService: (service: ServiceView) => void
|
onEditService: (service: ServiceView) => void
|
||||||
onDeleteService: (service: ServiceView) => void
|
onDeleteService: (service: ServiceView) => void
|
||||||
onToggleService: (serviceId: number, enabled: boolean) => void
|
onToggleService: (serviceId: number, enabled: boolean) => void
|
||||||
|
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
|
||||||
onEditGroup: (group: ServiceGroupView) => void
|
onEditGroup: (group: ServiceGroupView) => void
|
||||||
onDeleteGroup: (group: ServiceGroupView) => void
|
onDeleteGroup: (group: ServiceGroupView) => void
|
||||||
onAddServiceToGroup: (groupId: number | null) => void
|
onAddServiceToGroup: (groupId: number | null) => void
|
||||||
@@ -195,23 +181,18 @@ export function ServicesGroupedCatalog({
|
|||||||
primaryAction,
|
primaryAction,
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
togglingId,
|
togglingId,
|
||||||
activeTab: controlledTab,
|
togglingIp,
|
||||||
onTabChange,
|
activeTab = 'all',
|
||||||
onEditService,
|
onEditService,
|
||||||
onDeleteService,
|
onDeleteService,
|
||||||
onToggleService,
|
onToggleService,
|
||||||
|
onToggleServiceIp,
|
||||||
onEditGroup,
|
onEditGroup,
|
||||||
onDeleteGroup,
|
onDeleteGroup,
|
||||||
onAddServiceToGroup,
|
onAddServiceToGroup,
|
||||||
emptyAction,
|
emptyAction,
|
||||||
}: ServicesGroupedCatalogProps) {
|
}: ServicesGroupedCatalogProps) {
|
||||||
const [internalTab, setInternalTab] = useState('all')
|
const [query, setQuery] = useState('')
|
||||||
const tab = controlledTab ?? internalTab
|
|
||||||
const setTab = onTabChange ?? setInternalTab
|
|
||||||
const [filters, setFilters] = useState<Filter[]>(() =>
|
|
||||||
createDefaultServiceFilters(),
|
|
||||||
)
|
|
||||||
const filterFields = useServiceFilterFields()
|
|
||||||
|
|
||||||
const flatRows = useMemo(() => {
|
const flatRows = useMemo(() => {
|
||||||
const rows: ServiceCatalogRow[] = []
|
const rows: ServiceCatalogRow[] = []
|
||||||
@@ -228,24 +209,16 @@ export function ServicesGroupedCatalog({
|
|||||||
return rows
|
return rows
|
||||||
}, [data, domainId])
|
}, [data, domainId])
|
||||||
|
|
||||||
const tabCounts = useMemo(() => {
|
|
||||||
const counts: Record<string, number> = {}
|
|
||||||
for (const t of ALL_TABS) {
|
|
||||||
counts[t.id] = flatRows.filter((row) => catalogTabFilter(row, t.id)).length
|
|
||||||
}
|
|
||||||
return counts
|
|
||||||
}, [flatRows])
|
|
||||||
|
|
||||||
const filteredIds = useMemo(() => {
|
const filteredIds = useMemo(() => {
|
||||||
const afterTab = flatRows.filter((row) => catalogTabFilter(row, tab))
|
const afterTab = flatRows.filter((row) => serviceTabFilter(row, activeTab))
|
||||||
const afterFilters = applyFiltersToData(afterTab, filters, (item, field) =>
|
const afterQuery = afterTab.filter((row) =>
|
||||||
serviceFilterFieldValue(item, field),
|
serviceMatchesQuery(row.service, query),
|
||||||
)
|
)
|
||||||
return new Set(afterFilters.map((r) => r.id))
|
return new Set(afterQuery.map((r) => r.id))
|
||||||
}, [flatRows, tab, filters])
|
}, [flatRows, activeTab, query])
|
||||||
|
|
||||||
const showEmptyGroups =
|
const showEmptyGroups =
|
||||||
tab === 'all' && domainId == null && getActiveFilters(filters).length === 0
|
activeTab === 'all' && domainId == null && query.trim().length === 0
|
||||||
|
|
||||||
const units = useMemo(
|
const units = useMemo(
|
||||||
() => buildGroupUnits(data, filteredIds, domainId, showEmptyGroups),
|
() => buildGroupUnits(data, filteredIds, domainId, showEmptyGroups),
|
||||||
@@ -260,7 +233,7 @@ export function ServicesGroupedCatalog({
|
|||||||
<Skeleton className="h-4 w-72" />
|
<Skeleton className="h-4 w-72" />
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
<FramePanel className="flex flex-col gap-3 p-4">
|
<FramePanel className="flex flex-col gap-3 p-4">
|
||||||
<Skeleton className="h-9 w-full max-w-md" />
|
<Skeleton className="h-8 w-full max-w-md" />
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<Skeleton key={i} className="h-36 w-full rounded-xl" />
|
<Skeleton key={i} className="h-36 w-full rounded-xl" />
|
||||||
))}
|
))}
|
||||||
@@ -307,79 +280,39 @@ export function ServicesGroupedCatalog({
|
|||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<FramePanel className="p-0 shadow-none!">
|
<FramePanel className="flex flex-col gap-4">
|
||||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
<InputGroup className="max-w-md">
|
||||||
<CountedLineTabs
|
<InputGroupAddon>
|
||||||
tabs={ALL_TABS.map((t) => ({
|
<InputGroupText>
|
||||||
id: t.id,
|
<SearchIcon aria-hidden />
|
||||||
label: t.label,
|
</InputGroupText>
|
||||||
count: tabCounts[t.id] ?? 0,
|
</InputGroupAddon>
|
||||||
}))}
|
<InputGroupInput
|
||||||
value={tab}
|
value={query}
|
||||||
onValueChange={setTab}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Поиск по названию"
|
||||||
|
aria-label="Поиск по названию"
|
||||||
/>
|
/>
|
||||||
</div>
|
</InputGroup>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
|
||||||
<Filters
|
|
||||||
filters={filters}
|
|
||||||
fields={filterFields}
|
|
||||||
onChange={setFilters}
|
|
||||||
size="default"
|
|
||||||
trigger={
|
|
||||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
|
||||||
<FilterIcon className="size-4" aria-hidden />
|
|
||||||
Фильтры
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setTab('all')
|
|
||||||
setFilters(createDefaultServiceFilters())
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FunnelXIcon className="size-4" aria-hidden />
|
|
||||||
Сбросить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{units.length === 0 ? (
|
{units.length === 0 ? (
|
||||||
<div className="p-6">
|
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Нет совпадений"
|
title="Нет совпадений"
|
||||||
description="Измените фильтры или вкладку."
|
description="Измените запрос поиска."
|
||||||
action={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setTab('all')
|
|
||||||
setFilters(createDefaultServiceFilters())
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сбросить
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
<div className="flex flex-col gap-6">
|
||||||
{units.map((unit) => (
|
{units.map((unit) => (
|
||||||
<ServiceCatalogSection
|
<ServiceCatalogSection
|
||||||
key={unit.id}
|
key={unit.id}
|
||||||
group={unit.group}
|
group={unit.group}
|
||||||
services={unit.services}
|
services={unit.services}
|
||||||
togglingId={togglingId}
|
togglingId={togglingId}
|
||||||
|
togglingIp={togglingIp}
|
||||||
onEditService={onEditService}
|
onEditService={onEditService}
|
||||||
onDeleteService={onDeleteService}
|
onDeleteService={onDeleteService}
|
||||||
onToggleService={onToggleService}
|
onToggleService={onToggleService}
|
||||||
|
onToggleServiceIp={onToggleServiceIp}
|
||||||
onEditGroup={onEditGroup}
|
onEditGroup={onEditGroup}
|
||||||
onDeleteGroup={onDeleteGroup}
|
onDeleteGroup={onDeleteGroup}
|
||||||
onAddServiceToGroup={onAddServiceToGroup}
|
onAddServiceToGroup={onAddServiceToGroup}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { dedupeBreadcrumbs, getBreadcrumbs } from './breadcrumbs'
|
||||||
|
|
||||||
|
describe('getBreadcrumbs', () => {
|
||||||
|
it('keeps a single Настройки parent plus the active section', () => {
|
||||||
|
expect(getBreadcrumbs('/settings/appearance')).toEqual([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Внешний вид', href: '/settings/appearance' },
|
||||||
|
])
|
||||||
|
expect(getBreadcrumbs('/settings/health')).toEqual([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Health-check', href: '/settings/health' },
|
||||||
|
])
|
||||||
|
expect(getBreadcrumbs('/settings/integrations')).toEqual([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Интеграции', href: '/settings/integrations' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not reuse the section href for the parent crumb', () => {
|
||||||
|
const crumbs = getBreadcrumbs('/settings/appearance')
|
||||||
|
const hrefs = crumbs.map((crumb) => crumb.href)
|
||||||
|
expect(new Set(hrefs).size).toBe(hrefs.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dedupeBreadcrumbs', () => {
|
||||||
|
it('collapses stacked identical labels from repeated navigations', () => {
|
||||||
|
expect(
|
||||||
|
dedupeBreadcrumbs([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Настройки', href: '/settings/appearance' },
|
||||||
|
{ label: 'Внешний вид', href: '/settings/appearance' },
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
{ label: 'Внешний вид', href: '/settings/appearance' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
export interface BreadcrumbCrumb {
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const routeTitles: Record<string, string> = {
|
||||||
|
'/': 'Панель управления',
|
||||||
|
'/domains': 'Домены',
|
||||||
|
'/groups': 'Группы доменов',
|
||||||
|
'/services': 'Сервисы',
|
||||||
|
'/certificates': 'Сертификаты',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SETTINGS_SECTIONS: Record<string, string> = {
|
||||||
|
'/settings/appearance': 'Внешний вид',
|
||||||
|
'/settings/health': 'Health-check',
|
||||||
|
'/settings/integrations': 'Интеграции',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop consecutive repeats so «Настройки» does not stack after tab switches. */
|
||||||
|
export function dedupeBreadcrumbs(crumbs: BreadcrumbCrumb[]): BreadcrumbCrumb[] {
|
||||||
|
const out: BreadcrumbCrumb[] = []
|
||||||
|
for (const crumb of crumbs) {
|
||||||
|
const prev = out.at(-1)
|
||||||
|
if (prev && prev.label === crumb.label) continue
|
||||||
|
out.push(crumb)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBreadcrumbs(
|
||||||
|
pathname: string,
|
||||||
|
dynamicLabels: Record<string, string> = {},
|
||||||
|
): BreadcrumbCrumb[] {
|
||||||
|
const path = pathname.replace(/\/+$/, '') || '/'
|
||||||
|
|
||||||
|
if (path === '/') {
|
||||||
|
return [{ label: 'Панель управления', href: '/' }]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.match(/^\/services\/\d+$/)) {
|
||||||
|
return [
|
||||||
|
{ label: 'Сервисы', href: '/services' },
|
||||||
|
{ label: dynamicLabels[path] ?? 'Сервис', href: path },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.match(/^\/groups\/\d+$/)) {
|
||||||
|
return [
|
||||||
|
{ label: 'Группы доменов', href: '/groups' },
|
||||||
|
{ label: dynamicLabels[path] ?? 'Группа', href: path },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.match(/^\/domains\/\d+\/dns$/)) {
|
||||||
|
const domainId = path.split('/')[2]
|
||||||
|
const domainPath = `/domains/${domainId}`
|
||||||
|
return [
|
||||||
|
{ label: 'Домены', href: '/domains' },
|
||||||
|
{ label: dynamicLabels[domainPath] ?? 'Домен', href: domainPath },
|
||||||
|
{ label: 'DNS', href: path },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.match(/^\/domains\/\d+$/)) {
|
||||||
|
return [
|
||||||
|
{ label: 'Домены', href: '/domains' },
|
||||||
|
{ label: dynamicLabels[path] ?? 'Обзор домена', href: path },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/settings' || path.startsWith('/settings/')) {
|
||||||
|
const section = SETTINGS_SECTIONS[path]
|
||||||
|
return dedupeBreadcrumbs([
|
||||||
|
{ label: 'Настройки', href: '/settings' },
|
||||||
|
...(section ? [{ label: section, href: path }] : []),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = routeTitles[path]
|
||||||
|
if (title) {
|
||||||
|
return [{ label: title, href: path }]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ label: 'Панель управления', href: '/' }]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -36,6 +36,9 @@ export const serviceGroupSchema = z.object({
|
|||||||
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),
|
||||||
|
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||||
|
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||||
|
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
updated_at: z.string(),
|
updated_at: z.string(),
|
||||||
})
|
})
|
||||||
@@ -76,6 +79,10 @@ export const serviceDomainBindingSchema = z
|
|||||||
health_check_interval_sec: z.number().default(30),
|
health_check_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),
|
||||||
|
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||||
|
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||||
|
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||||
|
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
|
||||||
sync_status: z.string().nullable().default(null),
|
sync_status: z.string().nullable().default(null),
|
||||||
})
|
})
|
||||||
.transform((binding) => ({
|
.transform((binding) => ({
|
||||||
@@ -94,6 +101,30 @@ export const serviceDomainBindingSchema = z
|
|||||||
: (binding.record_type ?? 'A'),
|
: (binding.record_type ?? 'A'),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
export const serviceIpHealthSchema = z.object({
|
||||||
|
ip: z.string(),
|
||||||
|
status: z.enum(['up', 'down', 'degraded', 'unknown']),
|
||||||
|
latency_ms: z.number().nullable(),
|
||||||
|
last_checked_at: z.string().nullable().optional(),
|
||||||
|
last_error: z.string().nullable().optional(),
|
||||||
|
provider: z.enum(['local', 'cloudflare', 'globalping', 'aggregate']).optional(),
|
||||||
|
colo: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const healthProbeLogSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
scope: z.string(),
|
||||||
|
ref_id: z.number(),
|
||||||
|
ip: z.string(),
|
||||||
|
provider: z.enum(['local', 'cloudflare', 'globalping']),
|
||||||
|
status: z.enum(['up', 'down', 'degraded', 'unknown']),
|
||||||
|
ok: z.coerce.boolean(),
|
||||||
|
latency_ms: z.number().nullable(),
|
||||||
|
colo: z.string().nullable(),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
checked_at: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
export const serviceViewSchema = serviceSchema.extend({
|
export const serviceViewSchema = serviceSchema.extend({
|
||||||
subdomain: z.string().default(''),
|
subdomain: z.string().default(''),
|
||||||
enabled: z.coerce.boolean().default(false),
|
enabled: z.coerce.boolean().default(false),
|
||||||
@@ -101,6 +132,10 @@ export const serviceViewSchema = serviceSchema.extend({
|
|||||||
domains: z.array(serviceDomainBindingSchema).default([]),
|
domains: z.array(serviceDomainBindingSchema).default([]),
|
||||||
health_status: z.enum(['up', 'down', 'degraded', 'unknown']).default('unknown'),
|
health_status: z.enum(['up', 'down', 'degraded', 'unknown']).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_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({
|
||||||
@@ -160,6 +195,10 @@ 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),
|
||||||
|
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||||
|
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||||
|
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||||
|
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
|
||||||
sync_status: z.string().nullable().default(null),
|
sync_status: z.string().nullable().default(null),
|
||||||
created_at: z.string(),
|
created_at: z.string(),
|
||||||
updated_at: z.string(),
|
updated_at: z.string(),
|
||||||
@@ -197,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(),
|
||||||
@@ -206,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>
|
||||||
@@ -219,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, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
|
||||||
@@ -236,14 +291,17 @@ const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted'])
|
|||||||
const healthCheckTypeSchema = z.enum(['tcp', 'http', 'ping', 'dns'])
|
const healthCheckTypeSchema = z.enum(['tcp', 'http', 'ping', 'dns'])
|
||||||
|
|
||||||
const healthCheckConfigFields = {
|
const healthCheckConfigFields = {
|
||||||
health_check_enabled: z.boolean().optional(),
|
health_check_enabled: z.coerce.boolean().optional(),
|
||||||
health_check_type: healthCheckTypeSchema.optional(),
|
health_check_type: healthCheckTypeSchema.optional(),
|
||||||
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
|
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
|
||||||
health_check_path: z.string().nullable().optional(),
|
health_check_path: z.string().nullable().optional(),
|
||||||
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
|
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
|
||||||
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
|
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
|
||||||
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
|
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
|
||||||
health_check_verify_tls: z.boolean().optional(),
|
health_check_verify_tls: z.coerce.boolean().optional(),
|
||||||
|
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).optional(),
|
||||||
|
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).optional(),
|
||||||
|
health_check_aggregate: z.enum(['any', 'all', 'majority']).optional(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const serviceDomainInputSchema = z
|
const serviceDomainInputSchema = z
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_BINDING_HEALTH,
|
||||||
|
addAddressNode,
|
||||||
|
emptyAddressBlock,
|
||||||
|
emptyBindingDraft,
|
||||||
|
hydrateAddressBlock,
|
||||||
|
removeAddressNode,
|
||||||
|
toAddressBindings,
|
||||||
|
toDomainsPayload,
|
||||||
|
type ServiceBindingDraft,
|
||||||
|
} from '@/lib/service-address'
|
||||||
|
|
||||||
|
const primaryMeta = {
|
||||||
|
lb_mode: 'round_robin' as const,
|
||||||
|
health: { ...DEFAULT_BINDING_HEALTH, enabled: true },
|
||||||
|
}
|
||||||
|
|
||||||
|
function aRecord(
|
||||||
|
fqdn: string,
|
||||||
|
target_ips: string[],
|
||||||
|
overrides: Partial<ServiceBindingDraft> = {},
|
||||||
|
): ServiceBindingDraft {
|
||||||
|
return {
|
||||||
|
...emptyBindingDraft(fqdn),
|
||||||
|
record_type: 'A',
|
||||||
|
target_ips,
|
||||||
|
target_ip_weights: Object.fromEntries(target_ips.map((ip) => [ip, 1])),
|
||||||
|
target_ip_priorities: Object.fromEntries(target_ips.map((ip) => [ip, 1])),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('hydrateAddressBlock', () => {
|
||||||
|
it('схлопывает extra A с одним IP пула в extraFqdn узла (MSK Macloud)', () => {
|
||||||
|
const drafts = [
|
||||||
|
aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']),
|
||||||
|
aRecord('msk.rutg.rkns.top', ['93.115.203.183']),
|
||||||
|
]
|
||||||
|
|
||||||
|
const state = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
|
||||||
|
|
||||||
|
expect(state.commonFqdn).toBe('rutg.rkns.top')
|
||||||
|
expect(state.nodes).toEqual([
|
||||||
|
{ ip: '93.115.203.183', extraFqdn: 'msk.rutg.rkns.top' },
|
||||||
|
{ ip: '185.244.181.61', extraFqdn: '' },
|
||||||
|
])
|
||||||
|
expect(state.otherBindings).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('не схлопывает CNAME и A на несколько IP', () => {
|
||||||
|
const cname: ServiceBindingDraft = {
|
||||||
|
...emptyBindingDraft('alias.rkns.top'),
|
||||||
|
record_type: 'CNAME',
|
||||||
|
target_cname: 'rutg.rkns.top',
|
||||||
|
}
|
||||||
|
const drafts = [
|
||||||
|
aRecord('rutg.rkns.top', ['1.1.1.1', '2.2.2.2']),
|
||||||
|
aRecord('both.rkns.top', ['1.1.1.1', '2.2.2.2']),
|
||||||
|
cname,
|
||||||
|
]
|
||||||
|
|
||||||
|
const state = hydrateAddressBlock(drafts, ['1.1.1.1', '2.2.2.2'])
|
||||||
|
|
||||||
|
expect(state.nodes.every((node) => node.extraFqdn === '')).toBe(true)
|
||||||
|
expect(state.otherBindings.map((item) => item.fqdn)).toEqual([
|
||||||
|
'both.rkns.top',
|
||||||
|
'alias.rkns.top',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('кладёт extra A с IP вне пула в otherBindings', () => {
|
||||||
|
const drafts = [
|
||||||
|
aRecord('gw.example.com', ['10.0.0.1']),
|
||||||
|
aRecord('edge.example.com', ['8.8.8.8']),
|
||||||
|
]
|
||||||
|
|
||||||
|
const state = hydrateAddressBlock(drafts, ['10.0.0.1'])
|
||||||
|
|
||||||
|
expect(state.nodes).toEqual([{ ip: '10.0.0.1', extraFqdn: '' }])
|
||||||
|
expect(state.otherBindings).toHaveLength(1)
|
||||||
|
expect(state.otherBindings[0]?.fqdn).toBe('edge.example.com')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('toDomainsPayload', () => {
|
||||||
|
it('собирает primary на весь пул и extra binding на один IP', () => {
|
||||||
|
const drafts = [
|
||||||
|
aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']),
|
||||||
|
aRecord('msk.rutg.rkns.top', ['93.115.203.183']),
|
||||||
|
]
|
||||||
|
const state = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
|
||||||
|
const payload = toDomainsPayload(state, primaryMeta)
|
||||||
|
|
||||||
|
expect(payload).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
fqdn: 'rutg.rkns.top',
|
||||||
|
target_ips: ['93.115.203.183', '185.244.181.61'],
|
||||||
|
health_check_enabled: true,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
fqdn: 'msk.rutg.rkns.top',
|
||||||
|
target_ips: ['93.115.203.183'],
|
||||||
|
health_check_enabled: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('круг hydrate → payload → hydrate сохраняет extra FQDN', () => {
|
||||||
|
const drafts = [
|
||||||
|
aRecord('rutg.rkns.top', ['93.115.203.183', '185.244.181.61']),
|
||||||
|
aRecord('msk.rutg.rkns.top', ['93.115.203.183']),
|
||||||
|
]
|
||||||
|
const first = hydrateAddressBlock(drafts, ['93.115.203.183', '185.244.181.61'])
|
||||||
|
const rebound = toAddressBindings(first, primaryMeta)
|
||||||
|
const second = hydrateAddressBlock(rebound, rebound[0]?.target_ips ?? [])
|
||||||
|
|
||||||
|
expect(second.commonFqdn).toBe(first.commonFqdn)
|
||||||
|
expect(second.nodes).toEqual(first.nodes)
|
||||||
|
expect(second.otherBindings).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('removeAddressNode', () => {
|
||||||
|
it('удаляет extra FQDN узла и IP из other A-bindings', () => {
|
||||||
|
const state = hydrateAddressBlock(
|
||||||
|
[
|
||||||
|
aRecord('gw.example.com', ['10.0.0.1', '10.0.0.2']),
|
||||||
|
aRecord('msk.example.com', ['10.0.0.1']),
|
||||||
|
aRecord('pair.example.com', ['10.0.0.1', '10.0.0.2']),
|
||||||
|
],
|
||||||
|
['10.0.0.1', '10.0.0.2'],
|
||||||
|
)
|
||||||
|
|
||||||
|
const next = removeAddressNode(state, '10.0.0.1')
|
||||||
|
|
||||||
|
expect(next.nodes).toEqual([{ ip: '10.0.0.2', extraFqdn: '' }])
|
||||||
|
expect(next.otherBindings).toHaveLength(1)
|
||||||
|
expect(next.otherBindings[0]?.target_ips).toEqual(['10.0.0.2'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('addAddressNode', () => {
|
||||||
|
it('не добавляет дубликат IP', () => {
|
||||||
|
const withIp = addAddressNode(
|
||||||
|
{ ...emptyAddressBlock(), nodes: [{ ip: '1.1.1.1', extraFqdn: '' }] },
|
||||||
|
'1.1.1.1',
|
||||||
|
)
|
||||||
|
expect(withIp.nodes).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CNAME / otherBindings', () => {
|
||||||
|
it('сохраняет CNAME в otherBindings при круге hydrate → payload', () => {
|
||||||
|
const cname: ServiceBindingDraft = {
|
||||||
|
...emptyBindingDraft('alias.rkns.top'),
|
||||||
|
record_type: 'CNAME',
|
||||||
|
target_cname: 'rutg.rkns.top',
|
||||||
|
}
|
||||||
|
const drafts = [
|
||||||
|
aRecord('rutg.rkns.top', ['1.1.1.1']),
|
||||||
|
aRecord('msk.rkns.top', ['1.1.1.1']),
|
||||||
|
cname,
|
||||||
|
]
|
||||||
|
const state = hydrateAddressBlock(drafts, ['1.1.1.1'])
|
||||||
|
expect(state.nodes[0]?.extraFqdn).toBe('msk.rkns.top')
|
||||||
|
expect(state.otherBindings).toHaveLength(1)
|
||||||
|
|
||||||
|
const payload = toDomainsPayload(state, primaryMeta)
|
||||||
|
expect(payload.map((item) => item.fqdn)).toEqual([
|
||||||
|
'rutg.rkns.top',
|
||||||
|
'msk.rkns.top',
|
||||||
|
'alias.rkns.top',
|
||||||
|
])
|
||||||
|
expect(payload[2]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
fqdn: 'alias.rkns.top',
|
||||||
|
target_cname: 'rutg.rkns.top',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import { parseHealthProviders } from '@cfdm/shared'
|
||||||
|
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
|
||||||
|
import { bindingToFqdn } from '@/lib/parse-fqdn'
|
||||||
|
import type { ServiceView } from '@/lib/schemas'
|
||||||
|
|
||||||
|
export type AddressLbMode = 'round_robin' | 'failover' | 'weighted'
|
||||||
|
export type AddressHealthCheckType = 'tcp' | 'http'
|
||||||
|
|
||||||
|
export interface BindingHealthConfig {
|
||||||
|
enabled: boolean
|
||||||
|
type: AddressHealthCheckType
|
||||||
|
port: number | null
|
||||||
|
path: string | null
|
||||||
|
expected_status: number | null
|
||||||
|
interval_sec: number
|
||||||
|
timeout_ms: number
|
||||||
|
verify_tls: boolean
|
||||||
|
provider: HealthCheckProvider
|
||||||
|
providers: HealthCheckProvider[]
|
||||||
|
aggregate: HealthCheckAggregate
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceBindingDraft {
|
||||||
|
fqdn: string
|
||||||
|
record_type: 'A' | 'CNAME'
|
||||||
|
target_ips: string[]
|
||||||
|
target_cname: string
|
||||||
|
lb_mode: AddressLbMode
|
||||||
|
health: BindingHealthConfig
|
||||||
|
target_ip_weights: Record<string, number>
|
||||||
|
target_ip_priorities: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressNode {
|
||||||
|
ip: string
|
||||||
|
extraFqdn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressBlockState {
|
||||||
|
commonFqdn: string
|
||||||
|
nodes: AddressNode[]
|
||||||
|
otherBindings: ServiceBindingDraft[]
|
||||||
|
target_ip_weights: Record<string, number>
|
||||||
|
target_ip_priorities: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddressPrimaryMeta {
|
||||||
|
lb_mode: AddressLbMode
|
||||||
|
health: BindingHealthConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_BINDING_HEALTH: BindingHealthConfig = {
|
||||||
|
enabled: false,
|
||||||
|
type: 'tcp',
|
||||||
|
port: null,
|
||||||
|
path: null,
|
||||||
|
expected_status: null,
|
||||||
|
interval_sec: 30,
|
||||||
|
timeout_ms: 3000,
|
||||||
|
verify_tls: false,
|
||||||
|
provider: 'local',
|
||||||
|
providers: ['local'],
|
||||||
|
aggregate: 'majority',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
|
||||||
|
return {
|
||||||
|
fqdn,
|
||||||
|
record_type: 'A',
|
||||||
|
target_ips: [],
|
||||||
|
target_cname: '',
|
||||||
|
lb_mode: 'round_robin',
|
||||||
|
health: { ...DEFAULT_BINDING_HEALTH },
|
||||||
|
target_ip_weights: {},
|
||||||
|
target_ip_priorities: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyAddressBlock(): AddressBlockState {
|
||||||
|
return {
|
||||||
|
commonFqdn: '',
|
||||||
|
nodes: [],
|
||||||
|
otherBindings: [],
|
||||||
|
target_ip_weights: {},
|
||||||
|
target_ip_priorities: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withPoolIps(
|
||||||
|
draft: ServiceBindingDraft,
|
||||||
|
pool: string[],
|
||||||
|
): ServiceBindingDraft {
|
||||||
|
if (draft.record_type !== 'A' || draft.target_ips.length > 0 || pool.length === 0) {
|
||||||
|
return draft
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
target_ips: pool,
|
||||||
|
target_ip_weights: Object.fromEntries(pool.map((ip) => [ip, draft.target_ip_weights[ip] ?? 1])),
|
||||||
|
target_ip_priorities: Object.fromEntries(
|
||||||
|
pool.map((ip) => [ip, draft.target_ip_priorities[ip] ?? 1]),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueIps(...lists: string[][]): string[] {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const out: string[] = []
|
||||||
|
for (const list of lists) {
|
||||||
|
for (const ip of list) {
|
||||||
|
const trimmed = ip.trim()
|
||||||
|
if (!trimmed || seen.has(trimmed)) continue
|
||||||
|
seen.add(trimmed)
|
||||||
|
out.push(trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function omitKey(record: Record<string, number>, key: string): Record<string, number> {
|
||||||
|
const next = { ...record }
|
||||||
|
delete next[key]
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||||
|
return (service.domains ?? []).map((binding) => ({
|
||||||
|
fqdn: bindingToFqdn(binding),
|
||||||
|
record_type: binding.record_type ?? (binding.target_cname ? 'CNAME' : 'A'),
|
||||||
|
target_ips: binding.target_ips ?? [],
|
||||||
|
target_cname: binding.target_cname ?? '',
|
||||||
|
lb_mode: binding.lb_mode,
|
||||||
|
health: {
|
||||||
|
enabled: Boolean(binding.health_check_enabled),
|
||||||
|
type: binding.health_check_type === 'http' ? 'http' : 'tcp',
|
||||||
|
port: binding.health_check_port,
|
||||||
|
path: binding.health_check_path,
|
||||||
|
expected_status: binding.health_check_expected_status,
|
||||||
|
interval_sec: binding.health_check_interval_sec,
|
||||||
|
timeout_ms: binding.health_check_timeout_ms,
|
||||||
|
verify_tls: Boolean(binding.health_check_verify_tls),
|
||||||
|
provider: binding.health_check_provider ?? 'local',
|
||||||
|
providers: parseHealthProviders(
|
||||||
|
binding.health_check_providers,
|
||||||
|
binding.health_check_provider ?? 'local',
|
||||||
|
),
|
||||||
|
aggregate: binding.health_check_aggregate ?? 'majority',
|
||||||
|
},
|
||||||
|
target_ip_weights: binding.target_ip_weights ?? {},
|
||||||
|
target_ip_priorities: binding.target_ip_priorities ?? {},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function canCollapseToNode(
|
||||||
|
extra: ServiceBindingDraft,
|
||||||
|
pool: Set<string>,
|
||||||
|
claimed: Set<string>,
|
||||||
|
): string | null {
|
||||||
|
if (extra.record_type !== 'A') return null
|
||||||
|
if (extra.target_ips.length !== 1) return null
|
||||||
|
const ip = extra.target_ips[0]?.trim() ?? ''
|
||||||
|
if (!ip || !pool.has(ip) || claimed.has(ip)) return null
|
||||||
|
if (!extra.fqdn.trim()) return null
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hydrateAddressBlock(
|
||||||
|
drafts: ServiceBindingDraft[],
|
||||||
|
pool: string[] = [],
|
||||||
|
): AddressBlockState {
|
||||||
|
const primary = drafts[0]
|
||||||
|
const ips = uniqueIps(pool, primary?.target_ips ?? [])
|
||||||
|
const poolSet = new Set(ips)
|
||||||
|
const claimed = new Set<string>()
|
||||||
|
const extraByIp = new Map<string, string>()
|
||||||
|
const otherBindings: ServiceBindingDraft[] = []
|
||||||
|
|
||||||
|
for (const extra of drafts.slice(1)) {
|
||||||
|
const ip = canCollapseToNode(extra, poolSet, claimed)
|
||||||
|
if (ip) {
|
||||||
|
claimed.add(ip)
|
||||||
|
extraByIp.set(ip, extra.fqdn)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
otherBindings.push(extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
commonFqdn: primary?.fqdn ?? '',
|
||||||
|
nodes: ips.map((ip) => ({
|
||||||
|
ip,
|
||||||
|
extraFqdn: extraByIp.get(ip) ?? '',
|
||||||
|
})),
|
||||||
|
otherBindings,
|
||||||
|
target_ip_weights: { ...(primary?.target_ip_weights ?? {}) },
|
||||||
|
target_ip_priorities: { ...(primary?.target_ip_priorities ?? {}) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneIpFromBindings(
|
||||||
|
bindings: ServiceBindingDraft[],
|
||||||
|
ip: string,
|
||||||
|
): ServiceBindingDraft[] {
|
||||||
|
return bindings.flatMap((binding) => {
|
||||||
|
if (binding.record_type !== 'A') return [binding]
|
||||||
|
if (!binding.target_ips.includes(ip)) return [binding]
|
||||||
|
const target_ips = binding.target_ips.filter((item) => item !== ip)
|
||||||
|
if (target_ips.length === 0) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...binding,
|
||||||
|
target_ips,
|
||||||
|
target_ip_weights: omitKey(binding.target_ip_weights, ip),
|
||||||
|
target_ip_priorities: omitKey(binding.target_ip_priorities, ip),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeAddressNode(state: AddressBlockState, ip: string): AddressBlockState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
nodes: state.nodes.filter((node) => node.ip !== ip),
|
||||||
|
otherBindings: pruneIpFromBindings(state.otherBindings, ip),
|
||||||
|
target_ip_weights: omitKey(state.target_ip_weights, ip),
|
||||||
|
target_ip_priorities: omitKey(state.target_ip_priorities, ip),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addAddressNode(state: AddressBlockState, ip: string): AddressBlockState {
|
||||||
|
const trimmed = ip.trim()
|
||||||
|
if (!trimmed || state.nodes.some((node) => node.ip === trimmed)) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
nodes: [...state.nodes, { ip: trimmed, extraFqdn: '' }],
|
||||||
|
target_ip_weights: { ...state.target_ip_weights, [trimmed]: 1 },
|
||||||
|
target_ip_priorities: { ...state.target_ip_priorities, [trimmed]: 1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAddressBindings(
|
||||||
|
state: AddressBlockState,
|
||||||
|
primary: AddressPrimaryMeta,
|
||||||
|
): ServiceBindingDraft[] {
|
||||||
|
const ips = state.nodes.map((node) => node.ip)
|
||||||
|
const weights = Object.fromEntries(
|
||||||
|
ips.map((ip) => [ip, state.target_ip_weights[ip] ?? 1]),
|
||||||
|
)
|
||||||
|
const priorities = Object.fromEntries(
|
||||||
|
ips.map((ip) => [ip, state.target_ip_priorities[ip] ?? 1]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const drafts: ServiceBindingDraft[] = []
|
||||||
|
const hasPrimary = Boolean(state.commonFqdn.trim()) || ips.length > 0
|
||||||
|
if (hasPrimary) {
|
||||||
|
drafts.push({
|
||||||
|
fqdn: state.commonFqdn,
|
||||||
|
record_type: 'A',
|
||||||
|
target_ips: ips,
|
||||||
|
target_cname: '',
|
||||||
|
lb_mode: primary.lb_mode,
|
||||||
|
health: { ...primary.health },
|
||||||
|
target_ip_weights: weights,
|
||||||
|
target_ip_priorities: priorities,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of state.nodes) {
|
||||||
|
const extraFqdn = node.extraFqdn.trim()
|
||||||
|
if (!extraFqdn) continue
|
||||||
|
drafts.push({
|
||||||
|
fqdn: extraFqdn,
|
||||||
|
record_type: 'A',
|
||||||
|
target_ips: [node.ip],
|
||||||
|
target_cname: '',
|
||||||
|
lb_mode: primary.lb_mode,
|
||||||
|
health: { ...primary.health },
|
||||||
|
target_ip_weights: { [node.ip]: weights[node.ip] ?? 1 },
|
||||||
|
target_ip_priorities: { [node.ip]: priorities[node.ip] ?? 1 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
drafts.push(...state.otherBindings)
|
||||||
|
return drafts
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||||
|
return bindings
|
||||||
|
.filter((binding) => {
|
||||||
|
if (!binding.fqdn.trim()) return false
|
||||||
|
if (binding.record_type === 'CNAME') return Boolean(binding.target_cname.trim())
|
||||||
|
return binding.target_ips.length > 0
|
||||||
|
})
|
||||||
|
.map((binding) =>
|
||||||
|
binding.record_type === 'CNAME'
|
||||||
|
? {
|
||||||
|
fqdn: binding.fqdn.trim(),
|
||||||
|
target_cname: binding.target_cname.trim(),
|
||||||
|
lb_mode: binding.lb_mode,
|
||||||
|
health_check_enabled: binding.health.enabled,
|
||||||
|
health_check_type: binding.health.type,
|
||||||
|
health_check_port: binding.health.port,
|
||||||
|
health_check_path: binding.health.path,
|
||||||
|
health_check_expected_status: binding.health.expected_status,
|
||||||
|
health_check_interval_sec: binding.health.interval_sec,
|
||||||
|
health_check_timeout_ms: binding.health.timeout_ms,
|
||||||
|
health_check_verify_tls: binding.health.verify_tls,
|
||||||
|
health_check_provider: binding.health.provider,
|
||||||
|
health_check_providers: binding.health.providers,
|
||||||
|
health_check_aggregate: binding.health.aggregate,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
fqdn: binding.fqdn.trim(),
|
||||||
|
target_ips: binding.target_ips,
|
||||||
|
target_ip_weights: binding.target_ip_weights,
|
||||||
|
target_ip_priorities: binding.target_ip_priorities,
|
||||||
|
lb_mode: binding.lb_mode,
|
||||||
|
health_check_enabled: binding.health.enabled,
|
||||||
|
health_check_type: binding.health.type,
|
||||||
|
health_check_port: binding.health.port,
|
||||||
|
health_check_path: binding.health.path,
|
||||||
|
health_check_expected_status: binding.health.expected_status,
|
||||||
|
health_check_interval_sec: binding.health.interval_sec,
|
||||||
|
health_check_timeout_ms: binding.health.timeout_ms,
|
||||||
|
health_check_verify_tls: binding.health.verify_tls,
|
||||||
|
health_check_provider: binding.health.provider,
|
||||||
|
health_check_providers: binding.health.providers,
|
||||||
|
health_check_aggregate: binding.health.aggregate,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDomainsPayload(
|
||||||
|
state: AddressBlockState,
|
||||||
|
primary: AddressPrimaryMeta,
|
||||||
|
) {
|
||||||
|
return buildDomainsPayload(toAddressBindings(state, primary))
|
||||||
|
}
|
||||||
@@ -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`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 {
|
import {
|
||||||
|
healthProbeLogSchema,
|
||||||
serviceBindingSchema,
|
serviceBindingSchema,
|
||||||
serviceGroupsResponseSchema,
|
serviceGroupsResponseSchema,
|
||||||
serviceViewSchema,
|
serviceViewSchema,
|
||||||
@@ -87,8 +88,28 @@ export async function deleteServiceBinding(id: number) {
|
|||||||
export const serviceDetailKeys = {
|
export const serviceDetailKeys = {
|
||||||
overview: (id: number) => [...serviceKeys.all, id, 'overview'] as const,
|
overview: (id: number) => [...serviceKeys.all, id, 'overview'] as const,
|
||||||
nodes: (id: number) => [...serviceKeys.all, id, 'nodes'] as const,
|
nodes: (id: number) => [...serviceKeys.all, id, 'nodes'] as const,
|
||||||
|
healthLog: (id: number) => [...serviceKeys.all, id, 'health-log'] as const,
|
||||||
|
view: (id: number) => [...serviceKeys.all, id, 'view'] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const serviceViewQueryOptions = (id: number) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: serviceDetailKeys.view(id),
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await api.get<unknown>(`/api/v1/services/${id}`)
|
||||||
|
return serviceViewSchema.parse(data)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const serviceHealthLogQueryOptions = (id: number) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: serviceDetailKeys.healthLog(id),
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await api.get<unknown>(`/api/v1/services/${id}/health-log`)
|
||||||
|
return z.object({ items: z.array(healthProbeLogSchema) }).parse(data)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const serviceOverviewQueryOptions = (id: number) =>
|
export const serviceOverviewQueryOptions = (id: number) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: serviceDetailKeys.overview(id),
|
queryKey: serviceDetailKeys.overview(id),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/i
|
|||||||
import { Route as AuthServicesIndexRouteImport } from './routes/_auth/services/index'
|
import { Route as AuthServicesIndexRouteImport } from './routes/_auth/services/index'
|
||||||
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
|
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
|
||||||
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
|
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
|
||||||
|
import { Route as AuthSettingsHealthRouteImport } from './routes/_auth/settings/health'
|
||||||
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
|
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
|
||||||
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
|
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
|
||||||
import { Route as AuthServicesServiceIdRouteRouteImport } from './routes/_auth/services/$serviceId/route'
|
import { Route as AuthServicesServiceIdRouteRouteImport } from './routes/_auth/services/$serviceId/route'
|
||||||
@@ -86,6 +87,11 @@ const AuthSettingsIntegrationsRoute =
|
|||||||
path: '/integrations',
|
path: '/integrations',
|
||||||
getParentRoute: () => AuthSettingsRouteRoute,
|
getParentRoute: () => AuthSettingsRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthSettingsHealthRoute = AuthSettingsHealthRouteImport.update({
|
||||||
|
id: '/health',
|
||||||
|
path: '/health',
|
||||||
|
getParentRoute: () => AuthSettingsRouteRoute,
|
||||||
|
} as any)
|
||||||
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
|
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
|
||||||
id: '/appearance',
|
id: '/appearance',
|
||||||
path: '/appearance',
|
path: '/appearance',
|
||||||
@@ -154,6 +160,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
|
'/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
|
||||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||||
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||||
|
'/settings/health': typeof AuthSettingsHealthRoute
|
||||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
'/domains/': typeof AuthDomainsIndexRoute
|
'/domains/': typeof AuthDomainsIndexRoute
|
||||||
'/services/': typeof AuthServicesIndexRoute
|
'/services/': typeof AuthServicesIndexRoute
|
||||||
@@ -174,6 +181,7 @@ export interface FileRoutesByTo {
|
|||||||
'/': typeof AuthIndexRoute
|
'/': typeof AuthIndexRoute
|
||||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||||
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||||
|
'/settings/health': typeof AuthSettingsHealthRoute
|
||||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
'/domains': typeof AuthDomainsIndexRoute
|
'/domains': typeof AuthDomainsIndexRoute
|
||||||
'/services': typeof AuthServicesIndexRoute
|
'/services': typeof AuthServicesIndexRoute
|
||||||
@@ -198,6 +206,7 @@ export interface FileRoutesById {
|
|||||||
'/_auth/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
|
'/_auth/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
|
||||||
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||||
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
|
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||||
|
'/_auth/settings/health': typeof AuthSettingsHealthRoute
|
||||||
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
'/_auth/domains/': typeof AuthDomainsIndexRoute
|
'/_auth/domains/': typeof AuthDomainsIndexRoute
|
||||||
'/_auth/services/': typeof AuthServicesIndexRoute
|
'/_auth/services/': typeof AuthServicesIndexRoute
|
||||||
@@ -222,6 +231,7 @@ export interface FileRouteTypes {
|
|||||||
| '/services/$serviceId'
|
| '/services/$serviceId'
|
||||||
| '/groups/$groupId'
|
| '/groups/$groupId'
|
||||||
| '/settings/appearance'
|
| '/settings/appearance'
|
||||||
|
| '/settings/health'
|
||||||
| '/settings/integrations'
|
| '/settings/integrations'
|
||||||
| '/domains/'
|
| '/domains/'
|
||||||
| '/services/'
|
| '/services/'
|
||||||
@@ -242,6 +252,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/groups/$groupId'
|
| '/groups/$groupId'
|
||||||
| '/settings/appearance'
|
| '/settings/appearance'
|
||||||
|
| '/settings/health'
|
||||||
| '/settings/integrations'
|
| '/settings/integrations'
|
||||||
| '/domains'
|
| '/domains'
|
||||||
| '/services'
|
| '/services'
|
||||||
@@ -265,6 +276,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_auth/services/$serviceId'
|
| '/_auth/services/$serviceId'
|
||||||
| '/_auth/groups/$groupId'
|
| '/_auth/groups/$groupId'
|
||||||
| '/_auth/settings/appearance'
|
| '/_auth/settings/appearance'
|
||||||
|
| '/_auth/settings/health'
|
||||||
| '/_auth/settings/integrations'
|
| '/_auth/settings/integrations'
|
||||||
| '/_auth/domains/'
|
| '/_auth/domains/'
|
||||||
| '/_auth/services/'
|
| '/_auth/services/'
|
||||||
@@ -363,6 +375,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
|
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
|
||||||
parentRoute: typeof AuthSettingsRouteRoute
|
parentRoute: typeof AuthSettingsRouteRoute
|
||||||
}
|
}
|
||||||
|
'/_auth/settings/health': {
|
||||||
|
id: '/_auth/settings/health'
|
||||||
|
path: '/health'
|
||||||
|
fullPath: '/settings/health'
|
||||||
|
preLoaderRoute: typeof AuthSettingsHealthRouteImport
|
||||||
|
parentRoute: typeof AuthSettingsRouteRoute
|
||||||
|
}
|
||||||
'/_auth/settings/appearance': {
|
'/_auth/settings/appearance': {
|
||||||
id: '/_auth/settings/appearance'
|
id: '/_auth/settings/appearance'
|
||||||
path: '/appearance'
|
path: '/appearance'
|
||||||
@@ -438,12 +457,14 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
interface AuthSettingsRouteRouteChildren {
|
interface AuthSettingsRouteRouteChildren {
|
||||||
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
|
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
|
||||||
|
AuthSettingsHealthRoute: typeof AuthSettingsHealthRoute
|
||||||
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
|
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
|
||||||
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
|
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
|
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
|
||||||
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
|
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
|
||||||
|
AuthSettingsHealthRoute: AuthSettingsHealthRoute,
|
||||||
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
|
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
|
||||||
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
|
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 { DetailPanel } from '@/components/reui-kit'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
|
||||||
import { FormSheet } from '@/components/form-sheet'
|
|
||||||
import { FormFieldSimple } from '@/components/form-field'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
|
||||||
import { Input } from '@cfdm/ui/components/input'
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
|
||||||
import { HealthProviderToggle } from '@/components/health-check-config-fields'
|
|
||||||
import {
|
|
||||||
createOriginHealthCheck,
|
|
||||||
listOriginHealthChecks,
|
|
||||||
serviceOverviewQueryOptions,
|
|
||||||
} from '@/queries'
|
|
||||||
|
|
||||||
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,
|
||||||
})
|
})
|
||||||
|
|
||||||
export function ServiceHealthPage() {
|
|
||||||
const { serviceId } = Route.useParams()
|
|
||||||
const id = Number(serviceId)
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const overview = useQuery(serviceOverviewQueryOptions(id))
|
|
||||||
const checksQuery = useQuery({
|
|
||||||
queryKey: ['health-checks'],
|
|
||||||
queryFn: listOriginHealthChecks,
|
|
||||||
})
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const form = useForm<{
|
|
||||||
name: string
|
|
||||||
provider: 'local' | 'cloudflare'
|
|
||||||
protocol: string
|
|
||||||
}>({
|
|
||||||
defaultValues: { name: '', provider: 'local', protocol: 'tcp' },
|
|
||||||
})
|
|
||||||
const provider = form.watch('provider')
|
|
||||||
const checks = (checksQuery.data ?? []) as Array<{
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
provider: string
|
|
||||||
protocol: string
|
|
||||||
}>
|
|
||||||
|
|
||||||
const createMut = useMutation({
|
|
||||||
mutationFn: (values: { name: string; provider: 'local' | 'cloudflare'; protocol: string }) =>
|
|
||||||
createOriginHealthCheck({
|
|
||||||
name: values.name,
|
|
||||||
provider: values.provider,
|
|
||||||
protocol: values.protocol,
|
|
||||||
}),
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast.success('Health check сохранён')
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['health-checks'] })
|
|
||||||
setOpen(false)
|
|
||||||
},
|
},
|
||||||
onError: (e: unknown) =>
|
component: () => null,
|
||||||
toast.error(
|
|
||||||
e instanceof Error
|
|
||||||
? e.message
|
|
||||||
: 'Cloudflare Health Checks недоступны для этой зоны',
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
void overview
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DetailPanel>
|
|
||||||
<DetailPanel.Header
|
|
||||||
title="Health checks"
|
|
||||||
description="Local TCP/HTTP или официальный Cloudflare Health Checks API."
|
|
||||||
actions={
|
|
||||||
<Button size="sm" onClick={() => setOpen(true)}>
|
|
||||||
Добавить проверку
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{checks.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
title="Нет проверок"
|
|
||||||
description="Локальные пробы уже работают на привязках. Cloudflare Health Checks — опционально."
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{checks.map((check) => (
|
|
||||||
<div
|
|
||||||
key={check.id}
|
|
||||||
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">{check.name}</span>
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{check.provider} · {check.protocol}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<FormSheet
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
title="Health check"
|
|
||||||
description="Поля Cloudflare соответствуют официальному API (address, type, interval, timeout, retries)."
|
|
||||||
form={form}
|
|
||||||
onSubmit={(values) => createMut.mutate(values)}
|
|
||||||
footer={
|
|
||||||
<LoadingButton type="submit" isLoading={createMut.isPending}>
|
|
||||||
Сохранить
|
|
||||||
</LoadingButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FormFieldSimple label="Имя" htmlFor="hc-name">
|
|
||||||
<Input id="hc-name" {...form.register('name')} />
|
|
||||||
</FormFieldSimple>
|
|
||||||
<FormFieldSimple label="Провайдер" htmlFor="hc-provider">
|
|
||||||
<HealthProviderToggle
|
|
||||||
id="hc-provider"
|
|
||||||
value={provider}
|
|
||||||
onChange={(next) => form.setValue('provider', next)}
|
|
||||||
/>
|
|
||||||
</FormFieldSimple>
|
|
||||||
{provider === 'cloudflare' ? (
|
|
||||||
<Alert>
|
|
||||||
<AlertTitle>Cloudflare Health Checks</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Если зона не поддерживает Health Checks, вернётся ошибка плана — останется
|
|
||||||
Local. Workers не используются.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
<FormFieldSimple label="Протокол" htmlFor="hc-protocol">
|
|
||||||
<Input id="hc-protocol" {...form.register('protocol')} placeholder="tcp" />
|
|
||||||
</FormFieldSimple>
|
|
||||||
</FormSheet>
|
|
||||||
</DetailPanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,94 +1,431 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { 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 {
|
||||||
|
KpiStatGrid,
|
||||||
|
ServiceHealthMonitor,
|
||||||
|
} from '@/components/reui-kit'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import {
|
||||||
|
enabledHealthProviders,
|
||||||
|
providerHealthStatuses,
|
||||||
|
} from '@/lib/health-log'
|
||||||
|
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
||||||
|
import {
|
||||||
|
createServiceNode,
|
||||||
|
deleteServiceNode,
|
||||||
|
domainKeys,
|
||||||
|
domainsListQueryOptions,
|
||||||
|
serviceBindingKeys,
|
||||||
|
serviceDetailKeys,
|
||||||
|
serviceGroupKeys,
|
||||||
|
serviceGroupsQueryOptions,
|
||||||
|
serviceHealthLogQueryOptions,
|
||||||
|
serviceKeys,
|
||||||
|
serviceNodesQueryOptions,
|
||||||
|
serviceOverviewQueryOptions,
|
||||||
|
serviceViewQueryOptions,
|
||||||
|
} from '@/queries'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/services/$serviceId/')({
|
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
|
||||||
|
priority: number
|
||||||
|
consecutive_failures: number
|
||||||
|
last_failure_reason: string | null
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
nodes: Array<{ id: number; address: string; health_status: string }>
|
|
||||||
routing_strategy: string
|
|
||||||
active_addresses: string[]
|
|
||||||
} | undefined
|
|
||||||
|
|
||||||
if (!overview) {
|
function ServiceDetailPage() {
|
||||||
|
const { serviceId } = Route.useParams()
|
||||||
|
const id = Number(serviceId)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const viewQuery = useQuery(serviceViewQueryOptions(id))
|
||||||
|
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
|
||||||
|
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
||||||
|
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
|
||||||
|
const groupsQuery = useQuery(serviceGroupsQueryOptions())
|
||||||
|
const domainsQuery = useQuery(domainsListQueryOptions())
|
||||||
|
|
||||||
|
const service = viewQuery.data
|
||||||
|
const overview = overviewQuery.data as OverviewPayload | undefined
|
||||||
|
const logItems = useMemo(
|
||||||
|
() => logQuery.data?.items ?? [],
|
||||||
|
[logQuery.data?.items],
|
||||||
|
)
|
||||||
|
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
|
||||||
|
|
||||||
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [togglingIp, setTogglingIp] = useState<string | null>(null)
|
||||||
|
const [changeIp, setChangeIp] = useState<{
|
||||||
|
bindingId: number
|
||||||
|
ip?: string
|
||||||
|
} | null>(null)
|
||||||
|
const [changeDomain, setChangeDomain] = useState(false)
|
||||||
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
|
const nodeForm = useForm<{ address: string; port: string }>({
|
||||||
|
defaultValues: { address: '', port: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const groups = groupsQuery.data
|
||||||
|
? [...groupsQuery.data.groups]
|
||||||
|
: []
|
||||||
|
|
||||||
|
async function invalidateService() {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ body }: { body: UpdateServiceConfigInput }) =>
|
||||||
|
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateService()
|
||||||
|
setEditOpen(false)
|
||||||
|
toast.success('Сервис сохранён')
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
|
||||||
|
},
|
||||||
|
onSettled: () => setSaving(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => api.delete(`/api/v1/services/${id}`),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateService()
|
||||||
|
toast.success('Сервис удалён')
|
||||||
|
await navigate({ to: '/services' })
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleIpMutation = useMutation({
|
||||||
|
mutationFn: ({ ip, enabled }: { ip: string; enabled: boolean }) =>
|
||||||
|
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, { ip, enabled }),
|
||||||
|
onSuccess: async (_data, { enabled }) => {
|
||||||
|
await invalidateService()
|
||||||
|
toast.success(
|
||||||
|
enabled
|
||||||
|
? 'IP включён и добавлен в DNS-привязки'
|
||||||
|
: 'IP выключен и снят с DNS-привязок',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Не удалось переключить IP')
|
||||||
|
},
|
||||||
|
onSettled: () => setTogglingIp(null),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createNodeMut = useMutation({
|
||||||
|
mutationFn: (values: { address: string; port: string }) =>
|
||||||
|
createServiceNode(id, {
|
||||||
|
address: values.address.trim(),
|
||||||
|
port: values.port ? Number(values.port) : null,
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('Нода добавлена, статус CHECKING')
|
||||||
|
await invalidateService()
|
||||||
|
setAddNodeOpen(false)
|
||||||
|
nodeForm.reset()
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteNodeMut = useMutation({
|
||||||
|
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success('Нода удалена')
|
||||||
|
await invalidateService()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = viewQuery.isLoading || overviewQuery.isLoading
|
||||||
|
const isError = viewQuery.isError || overviewQuery.isError
|
||||||
|
const error = viewQuery.error ?? overviewQuery.error
|
||||||
|
|
||||||
|
const failoverEvents =
|
||||||
|
(nodes.length > 0 ? nodes : (overview?.nodes ?? []))
|
||||||
|
.filter(
|
||||||
|
(node) =>
|
||||||
|
node.health_status === 'unhealthy' ||
|
||||||
|
node.health_status === 'down' ||
|
||||||
|
node.health_status === 'checking',
|
||||||
|
)
|
||||||
|
.map((node) => ({
|
||||||
|
id: node.address,
|
||||||
|
title: `${node.address}: ${node.health_status}`,
|
||||||
|
detail: node.last_failure_reason
|
||||||
|
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
|
||||||
|
: `fail ${node.consecutive_failures}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const enabledProviders = useMemo(
|
||||||
|
() => enabledHealthProviders(service?.domains ?? []),
|
||||||
|
[service],
|
||||||
|
)
|
||||||
|
const providerStatuses = useMemo(
|
||||||
|
() => providerHealthStatuses(logItems, enabledProviders),
|
||||||
|
[logItems, enabledProviders],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<QueryState
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
onRetry={() => {
|
||||||
|
void viewQuery.refetch()
|
||||||
|
void overviewQuery.refetch()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!service ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Сервис не найден"
|
title="Сервис не найден"
|
||||||
description="Вернитесь в каталог и выберите сервис."
|
description="Вернитесь в каталог и выберите сервис."
|
||||||
/>
|
/>
|
||||||
)
|
) : (
|
||||||
}
|
<div className="@container flex w-full flex-col gap-4 md:gap-6">
|
||||||
|
<PageHeader
|
||||||
const nodes = overview.nodes ?? []
|
title={service.name}
|
||||||
const domains = overview.service.domains ?? []
|
description="Domain → Service → Node → Health → Failover"
|
||||||
|
|
||||||
return (
|
|
||||||
<DetailPanel>
|
|
||||||
<DetailPanel.Header
|
|
||||||
title={overview.service.name}
|
|
||||||
description={`Маршрутизация: ${overview.routing_strategy}. Активные IP: ${
|
|
||||||
overview.active_addresses.join(', ') || '—'
|
|
||||||
}`}
|
|
||||||
actions={
|
actions={
|
||||||
<HealthCheckBadge status={overview.service.health_status} />
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<DetailPanel.Metrics
|
|
||||||
|
<KpiStatGrid
|
||||||
cards={[
|
cards={[
|
||||||
{
|
{
|
||||||
id: 'subdomains',
|
id: 'status',
|
||||||
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 />,
|
icon: <ActivityIcon />,
|
||||||
label: 'Пул',
|
label: 'Статус',
|
||||||
description:
|
value: service.health_status === 'up' ? 'OK' : service.health_status,
|
||||||
overview.active_addresses.length > 0
|
variant:
|
||||||
? 'Здоровые адреса участвуют в DNS'
|
service.health_status === 'down'
|
||||||
: 'unknown не попадает в пул, пока не станет healthy',
|
? '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(', ') || 'нет',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{domains.length === 0 && nodes.length === 0 ? (
|
|
||||||
|
<section
|
||||||
|
aria-label="Мониторинг"
|
||||||
|
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||||
|
>
|
||||||
|
<ServiceHealthMonitor
|
||||||
|
items={logItems}
|
||||||
|
enabledProviders={enabledProviders}
|
||||||
|
statuses={providerStatuses}
|
||||||
|
isLoading={logQuery.isLoading}
|
||||||
|
/>
|
||||||
|
<Frame dense spacing="sm" className="min-w-0 w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Failover</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Нездоровые ноды и причины последней ошибки
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel>
|
||||||
|
<FailoverTimeline events={failoverEvents} />
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{service.ips.length === 0 && service.domains.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Пустой сервис"
|
title="Пустой сервис"
|
||||||
description="Добавьте поддомен и ноду, затем настройте health-check."
|
description="Добавьте поддомен и ноду, затем настройте health-check."
|
||||||
stackedIcon
|
stackedIcon
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : (
|
||||||
</DetailPanel>
|
<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,
|
||||||
})
|
})
|
||||||
|
|
||||||
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) =>
|
component: () => null,
|
||||||
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
|
|
||||||
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 />
|
<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,
|
||||||
})
|
})
|
||||||
|
},
|
||||||
export function ServiceSubdomainsPage() {
|
component: () => null,
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -81,6 +81,29 @@ function setServiceEnabled(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCatalogServiceIpEnabled(
|
||||||
|
data: ServiceGroupsResponse,
|
||||||
|
serviceId: number,
|
||||||
|
ip: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): ServiceGroupsResponse {
|
||||||
|
const patch = (service: ServiceView): ServiceView =>
|
||||||
|
service.id === serviceId
|
||||||
|
? {
|
||||||
|
...service,
|
||||||
|
ip_enabled: { ...(service.ip_enabled ?? {}), [ip]: enabled },
|
||||||
|
}
|
||||||
|
: service
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups: data.groups.map((group) => ({
|
||||||
|
...group,
|
||||||
|
services: group.services.map(patch),
|
||||||
|
})),
|
||||||
|
ungrouped: data.ungrouped.map(patch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const GROUP_DOT_COLORS = [
|
const GROUP_DOT_COLORS = [
|
||||||
'bg-chart-1',
|
'bg-chart-1',
|
||||||
'bg-chart-2',
|
'bg-chart-2',
|
||||||
@@ -150,6 +173,10 @@ function ServicesPage() {
|
|||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
|
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
|
||||||
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
||||||
|
const [togglingIp, setTogglingIp] = useState<{
|
||||||
|
serviceId: number
|
||||||
|
ip: string
|
||||||
|
} | null>(null)
|
||||||
const [bulkToggling, setBulkToggling] = useState(false)
|
const [bulkToggling, setBulkToggling] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -364,6 +391,54 @@ function ServicesPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toggleServiceIpMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
ip,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
id: number
|
||||||
|
ip: string
|
||||||
|
enabled: boolean
|
||||||
|
}) =>
|
||||||
|
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, {
|
||||||
|
ip,
|
||||||
|
enabled,
|
||||||
|
}),
|
||||||
|
onMutate: async ({ id, ip, enabled }) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
|
||||||
|
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
|
||||||
|
serviceGroupKeys.all,
|
||||||
|
)
|
||||||
|
if (previous) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
serviceGroupKeys.all,
|
||||||
|
setCatalogServiceIpEnabled(previous, id, ip, enabled),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { previous }
|
||||||
|
},
|
||||||
|
onError: (err, _vars, context) => {
|
||||||
|
if (context?.previous) {
|
||||||
|
queryClient.setQueryData(serviceGroupKeys.all, context.previous)
|
||||||
|
}
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : 'Не удалось переключить IP',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSuccess: (_data, { enabled }) => {
|
||||||
|
toast.success(
|
||||||
|
enabled
|
||||||
|
? 'IP включён и добавлен в DNS-привязки'
|
||||||
|
: 'IP выключен и снят с DNS-привязок',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSettled: async () => {
|
||||||
|
setTogglingIp(null)
|
||||||
|
await invalidateAll()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const deleteGroupMutation = useMutation({
|
const deleteGroupMutation = useMutation({
|
||||||
mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`),
|
mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -398,6 +473,15 @@ function ServicesPage() {
|
|||||||
toggleServiceMutation.mutate({ id: serviceId, enabled })
|
toggleServiceMutation.mutate({ id: serviceId, enabled })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleServiceIpToggle(
|
||||||
|
serviceId: number,
|
||||||
|
ip: string,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
setTogglingIp({ serviceId, ip })
|
||||||
|
toggleServiceIpMutation.mutate({ id: serviceId, ip, enabled })
|
||||||
|
}
|
||||||
|
|
||||||
function handleOpenCreateService(groupId: number | null = null) {
|
function handleOpenCreateService(groupId: number | null = null) {
|
||||||
setDefaultGroupId(groupId)
|
setDefaultGroupId(groupId)
|
||||||
setCreateSheetOpen(true)
|
setCreateSheetOpen(true)
|
||||||
@@ -602,9 +686,11 @@ function ServicesPage() {
|
|||||||
isLoading
|
isLoading
|
||||||
hideHeader
|
hideHeader
|
||||||
togglingId={null}
|
togglingId={null}
|
||||||
|
togglingIp={null}
|
||||||
onEditService={() => {}}
|
onEditService={() => {}}
|
||||||
onDeleteService={() => {}}
|
onDeleteService={() => {}}
|
||||||
onToggleService={() => {}}
|
onToggleService={() => {}}
|
||||||
|
onToggleServiceIp={() => {}}
|
||||||
onEditGroup={() => {}}
|
onEditGroup={() => {}}
|
||||||
onDeleteGroup={() => {}}
|
onDeleteGroup={() => {}}
|
||||||
onAddServiceToGroup={() => {}}
|
onAddServiceToGroup={() => {}}
|
||||||
@@ -702,12 +788,14 @@ function ServicesPage() {
|
|||||||
domainId={domainId}
|
domainId={domainId}
|
||||||
domainLabel={filteredDomain?.zone_name}
|
domainLabel={filteredDomain?.zone_name}
|
||||||
togglingId={togglingServiceId}
|
togglingId={togglingServiceId}
|
||||||
|
togglingIp={togglingIp}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={setActiveTab}
|
onTabChange={setActiveTab}
|
||||||
hideHeader
|
hideHeader
|
||||||
onEditService={setEditingService}
|
onEditService={setEditingService}
|
||||||
onDeleteService={setDeletingService}
|
onDeleteService={setDeletingService}
|
||||||
onToggleService={handleServiceToggle}
|
onToggleService={handleServiceToggle}
|
||||||
|
onToggleServiceIp={handleServiceIpToggle}
|
||||||
onEditGroup={setEditingGroup}
|
onEditGroup={setEditingGroup}
|
||||||
onDeleteGroup={setDeletingGroup}
|
onDeleteGroup={setDeletingGroup}
|
||||||
onAddServiceToGroup={handleOpenCreateService}
|
onAddServiceToGroup={handleOpenCreateService}
|
||||||
|
|||||||
@@ -0,0 +1,588 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { HeartPulseIcon, GlobeIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { SettingRow } from '@/components/setting-row'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import {
|
||||||
|
NumberField,
|
||||||
|
NumberFieldDecrement,
|
||||||
|
NumberFieldGroup,
|
||||||
|
NumberFieldIncrement,
|
||||||
|
NumberFieldInput,
|
||||||
|
} from '@/components/reui/number-field'
|
||||||
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
healthCheckCron: z.string().trim().min(1, 'Укажите cron').max(64),
|
||||||
|
healthDegradedFailures: z.number().int().min(1).max(20),
|
||||||
|
healthDownFailures: z.number().int().min(1).max(50),
|
||||||
|
healthLatencyWarnMs: z.number().int().min(50).max(60_000),
|
||||||
|
healthSuccessRecoveries: z.number().int().min(1).max(20),
|
||||||
|
}).superRefine((data, ctx) => {
|
||||||
|
if (data.healthDownFailures < data.healthDegradedFailures) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'Не меньше порога degraded',
|
||||||
|
path: ['healthDownFailures'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const globalpingSchema = z.object({
|
||||||
|
globalpingToken: z.string().optional(),
|
||||||
|
globalpingLocations: z.string().trim().min(1).max(200),
|
||||||
|
globalpingLimit: z.number().int().min(1).max(10),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
type GlobalpingValues = z.infer<typeof globalpingSchema>
|
||||||
|
type HealthWorkerStatus = 'missing' | 'ready' | 'error'
|
||||||
|
|
||||||
|
type SettingsResponse = FormValues & {
|
||||||
|
id: string
|
||||||
|
healthWorkerUrl?: string
|
||||||
|
healthWorkerStatus?: HealthWorkerStatus
|
||||||
|
healthWorkerError?: string | null
|
||||||
|
healthWorkerDeployedAt?: string | null
|
||||||
|
healthWorkerLastIngestAt?: string | null
|
||||||
|
healthWorkerKvNamespaceId?: string
|
||||||
|
globalpingTokenSet?: boolean
|
||||||
|
globalpingLocations?: string
|
||||||
|
globalpingLimit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/settings/health')({
|
||||||
|
component: HealthSettingsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function CompactNumberInput({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
disabled,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
value: number
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
disabled?: boolean
|
||||||
|
onValueChange: (next: number) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NumberField
|
||||||
|
id={id}
|
||||||
|
size="sm"
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
if (next != null) onValueChange(next)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NumberFieldGroup className="w-36">
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadge(status: HealthWorkerStatus | undefined) {
|
||||||
|
if (status === 'ready') {
|
||||||
|
return (
|
||||||
|
<Badge variant="success-light" size="sm">
|
||||||
|
Готов
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (status === 'error') {
|
||||||
|
return (
|
||||||
|
<Badge variant="destructive-light" size="sm">
|
||||||
|
Ошибка
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
Не создан
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthSettingsPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['app-settings'],
|
||||||
|
queryFn: () => api.get<SettingsResponse>('/api/v1/settings'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
healthCheckCron: '0 */2 * * * *',
|
||||||
|
healthDegradedFailures: 1,
|
||||||
|
healthDownFailures: 2,
|
||||||
|
healthLatencyWarnMs: 1000,
|
||||||
|
healthSuccessRecoveries: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const gpForm = useForm<GlobalpingValues>({
|
||||||
|
resolver: zodResolver(globalpingSchema),
|
||||||
|
defaultValues: {
|
||||||
|
globalpingToken: '',
|
||||||
|
globalpingLocations: 'World',
|
||||||
|
globalpingLimit: 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return
|
||||||
|
form.reset({
|
||||||
|
healthCheckCron: data.healthCheckCron,
|
||||||
|
healthDegradedFailures: data.healthDegradedFailures,
|
||||||
|
healthDownFailures: data.healthDownFailures,
|
||||||
|
healthLatencyWarnMs: data.healthLatencyWarnMs,
|
||||||
|
healthSuccessRecoveries: data.healthSuccessRecoveries,
|
||||||
|
})
|
||||||
|
gpForm.reset({
|
||||||
|
globalpingToken: '',
|
||||||
|
globalpingLocations: data.globalpingLocations || 'World',
|
||||||
|
globalpingLimit: data.globalpingLimit ?? 3,
|
||||||
|
})
|
||||||
|
}, [data, form, gpForm])
|
||||||
|
|
||||||
|
const saveMut = useMutation({
|
||||||
|
mutationFn: (values: FormValues) =>
|
||||||
|
api.patch<SettingsResponse>('/api/v1/settings', {
|
||||||
|
healthCheckCron: values.healthCheckCron,
|
||||||
|
healthDegradedFailures: values.healthDegradedFailures,
|
||||||
|
healthDownFailures: values.healthDownFailures,
|
||||||
|
healthLatencyWarnMs: values.healthLatencyWarnMs,
|
||||||
|
healthSuccessRecoveries: values.healthSuccessRecoveries,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||||
|
toast.success('Настройки health-check сохранены')
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const saveGpMut = useMutation({
|
||||||
|
mutationFn: (values: GlobalpingValues) =>
|
||||||
|
api.patch<SettingsResponse>('/api/v1/settings', {
|
||||||
|
globalpingLocations: values.globalpingLocations,
|
||||||
|
globalpingLimit: values.globalpingLimit,
|
||||||
|
...(values.globalpingToken?.trim()
|
||||||
|
? { globalpingToken: values.globalpingToken.trim() }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||||
|
toast.success('Настройки Globalping сохранены')
|
||||||
|
gpForm.reset({
|
||||||
|
...gpForm.getValues(),
|
||||||
|
globalpingToken: '',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const ensureMut = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.post<SettingsResponse>('/api/v1/settings/health/worker/ensure'),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||||
|
toast.success('Worker создан или обновлён')
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось создать Worker'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-4">
|
||||||
|
<form
|
||||||
|
className="flex w-full flex-col gap-4"
|
||||||
|
onSubmit={(event) =>
|
||||||
|
void form.handleSubmit((values) => saveMut.mutate(values))(event)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle className="flex items-center gap-2">
|
||||||
|
<HeartPulseIcon className="size-4" aria-hidden />
|
||||||
|
Local health-check
|
||||||
|
</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Расписание и пороги движка — общие для Local, Cloudflare Worker и Globalping.
|
||||||
|
Тип/порт/path и правило агрегации задаются в карточке сервиса.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
<FieldGroup className="gap-0">
|
||||||
|
<SettingRow
|
||||||
|
title="Cron"
|
||||||
|
description="Расписание проб CFDM (6 полей). Worker на edge получает 5-польное cron без секунд. Env: HEALTH_CHECK_CRON."
|
||||||
|
labelFor="health-cron"
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="health-cron"
|
||||||
|
className="font-mono"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={isLoading || saveMut.isPending}
|
||||||
|
{...form.register('healthCheckCron')}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
{form.formState.errors.healthCheckCron ? (
|
||||||
|
<p className="text-destructive px-5 pb-2 text-sm">
|
||||||
|
{form.formState.errors.healthCheckCron.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Ошибок до Slow"
|
||||||
|
description="Подряд неуспешных проб до статуса degraded. Env: HEALTH_DEGRADED_FAILURES."
|
||||||
|
labelFor="health-degraded"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="healthDegradedFailures"
|
||||||
|
render={({ field }) => (
|
||||||
|
<CompactNumberInput
|
||||||
|
id="health-degraded"
|
||||||
|
value={field.value}
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
disabled={isLoading || saveMut.isPending}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Ошибок до Down"
|
||||||
|
description="Подряд неуспешных проб до статуса down. Env: HEALTH_DOWN_FAILURES."
|
||||||
|
labelFor="health-down"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="healthDownFailures"
|
||||||
|
render={({ field }) => (
|
||||||
|
<CompactNumberInput
|
||||||
|
id="health-down"
|
||||||
|
value={field.value}
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
disabled={isLoading || saveMut.isPending}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
{form.formState.errors.healthDownFailures ? (
|
||||||
|
<p className="text-destructive px-5 pb-2 text-sm">
|
||||||
|
{form.formState.errors.healthDownFailures.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Латентность Slow, мс"
|
||||||
|
description="Порог задержки для degraded при успешной пробе. Env: HEALTH_LATENCY_WARN_MS."
|
||||||
|
labelFor="health-latency"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="healthLatencyWarnMs"
|
||||||
|
render={({ field }) => (
|
||||||
|
<CompactNumberInput
|
||||||
|
id="health-latency"
|
||||||
|
value={field.value}
|
||||||
|
min={50}
|
||||||
|
max={60_000}
|
||||||
|
disabled={isLoading || saveMut.isPending}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Успехов для recovery"
|
||||||
|
description="Подряд успешных проб, чтобы выйти из Checking в Healthy. Env: HEALTH_SUCCESS_RECOVERIES."
|
||||||
|
labelFor="health-recoveries"
|
||||||
|
compact
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="healthSuccessRecoveries"
|
||||||
|
render={({ field }) => (
|
||||||
|
<CompactNumberInput
|
||||||
|
id="health-recoveries"
|
||||||
|
value={field.value}
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
disabled={isLoading || saveMut.isPending}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</FieldGroup>
|
||||||
|
<FrameFooter className="flex flex-row justify-end">
|
||||||
|
<LoadingButton
|
||||||
|
type="submit"
|
||||||
|
isLoading={saveMut.isPending}
|
||||||
|
disabled={isLoading || !form.formState.isDirty}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle className="flex items-center gap-2">
|
||||||
|
Cloudflare Worker
|
||||||
|
{statusBadge(data?.healthWorkerStatus)}
|
||||||
|
</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Worker сам опрашивает IP/порты с edge. CFDM создаёт скрипт через API
|
||||||
|
и забирает результаты из KV. Preview:{' '}
|
||||||
|
<a
|
||||||
|
href="https://reui.io/preview/base/settings-16"
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
settings-16
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-col gap-3 p-4">
|
||||||
|
<Alert variant={data?.healthWorkerStatus === 'error' ? 'destructive' : 'info'}>
|
||||||
|
<AlertTitle>Не Health Checks API</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
На Free-плане продукта Health Checks нет. Нужен Account-токен с
|
||||||
|
Workers Scripts Write и Workers KV Storage Write — Zone DNS
|
||||||
|
недостаточно. Лимиты Free: 5 cron на аккаунт, KV 1000 writes/сутки
|
||||||
|
(интервал ≥ 2 мин), до 48 целей за тик.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{data?.healthWorkerError ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Ошибка деплоя</AlertTitle>
|
||||||
|
<AlertDescription>{data.healthWorkerError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<FieldGroup className="gap-0">
|
||||||
|
<SettingRow title="Скрипт" compact>
|
||||||
|
<span className="font-mono text-sm">cfdm-health-probe</span>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="KV namespace"
|
||||||
|
description="id mailbox targets/results"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm break-all">
|
||||||
|
{data?.healthWorkerKvNamespaceId || '—'}
|
||||||
|
</span>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="URL"
|
||||||
|
description="workers.dev после автодеплоя"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<span className="font-mono text-sm break-all">
|
||||||
|
{data?.healthWorkerUrl || '—'}
|
||||||
|
</span>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="Последний деплой"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{data?.healthWorkerDeployedAt || '—'}
|
||||||
|
</span>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="Последний ingest"
|
||||||
|
description="colo пишется в журнал проб"
|
||||||
|
compact
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{data?.healthWorkerLastIngestAt || '—'}
|
||||||
|
</span>
|
||||||
|
</SettingRow>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={ensureMut.isPending}
|
||||||
|
onClick={() => ensureMut.mutate()}
|
||||||
|
>
|
||||||
|
{ensureMut.isPending ? 'Создаём…' : 'Создать / обновить Worker'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="flex w-full flex-col gap-4"
|
||||||
|
onSubmit={(event) =>
|
||||||
|
void gpForm.handleSubmit((values) => saveGpMut.mutate(values))(event)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle className="flex items-center gap-2">
|
||||||
|
<GlobeIcon className="size-4" aria-hidden />
|
||||||
|
Globalping
|
||||||
|
<Badge
|
||||||
|
variant={data?.globalpingTokenSet ? 'success-light' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{data?.globalpingTokenSet ? 'Токен задан' : 'Нет токена'}
|
||||||
|
</Badge>
|
||||||
|
</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Пробы из сети globalping.io. Poll ≥ 500 мс.{' '}
|
||||||
|
<a
|
||||||
|
href="https://reui.io/preview/base/settings-16"
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
settings-16
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Лимиты и credits</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Без токена — 250 tests/hour, с токеном — 500 +{' '}
|
||||||
|
<a
|
||||||
|
href="https://globalping.io/credits"
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
credits
|
||||||
|
</a>
|
||||||
|
. Токен: dash.globalping.io/tokens. Один measurement на
|
||||||
|
уникальный IP/порт за тик cron. При десятках IP следите за
|
||||||
|
hourly credits.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
<FieldGroup className="gap-0">
|
||||||
|
<SettingRow
|
||||||
|
title="Токен"
|
||||||
|
description={
|
||||||
|
data?.globalpingTokenSet
|
||||||
|
? 'Оставьте пустым, чтобы не менять сохранённый токен.'
|
||||||
|
: 'Authorization: Bearer. Без токена источник Globalping = fail.'
|
||||||
|
}
|
||||||
|
labelFor="gp-token"
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="gp-token"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={data?.globalpingTokenSet ? '••••••••' : 'gp_…'}
|
||||||
|
disabled={isLoading || saveGpMut.isPending}
|
||||||
|
{...gpForm.register('globalpingToken')}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="Локации"
|
||||||
|
description="Magic CSV, например World или EU,US. Default World."
|
||||||
|
labelFor="gp-locations"
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="gp-locations"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={isLoading || saveGpMut.isPending}
|
||||||
|
{...gpForm.register('globalpingLocations')}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="Проб в measurement"
|
||||||
|
description="limit 1–10. Default 3."
|
||||||
|
labelFor="gp-limit"
|
||||||
|
compact
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={gpForm.control}
|
||||||
|
name="globalpingLimit"
|
||||||
|
render={({ field }) => (
|
||||||
|
<CompactNumberInput
|
||||||
|
id="gp-limit"
|
||||||
|
value={field.value}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
disabled={isLoading || saveGpMut.isPending}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</FieldGroup>
|
||||||
|
<FrameFooter className="flex flex-row justify-end">
|
||||||
|
<LoadingButton
|
||||||
|
type="submit"
|
||||||
|
isLoading={saveGpMut.isPending}
|
||||||
|
disabled={isLoading || !gpForm.formState.isDirty}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ COPY apps/api apps/api
|
|||||||
COPY packages/ui packages/ui
|
COPY packages/ui packages/ui
|
||||||
COPY packages/shared packages/shared
|
COPY packages/shared packages/shared
|
||||||
COPY packages/db packages/db
|
COPY packages/db packages/db
|
||||||
|
COPY workers/health-probe workers/health-probe
|
||||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||||
pnpm turbo build --filter=web --filter=@cfdm/api \
|
pnpm turbo build --filter=web --filter=@cfdm/api \
|
||||||
&& pnpm --filter @cfdm/api deploy --prod /out \
|
&& pnpm --filter @cfdm/api deploy --prod /out \
|
||||||
|
|||||||
+50
-8
@@ -14,17 +14,17 @@ Manage Cloudflare zones, DNS records, domain groups, and TLS certificate expiry
|
|||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `CLOUDFLARE_API_TOKEN` | API token with Zone.DNS permissions |
|
| `CLOUDFLARE_API_TOKEN` | API token: Zone.DNS **и** для Worker — Account Workers Scripts Write + Workers KV Storage Write |
|
||||||
| `DATABASE_URL` | SQLite path (`sqlite:/data/app.db`) |
|
| `DATABASE_URL` | SQLite path (`sqlite:/data/app.db`) |
|
||||||
| `JWT_SECRET` | JWT signing secret |
|
| `JWT_SECRET` | JWT signing secret |
|
||||||
| `ADMIN_USERNAME` | Admin username |
|
| `ADMIN_USERNAME` | Admin username |
|
||||||
| `ADMIN_PASSWORD_HASH` | Argon2 hash (empty = dev `admin`/`admin`) |
|
| `ADMIN_PASSWORD_HASH` | Argon2 hash (empty = dev `admin`/`admin`) |
|
||||||
| `LOG_LEVEL` | Уровень логов API (`info`, `debug`) |
|
| `LOG_LEVEL` | Уровень логов API (`info`, `debug`) |
|
||||||
| `HEALTH_CHECK_CRON` | Cron для health-check (default `*/30 * * * * *`) |
|
| `HEALTH_CHECK_CRON` | Cron для health-check (default `0 */2 * * * *`). Переопределяется в **Настройки → Health-check**. |
|
||||||
| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`) |
|
| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`). То же в UI. |
|
||||||
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`) |
|
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`). То же в UI. |
|
||||||
| `HEALTH_SUCCESS_RECOVERIES` | Успехов подряд для recovery `CHECKING → HEALTHY` (default `2`) |
|
| `HEALTH_SUCCESS_RECOVERIES` | Успехов подряд для recovery `CHECKING → HEALTHY` (default `2`). То же в UI. |
|
||||||
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`) |
|
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`). То же в UI. |
|
||||||
|
|
||||||
## Load balancing & health checks
|
## Load balancing & health checks
|
||||||
|
|
||||||
@@ -46,8 +46,50 @@ health-check работают на двух уровнях:
|
|||||||
Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted`
|
Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted`
|
||||||
работает как `round_robin` (одна A на IP). `unknown` **не** считается healthy и
|
работает как `round_robin` (одна A на IP). `unknown` **не** считается healthy и
|
||||||
не попадает в пул, пока нет успешных проб; восстановление — `UNHEALTHY → CHECKING → HEALTHY`
|
не попадает в пул, пока нет успешных проб; восстановление — `UNHEALTHY → CHECKING → HEALTHY`
|
||||||
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Reconcile DNS запускается cron-задачей
|
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в
|
||||||
`health-check`. Cloudflare Health Checks — официальный API зоны, Workers не используются.
|
**Настройки → Health-check** (env — fallback, пока значения не сохранены в UI).
|
||||||
|
|
||||||
|
### Источники проб: Local, Cloudflare Worker, Globalping
|
||||||
|
|
||||||
|
На привязке/группе задаётся **мультивыбор** источников (`health_check_providers` JSON)
|
||||||
|
и **правило агрегации** (`health_check_aggregate`: `any` | `all` | `majority`).
|
||||||
|
Failover читает одну строку `ip_health_status` (агрегат). Журнал `health_probe_log` —
|
||||||
|
строка на каждый источник.
|
||||||
|
|
||||||
|
| | Local | Cloudflare Worker | Globalping |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Кто пробирует | процесс API CFDM | Worker на edge (Cron Trigger) | [globalping.io](https://globalping.io) |
|
||||||
|
| Планировщик | глобальный cron CFDM | cron Worker + ingest KV | тот же cron CFDM (POST/GET measurements) |
|
||||||
|
| Пороги Slow/Down | Настройки → Health-check | те же | те же (по агрегату) |
|
||||||
|
| Результат | SQLite `ip_health_status` | та же SQLite + `colo` из KV | та же SQLite, colo = city/country пробы |
|
||||||
|
| Fallback | — | нет (не Local) | нет (нет токена / 429 / timeout = fail) |
|
||||||
|
|
||||||
|
**Агрегация (на сервисе/группе):**
|
||||||
|
|
||||||
|
- `any` — Down, если хотя бы один выбранный источник Down
|
||||||
|
- `all` — Down, только если все выбранные Down
|
||||||
|
- `majority` — Down по большинству (2 источника → оба; 3 → ≥2)
|
||||||
|
|
||||||
|
**Cloudflare в CFDM — это Worker**, не [Health Checks API](https://developers.cloudflare.com/api/resources/healthchecks).
|
||||||
|
Продукт Health Checks на Free-плане недоступен и **не используется**.
|
||||||
|
|
||||||
|
Worker **сам** опрашивает IP/порты/протоколы (TCP/HTTP, паттерн [UptimeFlare](https://github.com/lyc8503/UptimeFlare): `sockets.opened`, p-limit 5).
|
||||||
|
CFDM создаёт скрипт через Workers Scripts API, кладёт список целей в KV и читает результаты.
|
||||||
|
Публичный URL API не нужен. Если Worker/KV не готовы, cloudflare-цели **не** пробируются как Local.
|
||||||
|
|
||||||
|
Кнопка **Создать / обновить Worker** — **Настройки → Health-check**. Токен:
|
||||||
|
Account `Workers Scripts Write` + `Workers KV Storage Write`. Zone DNS недостаточно.
|
||||||
|
|
||||||
|
Free: 5 Cron Triggers на аккаунт; KV 1000 writes/сутки (интервал ≥ 2 мин);
|
||||||
|
≤ 48 целей за тик. Исходник: [`workers/health-probe/`](../workers/health-probe/).
|
||||||
|
|
||||||
|
**Globalping:** `POST /v1/measurements` → poll `GET` каждые ≥ 500 мс.
|
||||||
|
CFDM TCP → `type: ping` + `protocol: TCP`; HTTP → `type: http`, `target` = IP, `request.host` = hostname.
|
||||||
|
Токен: [dash.globalping.io/tokens](https://dash.globalping.io/tokens). Без токена 250 tests/hour, с токеном 500 + [credits](https://globalping.io/credits).
|
||||||
|
Локации (magic CSV, default `World`) и `limit` (1–10, default 3) — **Настройки → Health-check**.
|
||||||
|
Один measurement на уникальный origin (IP/порт/path) за тик.
|
||||||
|
|
||||||
|
Reconcile DNS запускается cron-задачей `health-check` после ingest KV и агрегации.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
|
|||||||
Vendored
+1484
-8
File diff suppressed because one or more lines are too long
Vendored
+455
-47
@@ -52,6 +52,9 @@ var serviceGroups = sqliteTable("service_groups", {
|
|||||||
health_check_interval_sec: integer("health_check_interval_sec").notNull().default(30),
|
health_check_interval_sec: integer("health_check_interval_sec").notNull().default(30),
|
||||||
health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3),
|
health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3),
|
||||||
health_check_verify_tls: integer("health_check_verify_tls", { mode: "boolean" }).notNull().default(false),
|
health_check_verify_tls: integer("health_check_verify_tls", { mode: "boolean" }).notNull().default(false),
|
||||||
|
health_check_provider: text("health_check_provider").notNull().default("local"),
|
||||||
|
health_check_providers: text("health_check_providers").notNull().default('["local"]'),
|
||||||
|
health_check_aggregate: text("health_check_aggregate").notNull().default("majority"),
|
||||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||||
});
|
});
|
||||||
@@ -117,6 +120,10 @@ var serviceBindings = sqliteTable(
|
|||||||
health_check_verify_tls: integer("health_check_verify_tls", {
|
health_check_verify_tls: integer("health_check_verify_tls", {
|
||||||
mode: "boolean"
|
mode: "boolean"
|
||||||
}).notNull().default(false),
|
}).notNull().default(false),
|
||||||
|
health_check_provider: text("health_check_provider").notNull().default("local"),
|
||||||
|
health_check_providers: text("health_check_providers").notNull().default('["local"]'),
|
||||||
|
health_check_aggregate: text("health_check_aggregate").notNull().default("majority"),
|
||||||
|
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
|
||||||
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
|
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").notNull().default(sql`datetime('now')`),
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||||
@@ -187,6 +194,7 @@ var serviceIps = sqliteTable("service_ips", {
|
|||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
|
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
|
||||||
ip: text("ip").notNull(),
|
ip: text("ip").notNull(),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
created_at: text("created_at").notNull().default(sql`datetime('now')`)
|
created_at: text("created_at").notNull().default(sql`datetime('now')`)
|
||||||
});
|
});
|
||||||
var serviceBindingRecords = sqliteTable(
|
var serviceBindingRecords = sqliteTable(
|
||||||
@@ -221,6 +229,9 @@ var 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"),
|
||||||
@@ -251,6 +262,8 @@ var ipHealthStatus = sqliteTable(
|
|||||||
consecutive_successes: integer("consecutive_successes").notNull().default(0),
|
consecutive_successes: integer("consecutive_successes").notNull().default(0),
|
||||||
last_checked_at: text("last_checked_at"),
|
last_checked_at: text("last_checked_at"),
|
||||||
last_error: text("last_error"),
|
last_error: text("last_error"),
|
||||||
|
colo: text("colo"),
|
||||||
|
provider: text("provider").notNull().default("local"),
|
||||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||||
},
|
},
|
||||||
@@ -269,6 +282,21 @@ var appSettings = sqliteTable("app_settings", {
|
|||||||
show_quick_actions: integer("show_quick_actions", {
|
show_quick_actions: integer("show_quick_actions", {
|
||||||
mode: "boolean"
|
mode: "boolean"
|
||||||
}).notNull().default(true),
|
}).notNull().default(true),
|
||||||
|
health_check_cron: text("health_check_cron"),
|
||||||
|
health_degraded_failures: integer("health_degraded_failures"),
|
||||||
|
health_down_failures: integer("health_down_failures"),
|
||||||
|
health_latency_warn_ms: integer("health_latency_warn_ms"),
|
||||||
|
health_success_recoveries: integer("health_success_recoveries"),
|
||||||
|
health_worker_url: text("health_worker_url"),
|
||||||
|
health_worker_token: text("health_worker_token"),
|
||||||
|
health_worker_account_id: text("health_worker_account_id"),
|
||||||
|
health_worker_kv_namespace_id: text("health_worker_kv_namespace_id"),
|
||||||
|
health_worker_error: text("health_worker_error"),
|
||||||
|
health_worker_deployed_at: text("health_worker_deployed_at"),
|
||||||
|
health_worker_last_ingest_at: text("health_worker_last_ingest_at"),
|
||||||
|
globalping_token: text("globalping_token"),
|
||||||
|
globalping_locations: text("globalping_locations"),
|
||||||
|
globalping_limit: integer("globalping_limit"),
|
||||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||||
});
|
});
|
||||||
@@ -305,6 +333,19 @@ var domainMonitorResults = sqliteTable("domain_monitor_results", {
|
|||||||
error: text("error"),
|
error: text("error"),
|
||||||
checked_at: text("checked_at").notNull().default(sql`datetime('now')`)
|
checked_at: text("checked_at").notNull().default(sql`datetime('now')`)
|
||||||
});
|
});
|
||||||
|
var healthProbeLog = sqliteTable("health_probe_log", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
scope: text("scope").notNull(),
|
||||||
|
ref_id: integer("ref_id").notNull(),
|
||||||
|
ip: text("ip").notNull(),
|
||||||
|
provider: text("provider").notNull(),
|
||||||
|
status: text("status").notNull(),
|
||||||
|
ok: integer("ok", { mode: "boolean" }).notNull(),
|
||||||
|
latency_ms: integer("latency_ms"),
|
||||||
|
colo: text("colo"),
|
||||||
|
error: text("error"),
|
||||||
|
checked_at: text("checked_at").notNull().default(sql`datetime('now')`)
|
||||||
|
});
|
||||||
var notificationLog = sqliteTable("notification_log", {
|
var notificationLog = sqliteTable("notification_log", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
kind: text("kind").notNull(),
|
kind: text("kind").notNull(),
|
||||||
@@ -352,6 +393,7 @@ var schema = {
|
|||||||
domainTags,
|
domainTags,
|
||||||
domainMonitors,
|
domainMonitors,
|
||||||
domainMonitorResults,
|
domainMonitorResults,
|
||||||
|
healthProbeLog,
|
||||||
notificationLog,
|
notificationLog,
|
||||||
auditLog
|
auditLog
|
||||||
};
|
};
|
||||||
@@ -493,7 +535,26 @@ function listAudit(db, opts = {}) {
|
|||||||
// src/settings-repo.ts
|
// src/settings-repo.ts
|
||||||
import { eq as eq2 } from "drizzle-orm";
|
import { eq as eq2 } from "drizzle-orm";
|
||||||
var SETTINGS_ID = "settings-main";
|
var SETTINGS_ID = "settings-main";
|
||||||
function toDto(row) {
|
function coalesceInt(value, fallback) {
|
||||||
|
return value == null || Number.isNaN(value) || value < 1 ? fallback : value;
|
||||||
|
}
|
||||||
|
function workerStatus(row, envUrl) {
|
||||||
|
if (row.health_worker_error?.trim()) return "error";
|
||||||
|
const url = row.health_worker_url?.trim() || envUrl;
|
||||||
|
const kv = row.health_worker_kv_namespace_id?.trim();
|
||||||
|
if (kv && url) return "ready";
|
||||||
|
return "missing";
|
||||||
|
}
|
||||||
|
function toDto(row, fallbacks) {
|
||||||
|
const env = fallbacks ?? {
|
||||||
|
healthCheckCron: "0 */2 * * * *",
|
||||||
|
healthDegradedFailures: 1,
|
||||||
|
healthDownFailures: 2,
|
||||||
|
healthLatencyWarnMs: 1e3,
|
||||||
|
healthSuccessRecoveries: 2,
|
||||||
|
healthWorkerUrl: "",
|
||||||
|
healthWorkerTokenSet: false
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "",
|
vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "",
|
||||||
@@ -502,42 +563,91 @@ function toDto(row) {
|
|||||||
),
|
),
|
||||||
vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled),
|
vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled),
|
||||||
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
|
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
|
||||||
showQuickActions: row.show_quick_actions == null ? true : Boolean(row.show_quick_actions)
|
showQuickActions: row.show_quick_actions == null ? true : Boolean(row.show_quick_actions),
|
||||||
|
healthCheckCron: row.health_check_cron?.trim() || env.healthCheckCron,
|
||||||
|
healthDegradedFailures: coalesceInt(
|
||||||
|
row.health_degraded_failures,
|
||||||
|
env.healthDegradedFailures
|
||||||
|
),
|
||||||
|
healthDownFailures: coalesceInt(
|
||||||
|
row.health_down_failures,
|
||||||
|
env.healthDownFailures
|
||||||
|
),
|
||||||
|
healthLatencyWarnMs: coalesceInt(
|
||||||
|
row.health_latency_warn_ms,
|
||||||
|
env.healthLatencyWarnMs
|
||||||
|
),
|
||||||
|
healthSuccessRecoveries: coalesceInt(
|
||||||
|
row.health_success_recoveries,
|
||||||
|
env.healthSuccessRecoveries
|
||||||
|
),
|
||||||
|
healthWorkerUrl: row.health_worker_url?.trim() || env.healthWorkerUrl,
|
||||||
|
healthWorkerTokenSet: Boolean(row.health_worker_token?.trim()) || env.healthWorkerTokenSet,
|
||||||
|
healthWorkerAccountId: row.health_worker_account_id?.trim() ?? "",
|
||||||
|
healthWorkerKvNamespaceId: row.health_worker_kv_namespace_id?.trim() ?? "",
|
||||||
|
healthWorkerError: row.health_worker_error?.trim() || null,
|
||||||
|
healthWorkerDeployedAt: row.health_worker_deployed_at ?? null,
|
||||||
|
healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null,
|
||||||
|
healthWorkerStatus: workerStatus(row, env.healthWorkerUrl),
|
||||||
|
globalpingTokenSet: Boolean(row.globalping_token?.trim()),
|
||||||
|
globalpingLocations: row.globalping_locations?.trim() || "World",
|
||||||
|
globalpingLimit: row.globalping_limit == null || Number.isNaN(row.globalping_limit) || row.globalping_limit < 1 ? 3 : Math.min(10, row.globalping_limit)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function getAppSettings(db) {
|
function getAppSettings(db, fallbacks) {
|
||||||
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
||||||
if (!row) {
|
if (!row) {
|
||||||
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
|
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
|
||||||
return toDto(
|
return toDto(
|
||||||
db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get()
|
db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get(),
|
||||||
|
fallbacks
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return toDto(row);
|
return toDto(row, fallbacks);
|
||||||
}
|
}
|
||||||
function getAppSettingsSecrets(db) {
|
function getAppSettingsSecrets(db) {
|
||||||
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
||||||
|
const limit = row?.globalping_limit;
|
||||||
return {
|
return {
|
||||||
vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "",
|
vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "",
|
||||||
vpsTrackerIntegrationToken: row?.vps_tracker_integration_token?.trim() ?? "",
|
vpsTrackerIntegrationToken: row?.vps_tracker_integration_token?.trim() ?? "",
|
||||||
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled)
|
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled),
|
||||||
|
healthWorkerUrl: row?.health_worker_url?.trim() ?? "",
|
||||||
|
healthWorkerToken: row?.health_worker_token?.trim() ?? "",
|
||||||
|
globalpingToken: row?.globalping_token?.trim() ?? "",
|
||||||
|
globalpingLocations: row?.globalping_locations?.trim() || "World",
|
||||||
|
globalpingLimit: limit == null || Number.isNaN(limit) || limit < 1 ? 3 : Math.min(10, limit)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function updateAppSettings(db, patch) {
|
function updateAppSettings(db, patch, fallbacks) {
|
||||||
const existing = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
const existing = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
|
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
|
||||||
}
|
}
|
||||||
const current = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
const current = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
||||||
db.update(appSettings).set({
|
db.update(appSettings).set({
|
||||||
// app_switcher_json: deprecated — source of truth is auth-portal
|
|
||||||
vps_tracker_url: patch.vpsTrackerUrl !== void 0 ? patch.vpsTrackerUrl : current.vps_tracker_url,
|
vps_tracker_url: patch.vpsTrackerUrl !== void 0 ? patch.vpsTrackerUrl : current.vps_tracker_url,
|
||||||
vps_tracker_integration_token: patch.vpsTrackerIntegrationToken !== void 0 && patch.vpsTrackerIntegrationToken.trim() !== "" ? patch.vpsTrackerIntegrationToken : current.vps_tracker_integration_token,
|
vps_tracker_integration_token: patch.vpsTrackerIntegrationToken !== void 0 && patch.vpsTrackerIntegrationToken.trim() !== "" ? patch.vpsTrackerIntegrationToken : current.vps_tracker_integration_token,
|
||||||
vps_tracker_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled,
|
vps_tracker_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled,
|
||||||
show_quick_actions: patch.showQuickActions !== void 0 ? patch.showQuickActions : current.show_quick_actions,
|
show_quick_actions: patch.showQuickActions !== void 0 ? patch.showQuickActions : current.show_quick_actions,
|
||||||
|
health_check_cron: patch.healthCheckCron !== void 0 ? patch.healthCheckCron.trim() : current.health_check_cron,
|
||||||
|
health_degraded_failures: patch.healthDegradedFailures !== void 0 ? patch.healthDegradedFailures : current.health_degraded_failures,
|
||||||
|
health_down_failures: patch.healthDownFailures !== void 0 ? patch.healthDownFailures : current.health_down_failures,
|
||||||
|
health_latency_warn_ms: patch.healthLatencyWarnMs !== void 0 ? patch.healthLatencyWarnMs : current.health_latency_warn_ms,
|
||||||
|
health_success_recoveries: patch.healthSuccessRecoveries !== void 0 ? patch.healthSuccessRecoveries : current.health_success_recoveries,
|
||||||
|
health_worker_url: patch.healthWorkerUrl !== void 0 ? patch.healthWorkerUrl.trim() || null : current.health_worker_url,
|
||||||
|
health_worker_token: patch.healthWorkerToken !== void 0 && patch.healthWorkerToken.trim() !== "" ? patch.healthWorkerToken : current.health_worker_token,
|
||||||
|
health_worker_account_id: patch.healthWorkerAccountId !== void 0 ? patch.healthWorkerAccountId?.trim() || null : current.health_worker_account_id,
|
||||||
|
health_worker_kv_namespace_id: patch.healthWorkerKvNamespaceId !== void 0 ? patch.healthWorkerKvNamespaceId?.trim() || null : current.health_worker_kv_namespace_id,
|
||||||
|
health_worker_error: patch.healthWorkerError !== void 0 ? patch.healthWorkerError?.trim() || null : current.health_worker_error,
|
||||||
|
health_worker_deployed_at: patch.healthWorkerDeployedAt !== void 0 ? patch.healthWorkerDeployedAt : current.health_worker_deployed_at,
|
||||||
|
health_worker_last_ingest_at: patch.healthWorkerLastIngestAt !== void 0 ? patch.healthWorkerLastIngestAt : current.health_worker_last_ingest_at,
|
||||||
|
globalping_token: patch.globalpingToken !== void 0 && patch.globalpingToken.trim() !== "" ? patch.globalpingToken : current.globalping_token,
|
||||||
|
globalping_locations: patch.globalpingLocations !== void 0 ? patch.globalpingLocations.trim() || "World" : current.globalping_locations,
|
||||||
|
globalping_limit: patch.globalpingLimit !== void 0 ? Math.min(10, Math.max(1, patch.globalpingLimit)) : current.globalping_limit,
|
||||||
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
||||||
}).where(eq2(appSettings.id, SETTINGS_ID)).run();
|
}).where(eq2(appSettings.id, SETTINGS_ID)).run();
|
||||||
return getAppSettings(db);
|
return getAppSettings(db, fallbacks);
|
||||||
}
|
}
|
||||||
function touchVpsTrackerSync(db) {
|
function touchVpsTrackerSync(db) {
|
||||||
db.update(appSettings).set({
|
db.update(appSettings).set({
|
||||||
@@ -605,6 +715,7 @@ __export(repos_exports, {
|
|||||||
getSyncJob: () => getSyncJob,
|
getSyncJob: () => getSyncJob,
|
||||||
insertBinding: () => insertBinding,
|
insertBinding: () => insertBinding,
|
||||||
insertDnsRecord: () => insertDnsRecord,
|
insertDnsRecord: () => insertDnsRecord,
|
||||||
|
insertHealthProbeLog: () => insertHealthProbeLog,
|
||||||
insertNotificationLog: () => insertNotificationLog,
|
insertNotificationLog: () => insertNotificationLog,
|
||||||
linkBindingRecord: () => linkBindingRecord,
|
linkBindingRecord: () => linkBindingRecord,
|
||||||
linkGroupDnsRecord: () => linkGroupDnsRecord,
|
linkGroupDnsRecord: () => linkGroupDnsRecord,
|
||||||
@@ -631,12 +742,15 @@ __export(repos_exports, {
|
|||||||
listGroups: () => listGroups,
|
listGroups: () => listGroups,
|
||||||
listHealthCheckTargets: () => listHealthCheckTargets,
|
listHealthCheckTargets: () => listHealthCheckTargets,
|
||||||
listHealthChecks: () => listHealthChecks,
|
listHealthChecks: () => listHealthChecks,
|
||||||
|
listHealthProbeLogForService: () => listHealthProbeLogForService,
|
||||||
|
listIpHealthByServiceIds: () => listIpHealthByServiceIds,
|
||||||
listIpHealthStatus: () => listIpHealthStatus,
|
listIpHealthStatus: () => listIpHealthStatus,
|
||||||
listNodes: () => listNodes,
|
listNodes: () => listNodes,
|
||||||
listNotificationLog: () => listNotificationLog,
|
listNotificationLog: () => listNotificationLog,
|
||||||
listOriginIpsForFqdn: () => listOriginIpsForFqdn,
|
listOriginIpsForFqdn: () => listOriginIpsForFqdn,
|
||||||
listRecordsForBinding: () => listRecordsForBinding,
|
listRecordsForBinding: () => listRecordsForBinding,
|
||||||
listServiceGroups: () => listServiceGroups,
|
listServiceGroups: () => listServiceGroups,
|
||||||
|
listServiceIpRows: () => listServiceIpRows,
|
||||||
listServiceIps: () => listServiceIps,
|
listServiceIps: () => listServiceIps,
|
||||||
listServices: () => listServices,
|
listServices: () => listServices,
|
||||||
listServicesByGroup: () => listServicesByGroup,
|
listServicesByGroup: () => listServicesByGroup,
|
||||||
@@ -658,6 +772,7 @@ __export(repos_exports, {
|
|||||||
setServiceEnabled: () => setServiceEnabled,
|
setServiceEnabled: () => setServiceEnabled,
|
||||||
setServiceGroup: () => setServiceGroup,
|
setServiceGroup: () => setServiceGroup,
|
||||||
setServiceGroupEnabled: () => setServiceGroupEnabled,
|
setServiceGroupEnabled: () => setServiceGroupEnabled,
|
||||||
|
setServiceIpEnabled: () => setServiceIpEnabled,
|
||||||
setServiceLb: () => setServiceLb,
|
setServiceLb: () => setServiceLb,
|
||||||
unlinkBindingRecord: () => unlinkBindingRecord,
|
unlinkBindingRecord: () => unlinkBindingRecord,
|
||||||
unlinkGroupDnsRecord: () => unlinkGroupDnsRecord,
|
unlinkGroupDnsRecord: () => unlinkGroupDnsRecord,
|
||||||
@@ -677,7 +792,15 @@ __export(repos_exports, {
|
|||||||
upsertIpHealthStatus: () => upsertIpHealthStatus,
|
upsertIpHealthStatus: () => upsertIpHealthStatus,
|
||||||
upsertSubdomain: () => upsertSubdomain
|
upsertSubdomain: () => upsertSubdomain
|
||||||
});
|
});
|
||||||
import { dnsRecordNamesMatch, isIpLiteral } from "@cfdm/shared";
|
import {
|
||||||
|
derivePrimaryProvider,
|
||||||
|
dnsRecordNamesMatch,
|
||||||
|
isIpLiteral,
|
||||||
|
parseHealthAggregate,
|
||||||
|
parseHealthProviders,
|
||||||
|
serializeHealthProviders,
|
||||||
|
normalizeStatusProvider
|
||||||
|
} from "@cfdm/shared";
|
||||||
import { and as and2, asc, count, eq as eq3, isNull, like, notInArray, or as or2, sql as sql2 } from "drizzle-orm";
|
import { and as and2, asc, count, eq as eq3, isNull, like, notInArray, or as or2, sql as sql2 } from "drizzle-orm";
|
||||||
function listGroups(db) {
|
function listGroups(db) {
|
||||||
return db.select().from(groups).orderBy(asc(groups.name)).all();
|
return db.select().from(groups).orderBy(asc(groups.name)).all();
|
||||||
@@ -1064,6 +1187,60 @@ function deleteService(db, id) {
|
|||||||
const result = db.delete(services).where(eq3(services.id, id)).run();
|
const result = db.delete(services).where(eq3(services.id, id)).run();
|
||||||
if (result.changes === 0) throw new NotFoundError(`service ${id}`);
|
if (result.changes === 0) throw new NotFoundError(`service ${id}`);
|
||||||
}
|
}
|
||||||
|
function healthProviderColumns(patch) {
|
||||||
|
const out = {};
|
||||||
|
if (patch.health_check_providers !== void 0) {
|
||||||
|
const list = parseHealthProviders(patch.health_check_providers);
|
||||||
|
out.health_check_providers = serializeHealthProviders(list);
|
||||||
|
out.health_check_provider = derivePrimaryProvider(list);
|
||||||
|
} else if (patch.health_check_provider !== void 0) {
|
||||||
|
const list = parseHealthProviders(null, patch.health_check_provider);
|
||||||
|
out.health_check_providers = serializeHealthProviders(list);
|
||||||
|
out.health_check_provider = derivePrimaryProvider(list);
|
||||||
|
}
|
||||||
|
if (patch.health_check_aggregate !== void 0) {
|
||||||
|
out.health_check_aggregate = parseHealthAggregate(
|
||||||
|
patch.health_check_aggregate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function mapHealthFields(row) {
|
||||||
|
const providers = parseHealthProviders(
|
||||||
|
row.health_check_providers,
|
||||||
|
row.health_check_provider
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
health_check_providers: providers,
|
||||||
|
health_check_provider: derivePrimaryProvider(providers),
|
||||||
|
health_check_aggregate: parseHealthAggregate(row.health_check_aggregate)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function mapServiceBinding(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
domain_id: row.domain_id,
|
||||||
|
service_id: row.service_id,
|
||||||
|
hostname: row.hostname,
|
||||||
|
cname_target: row.cname_target,
|
||||||
|
dns_record_id: row.dns_record_id,
|
||||||
|
lb_mode: row.lb_mode,
|
||||||
|
health_check_enabled: Boolean(row.health_check_enabled),
|
||||||
|
health_check_type: row.health_check_type,
|
||||||
|
health_check_port: row.health_check_port,
|
||||||
|
health_check_path: row.health_check_path,
|
||||||
|
health_check_expected_status: row.health_check_expected_status,
|
||||||
|
health_check_interval_sec: row.health_check_interval_sec,
|
||||||
|
health_check_timeout_ms: row.health_check_timeout_ms,
|
||||||
|
health_check_verify_tls: Boolean(row.health_check_verify_tls),
|
||||||
|
...mapHealthFields(row),
|
||||||
|
cert_monitoring: row.cert_monitoring ?? "auto",
|
||||||
|
routing_strategy: row.routing_strategy,
|
||||||
|
operation_version: row.operation_version,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at
|
||||||
|
};
|
||||||
|
}
|
||||||
function mapServiceGroup(row) {
|
function mapServiceGroup(row) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -1081,6 +1258,7 @@ function mapServiceGroup(row) {
|
|||||||
health_check_interval_sec: row.health_check_interval_sec,
|
health_check_interval_sec: row.health_check_interval_sec,
|
||||||
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),
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
updated_at: row.updated_at
|
updated_at: row.updated_at
|
||||||
};
|
};
|
||||||
@@ -1107,7 +1285,12 @@ function createServiceGroup(db, name, groupType, icon, domain, lbPatch) {
|
|||||||
health_check_expected_status: lbPatch?.health_check_expected_status ?? null,
|
health_check_expected_status: lbPatch?.health_check_expected_status ?? null,
|
||||||
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
|
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
|
||||||
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3e3,
|
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3e3,
|
||||||
health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false
|
health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false,
|
||||||
|
...healthProviderColumns({
|
||||||
|
health_check_provider: lbPatch?.health_check_provider ?? "local",
|
||||||
|
health_check_providers: lbPatch?.health_check_providers,
|
||||||
|
health_check_aggregate: lbPatch?.health_check_aggregate ?? "majority"
|
||||||
|
})
|
||||||
}).returning({ id: serviceGroups.id }).get().id;
|
}).returning({ id: serviceGroups.id }).get().id;
|
||||||
return getServiceGroup(db, id);
|
return getServiceGroup(db, id);
|
||||||
}
|
}
|
||||||
@@ -1137,6 +1320,7 @@ function updateServiceGroup(db, id, name, groupType, icon, domain, lbPatch) {
|
|||||||
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
|
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
|
||||||
if (lbPatch.health_check_verify_tls !== void 0)
|
if (lbPatch.health_check_verify_tls !== void 0)
|
||||||
update.health_check_verify_tls = lbPatch.health_check_verify_tls;
|
update.health_check_verify_tls = lbPatch.health_check_verify_tls;
|
||||||
|
Object.assign(update, healthProviderColumns(lbPatch));
|
||||||
}
|
}
|
||||||
const result = db.update(serviceGroups).set(update).where(eq3(serviceGroups.id, id)).run();
|
const result = db.update(serviceGroups).set(update).where(eq3(serviceGroups.id, id)).run();
|
||||||
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
|
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
|
||||||
@@ -1154,16 +1338,32 @@ function deleteServiceGroup(db, id) {
|
|||||||
function insertServiceIpIfMissing(db, serviceId, ip) {
|
function insertServiceIpIfMissing(db, serviceId, ip) {
|
||||||
const existing = db.select({ ip: serviceIps.ip }).from(serviceIps).where(and2(eq3(serviceIps.service_id, serviceId), eq3(serviceIps.ip, ip))).get();
|
const existing = db.select({ ip: serviceIps.ip }).from(serviceIps).where(and2(eq3(serviceIps.service_id, serviceId), eq3(serviceIps.ip, ip))).get();
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
db.insert(serviceIps).values({ service_id: serviceId, ip }).run();
|
db.insert(serviceIps).values({ service_id: serviceId, ip, enabled: true }).run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function listServiceIpRows(db, serviceId) {
|
||||||
|
return db.select({ ip: serviceIps.ip, enabled: serviceIps.enabled }).from(serviceIps).where(eq3(serviceIps.service_id, serviceId)).all().map((row) => ({ ip: row.ip, enabled: Boolean(row.enabled) }));
|
||||||
|
}
|
||||||
function listServiceIps(db, serviceId) {
|
function listServiceIps(db, serviceId) {
|
||||||
return db.select({ ip: serviceIps.ip }).from(serviceIps).where(eq3(serviceIps.service_id, serviceId)).all().map((r) => r.ip);
|
return listServiceIpRows(db, serviceId).map((row) => row.ip);
|
||||||
|
}
|
||||||
|
function setServiceIpEnabled(db, serviceId, ip, enabled) {
|
||||||
|
const result = db.update(serviceIps).set({ enabled }).where(and2(eq3(serviceIps.service_id, serviceId), eq3(serviceIps.ip, ip))).run();
|
||||||
|
if (result.changes === 0) {
|
||||||
|
throw new NotFoundError(`service ip ${ip}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function replaceServiceIps(db, serviceId, ips) {
|
function replaceServiceIps(db, serviceId, ips) {
|
||||||
|
const previous = new Map(
|
||||||
|
listServiceIpRows(db, serviceId).map((row) => [row.ip, row.enabled])
|
||||||
|
);
|
||||||
db.delete(serviceIps).where(eq3(serviceIps.service_id, serviceId)).run();
|
db.delete(serviceIps).where(eq3(serviceIps.service_id, serviceId)).run();
|
||||||
for (const ip of ips) {
|
for (const ip of ips) {
|
||||||
db.insert(serviceIps).values({ service_id: serviceId, ip }).run();
|
db.insert(serviceIps).values({
|
||||||
|
service_id: serviceId,
|
||||||
|
ip,
|
||||||
|
enabled: previous.get(ip) ?? true
|
||||||
|
}).run();
|
||||||
ensureNode(db, serviceId, ip);
|
ensureNode(db, serviceId, ip);
|
||||||
}
|
}
|
||||||
const keep = new Set(ips);
|
const keep = new Set(ips);
|
||||||
@@ -1430,6 +1630,9 @@ function updateBindingLbConfig(db, bindingId, patch) {
|
|||||||
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 !== void 0)
|
if (patch.health_check_verify_tls !== void 0)
|
||||||
update.health_check_verify_tls = patch.health_check_verify_tls;
|
update.health_check_verify_tls = patch.health_check_verify_tls;
|
||||||
|
if (patch.cert_monitoring !== void 0)
|
||||||
|
update.cert_monitoring = patch.cert_monitoring;
|
||||||
|
Object.assign(update, healthProviderColumns(patch));
|
||||||
db.update(serviceBindings).set(update).where(eq3(serviceBindings.id, bindingId)).run();
|
db.update(serviceBindings).set(update).where(eq3(serviceBindings.id, bindingId)).run();
|
||||||
}
|
}
|
||||||
function setBindingCnameTarget(db, bindingId, target) {
|
function setBindingCnameTarget(db, bindingId, target) {
|
||||||
@@ -1488,7 +1691,8 @@ function dnsRecordMatchesHostname(recordName, hostname, zoneName) {
|
|||||||
var SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
|
var SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
|
||||||
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
|
sb.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.cname_target,
|
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider,
|
||||||
|
sb.health_check_providers, sb.health_check_aggregate, sb.cert_monitoring, sb.cname_target,
|
||||||
d.zone_name, d.group_id, g.name AS group_name,
|
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,
|
||||||
@@ -1536,6 +1740,8 @@ function enrichServiceBindingView(db, row) {
|
|||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
cname_target: row.cname_target ?? null,
|
cname_target: row.cname_target ?? null,
|
||||||
|
cert_monitoring: row.cert_monitoring ?? "auto",
|
||||||
|
...mapHealthFields(row),
|
||||||
target_ips,
|
target_ips,
|
||||||
target_ip: target_ips[0] ?? null,
|
target_ip: target_ips[0] ?? null,
|
||||||
target_ip_weights,
|
target_ip_weights,
|
||||||
@@ -1567,16 +1773,20 @@ function listBindingsByDomain(db, domainId) {
|
|||||||
`).map((row) => enrichServiceBindingView(db, row));
|
`).map((row) => enrichServiceBindingView(db, row));
|
||||||
}
|
}
|
||||||
function listBindingsByService(db, serviceId) {
|
function listBindingsByService(db, serviceId) {
|
||||||
return db.all(sql2`
|
const rows = db.all(sql2`
|
||||||
SELECT sb.*, d.zone_name FROM service_bindings sb
|
SELECT sb.*, d.zone_name FROM service_bindings sb
|
||||||
JOIN domains d ON d.id = sb.domain_id
|
JOIN domains d ON d.id = sb.domain_id
|
||||||
WHERE sb.service_id = ${serviceId}
|
WHERE sb.service_id = ${serviceId}
|
||||||
`);
|
`);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...mapServiceBinding(row),
|
||||||
|
zone_name: row.zone_name
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
function getBinding(db, id) {
|
function getBinding(db, id) {
|
||||||
const row = db.select().from(serviceBindings).where(eq3(serviceBindings.id, id)).get();
|
const row = db.select().from(serviceBindings).where(eq3(serviceBindings.id, id)).get();
|
||||||
if (!row) throw new NotFoundError(`service binding ${id}`);
|
if (!row) throw new NotFoundError(`service binding ${id}`);
|
||||||
return row;
|
return mapServiceBinding(row);
|
||||||
}
|
}
|
||||||
function getBindingView(db, id) {
|
function getBindingView(db, id) {
|
||||||
const rows = db.all(sql2`
|
const rows = db.all(sql2`
|
||||||
@@ -1599,7 +1809,8 @@ function findBinding(db, serviceId, domainId, hostname) {
|
|||||||
eq3(serviceBindings.hostname, hostname)
|
eq3(serviceBindings.hostname, hostname)
|
||||||
)
|
)
|
||||||
).get();
|
).get();
|
||||||
return row ?? null;
|
if (!row) return null;
|
||||||
|
return mapServiceBinding(row);
|
||||||
}
|
}
|
||||||
function insertBinding(db, domainId, serviceId, hostname, dnsRecordId) {
|
function insertBinding(db, domainId, serviceId, hostname, dnsRecordId) {
|
||||||
const id = db.insert(serviceBindings).values({
|
const id = db.insert(serviceBindings).values({
|
||||||
@@ -1625,7 +1836,7 @@ function setBindingDnsRecordId(db, bindingId, dnsRecordId) {
|
|||||||
}).where(eq3(serviceBindings.id, bindingId)).run();
|
}).where(eq3(serviceBindings.id, bindingId)).run();
|
||||||
}
|
}
|
||||||
function bindingsToRemove(db, serviceId, keepIds) {
|
function bindingsToRemove(db, serviceId, keepIds) {
|
||||||
const all = db.select().from(serviceBindings).where(eq3(serviceBindings.service_id, serviceId)).all();
|
const all = db.select().from(serviceBindings).where(eq3(serviceBindings.service_id, serviceId)).all().map(mapServiceBinding);
|
||||||
return all.filter((b) => !keepIds.includes(b.id));
|
return all.filter((b) => !keepIds.includes(b.id));
|
||||||
}
|
}
|
||||||
function deleteBindingsExcept(db, serviceId, keepIds) {
|
function deleteBindingsExcept(db, serviceId, keepIds) {
|
||||||
@@ -1640,23 +1851,57 @@ function deleteBinding(db, id) {
|
|||||||
const result = db.delete(serviceBindings).where(eq3(serviceBindings.id, id)).run();
|
const result = db.delete(serviceBindings).where(eq3(serviceBindings.id, id)).run();
|
||||||
if (result.changes === 0) throw new NotFoundError(`service binding ${id}`);
|
if (result.changes === 0) throw new NotFoundError(`service binding ${id}`);
|
||||||
}
|
}
|
||||||
function listCertificates(db, status) {
|
var CERTIFICATE_SELECT = `c.id, c.domain_id, c.subdomain_id, c.service_id, c.hostname,
|
||||||
if (status) {
|
c.expires_at, c.last_checked_at, c.last_error, c.status, c.created_at, c.updated_at,
|
||||||
return db.select().from(certificates).where(eq3(certificates.status, status)).orderBy(asc(certificates.expires_at)).all();
|
s.name AS service_name`;
|
||||||
|
function mapCertificate(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
domain_id: row.domain_id,
|
||||||
|
subdomain_id: row.subdomain_id,
|
||||||
|
service_id: row.service_id ?? null,
|
||||||
|
service_name: row.service_name ?? null,
|
||||||
|
hostname: row.hostname,
|
||||||
|
expires_at: row.expires_at,
|
||||||
|
last_checked_at: row.last_checked_at,
|
||||||
|
last_error: row.last_error,
|
||||||
|
status: row.status,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return db.select().from(certificates).orderBy(asc(certificates.expires_at)).all();
|
function listCertificates(db, status) {
|
||||||
|
const rows = status ? db.all(sql2`
|
||||||
|
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
|
||||||
|
FROM certificates c
|
||||||
|
LEFT JOIN services s ON s.id = c.service_id
|
||||||
|
WHERE c.status = ${status}
|
||||||
|
ORDER BY c.expires_at ASC
|
||||||
|
`) : db.all(sql2`
|
||||||
|
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
|
||||||
|
FROM certificates c
|
||||||
|
LEFT JOIN services s ON s.id = c.service_id
|
||||||
|
ORDER BY c.expires_at ASC
|
||||||
|
`);
|
||||||
|
return rows.map(mapCertificate);
|
||||||
}
|
}
|
||||||
function getCertificate(db, id) {
|
function getCertificate(db, id) {
|
||||||
const row = db.select().from(certificates).where(eq3(certificates.id, id)).get();
|
const row = db.all(sql2`
|
||||||
|
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
|
||||||
|
FROM certificates c
|
||||||
|
LEFT JOIN services s ON s.id = c.service_id
|
||||||
|
WHERE c.id = ${id}
|
||||||
|
`)[0];
|
||||||
if (!row) throw new NotFoundError(`certificate ${id}`);
|
if (!row) throw new NotFoundError(`certificate ${id}`);
|
||||||
return row;
|
return mapCertificate(row);
|
||||||
}
|
}
|
||||||
function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, status, lastError) {
|
function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, status, lastError, serviceId) {
|
||||||
const existing = db.select().from(certificates).where(eq3(certificates.hostname, hostname)).get();
|
const existing = db.select().from(certificates).where(eq3(certificates.hostname, hostname)).get();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.update(certificates).set({
|
db.update(certificates).set({
|
||||||
domain_id: domainId,
|
domain_id: domainId,
|
||||||
subdomain_id: subdomainId,
|
subdomain_id: subdomainId,
|
||||||
|
service_id: serviceId === void 0 ? existing.service_id : serviceId,
|
||||||
expires_at: expiresAt,
|
expires_at: expiresAt,
|
||||||
last_checked_at: sql2`datetime('now')`,
|
last_checked_at: sql2`datetime('now')`,
|
||||||
last_error: lastError,
|
last_error: lastError,
|
||||||
@@ -1668,6 +1913,7 @@ function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt,
|
|||||||
const id = db.insert(certificates).values({
|
const id = db.insert(certificates).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: sql2`datetime('now')`,
|
last_checked_at: sql2`datetime('now')`,
|
||||||
@@ -1709,7 +1955,7 @@ function finishSyncJob(db, id, status, message) {
|
|||||||
function listIpHealthStatus(db, scope, refId) {
|
function listIpHealthStatus(db, scope, refId) {
|
||||||
return db.all(sql2`
|
return db.all(sql2`
|
||||||
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
|
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
|
||||||
last_checked_at, last_error
|
last_checked_at, last_error, colo, provider
|
||||||
FROM ip_health_status
|
FROM ip_health_status
|
||||||
WHERE scope = ${scope} AND ref_id = ${refId}
|
WHERE scope = ${scope} AND ref_id = ${refId}
|
||||||
`);
|
`);
|
||||||
@@ -1822,6 +2068,47 @@ function aggregateIpHealthByServiceIds(db, serviceIds) {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
function listIpHealthByServiceIds(db, serviceIds) {
|
||||||
|
const result = /* @__PURE__ */ new Map();
|
||||||
|
if (serviceIds.length === 0) return result;
|
||||||
|
const idList = sql2.join(
|
||||||
|
serviceIds.map((id) => sql2`${id}`),
|
||||||
|
sql2`, `
|
||||||
|
);
|
||||||
|
const rows = db.all(sql2`
|
||||||
|
SELECT sb.service_id AS service_id,
|
||||||
|
ihs.ip AS ip,
|
||||||
|
${WORST_HEALTH_SQL} AS health_status,
|
||||||
|
MAX(ihs.latency_ms) AS health_latency_ms,
|
||||||
|
MAX(ihs.last_checked_at) AS last_checked_at,
|
||||||
|
MAX(ihs.last_error) AS last_error,
|
||||||
|
MAX(ihs.provider) AS provider,
|
||||||
|
MAX(ihs.colo) AS colo
|
||||||
|
FROM ip_health_status ihs
|
||||||
|
INNER JOIN service_bindings sb
|
||||||
|
ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
|
||||||
|
WHERE sb.service_id IN (${idList})
|
||||||
|
GROUP BY sb.service_id, ihs.ip
|
||||||
|
`);
|
||||||
|
for (const row of rows) {
|
||||||
|
const parsed = parseHealthAggregateRow({
|
||||||
|
health_status: row.health_status,
|
||||||
|
health_latency_ms: row.health_latency_ms
|
||||||
|
});
|
||||||
|
const list = result.get(row.service_id) ?? [];
|
||||||
|
list.push({
|
||||||
|
ip: row.ip,
|
||||||
|
status: parsed.health_status,
|
||||||
|
latency_ms: parsed.health_latency_ms,
|
||||||
|
last_checked_at: row.last_checked_at,
|
||||||
|
last_error: row.last_error,
|
||||||
|
provider: normalizeStatusProvider(row.provider),
|
||||||
|
colo: row.colo
|
||||||
|
});
|
||||||
|
result.set(row.service_id, list);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
function mergeHealthAggregates(parts) {
|
function mergeHealthAggregates(parts) {
|
||||||
const rank = {
|
const rank = {
|
||||||
unknown: 0,
|
unknown: 0,
|
||||||
@@ -1851,20 +2138,24 @@ function mergeHealthAggregates(parts) {
|
|||||||
function getIpHealthStatusRow(db, scope, refId, ip) {
|
function getIpHealthStatusRow(db, scope, refId, ip) {
|
||||||
const rows = db.all(sql2`
|
const rows = db.all(sql2`
|
||||||
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
|
SELECT scope, ref_id, ip, status, latency_ms, consecutive_failures,
|
||||||
consecutive_successes, last_checked_at, last_error
|
consecutive_successes, last_checked_at, last_error, colo, provider
|
||||||
FROM ip_health_status
|
FROM ip_health_status
|
||||||
WHERE scope = ${scope} AND ref_id = ${refId} AND ip = ${ip}
|
WHERE scope = ${scope} AND ref_id = ${refId} AND ip = ${ip}
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`);
|
`);
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecutiveFailures, lastError, consecutiveSuccesses = 0) {
|
function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecutiveFailures, lastError, consecutiveSuccesses = 0, extras) {
|
||||||
|
const colo = extras?.colo ?? null;
|
||||||
|
const provider = extras?.provider ?? "local";
|
||||||
db.run(sql2`
|
db.run(sql2`
|
||||||
INSERT INTO ip_health_status
|
INSERT INTO ip_health_status
|
||||||
(scope, ref_id, ip, status, latency_ms, consecutive_failures,
|
(scope, ref_id, ip, status, latency_ms, consecutive_failures,
|
||||||
consecutive_successes, last_checked_at, last_error, created_at, updated_at)
|
consecutive_successes, last_checked_at, last_error, colo, provider,
|
||||||
|
created_at, updated_at)
|
||||||
VALUES (${scope}, ${refId}, ${ip}, ${status}, ${latencyMs}, ${consecutiveFailures},
|
VALUES (${scope}, ${refId}, ${ip}, ${status}, ${latencyMs}, ${consecutiveFailures},
|
||||||
${consecutiveSuccesses}, datetime('now'), ${lastError}, datetime('now'), datetime('now'))
|
${consecutiveSuccesses}, datetime('now'), ${lastError}, ${colo}, ${provider},
|
||||||
|
datetime('now'), datetime('now'))
|
||||||
ON CONFLICT(scope, ref_id, ip) DO UPDATE SET
|
ON CONFLICT(scope, ref_id, ip) DO UPDATE SET
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
latency_ms = excluded.latency_ms,
|
latency_ms = excluded.latency_ms,
|
||||||
@@ -1872,6 +2163,8 @@ function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecuti
|
|||||||
consecutive_successes = excluded.consecutive_successes,
|
consecutive_successes = excluded.consecutive_successes,
|
||||||
last_checked_at = excluded.last_checked_at,
|
last_checked_at = excluded.last_checked_at,
|
||||||
last_error = excluded.last_error,
|
last_error = excluded.last_error,
|
||||||
|
colo = excluded.colo,
|
||||||
|
provider = excluded.provider,
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -1924,6 +2217,40 @@ function pruneStaleIpHealthStatus(db, activeTargets) {
|
|||||||
}
|
}
|
||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
function normalizeCnameHost(target, zoneName) {
|
||||||
|
const trimmed = target.trim().toLowerCase().replace(/\.+$/, "");
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (trimmed.includes(".")) return trimmed;
|
||||||
|
const zone = zoneName.trim().toLowerCase().replace(/\.+$/, "");
|
||||||
|
return zone ? `${trimmed}.${zone}` : trimmed;
|
||||||
|
}
|
||||||
|
function resolveCnameProbeIps(db, cnameTarget, zoneName, serviceId) {
|
||||||
|
const fqdn = normalizeCnameHost(cnameTarget, zoneName);
|
||||||
|
if (fqdn) {
|
||||||
|
const fromDns = listOriginIpsForFqdn(db, fqdn).filter(isIpLiteral);
|
||||||
|
if (fromDns.length > 0) return [...new Set(fromDns)];
|
||||||
|
}
|
||||||
|
if (serviceId > 0) {
|
||||||
|
return [...new Set(listServiceIps(db, serviceId).filter(isIpLiteral))];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
function expandCnameHealthTargets(db, rows) {
|
||||||
|
const expanded = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const ips = resolveCnameProbeIps(
|
||||||
|
db,
|
||||||
|
row.ip,
|
||||||
|
row.zone_name ?? "",
|
||||||
|
row.service_id ?? 0
|
||||||
|
);
|
||||||
|
const resolved = ips.length > 0 ? ips : [row.ip];
|
||||||
|
for (const ip of resolved) {
|
||||||
|
expanded.push({ ...row, ip });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
function listHealthCheckTargets(db) {
|
function listHealthCheckTargets(db) {
|
||||||
const fqdnExpr = sql2`CASE WHEN sb.hostname = '@' OR sb.hostname IS NULL THEN d.zone_name ELSE sb.hostname || '.' || d.zone_name END`;
|
const fqdnExpr = sql2`CASE WHEN sb.hostname = '@' OR sb.hostname IS NULL THEN d.zone_name ELSE sb.hostname || '.' || d.zone_name END`;
|
||||||
const bindingTargets = db.all(sql2`
|
const bindingTargets = db.all(sql2`
|
||||||
@@ -1934,12 +2261,15 @@ function listHealthCheckTargets(db) {
|
|||||||
sb.health_check_path AS path,
|
sb.health_check_path AS path,
|
||||||
sb.health_check_expected_status AS expected_status,
|
sb.health_check_expected_status AS expected_status,
|
||||||
sb.health_check_timeout_ms AS timeout_ms,
|
sb.health_check_timeout_ms AS timeout_ms,
|
||||||
sb.health_check_verify_tls AS verify_tls
|
sb.health_check_verify_tls AS verify_tls,
|
||||||
|
sb.health_check_providers AS providers_json,
|
||||||
|
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
||||||
|
COALESCE(sb.health_check_provider, 'local') AS provider
|
||||||
FROM service_binding_ips sbi
|
FROM service_binding_ips sbi
|
||||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||||
JOIN domains d ON d.id = sb.domain_id
|
JOIN domains d ON d.id = sb.domain_id
|
||||||
WHERE sb.health_check_enabled = 1
|
WHERE sb.health_check_enabled = 1
|
||||||
`);
|
`).filter((t) => isIpLiteral(t.ip));
|
||||||
const groupTargets = db.all(sql2`
|
const groupTargets = db.all(sql2`
|
||||||
SELECT DISTINCT 'group' AS scope, sg.id AS ref_id, sbi.ip,
|
SELECT DISTINCT 'group' AS scope, sg.id AS ref_id, sbi.ip,
|
||||||
sg.domain AS hostname,
|
sg.domain AS hostname,
|
||||||
@@ -1948,7 +2278,10 @@ function listHealthCheckTargets(db) {
|
|||||||
sg.health_check_path AS path,
|
sg.health_check_path AS path,
|
||||||
sg.health_check_expected_status AS expected_status,
|
sg.health_check_expected_status AS expected_status,
|
||||||
sg.health_check_timeout_ms AS timeout_ms,
|
sg.health_check_timeout_ms AS timeout_ms,
|
||||||
sg.health_check_verify_tls AS verify_tls
|
sg.health_check_verify_tls AS verify_tls,
|
||||||
|
sg.health_check_providers AS providers_json,
|
||||||
|
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||||
|
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||||
FROM service_binding_ips sbi
|
FROM service_binding_ips sbi
|
||||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||||
JOIN services s ON s.id = sb.service_id
|
JOIN services s ON s.id = sb.service_id
|
||||||
@@ -1959,7 +2292,7 @@ function listHealthCheckTargets(db) {
|
|||||||
AND s.enabled = 1
|
AND s.enabled = 1
|
||||||
AND sg.enabled = 1
|
AND sg.enabled = 1
|
||||||
AND (sb.cname_target IS NULL OR sb.cname_target = '')
|
AND (sb.cname_target IS NULL OR sb.cname_target = '')
|
||||||
`);
|
`).filter((t) => isIpLiteral(t.ip));
|
||||||
const groupInheritedBindingTargets = db.all(sql2`
|
const groupInheritedBindingTargets = db.all(sql2`
|
||||||
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
|
SELECT 'binding' AS scope, sb.id AS ref_id, sbi.ip,
|
||||||
${fqdnExpr} AS hostname,
|
${fqdnExpr} AS hostname,
|
||||||
@@ -1968,7 +2301,10 @@ function listHealthCheckTargets(db) {
|
|||||||
sg.health_check_path AS path,
|
sg.health_check_path AS path,
|
||||||
sg.health_check_expected_status AS expected_status,
|
sg.health_check_expected_status AS expected_status,
|
||||||
sg.health_check_timeout_ms AS timeout_ms,
|
sg.health_check_timeout_ms AS timeout_ms,
|
||||||
sg.health_check_verify_tls AS verify_tls
|
sg.health_check_verify_tls AS verify_tls,
|
||||||
|
sg.health_check_providers AS providers_json,
|
||||||
|
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||||
|
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||||
FROM service_binding_ips sbi
|
FROM service_binding_ips sbi
|
||||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||||
JOIN domains d ON d.id = sb.domain_id
|
JOIN domains d ON d.id = sb.domain_id
|
||||||
@@ -1979,8 +2315,10 @@ function listHealthCheckTargets(db) {
|
|||||||
AND s.enabled = 1
|
AND s.enabled = 1
|
||||||
AND sg.enabled = 1
|
AND sg.enabled = 1
|
||||||
AND sb.health_check_enabled = 0
|
AND sb.health_check_enabled = 0
|
||||||
`);
|
`).filter((t) => isIpLiteral(t.ip));
|
||||||
const cnameBindingTargets = db.all(sql2`
|
const cnameBindingTargets = expandCnameHealthTargets(
|
||||||
|
db,
|
||||||
|
db.all(sql2`
|
||||||
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
||||||
${fqdnExpr} AS hostname,
|
${fqdnExpr} AS hostname,
|
||||||
sb.health_check_type AS type,
|
sb.health_check_type AS type,
|
||||||
@@ -1988,7 +2326,12 @@ function listHealthCheckTargets(db) {
|
|||||||
sb.health_check_path AS path,
|
sb.health_check_path AS path,
|
||||||
sb.health_check_expected_status AS expected_status,
|
sb.health_check_expected_status AS expected_status,
|
||||||
sb.health_check_timeout_ms AS timeout_ms,
|
sb.health_check_timeout_ms AS timeout_ms,
|
||||||
sb.health_check_verify_tls AS verify_tls
|
sb.health_check_verify_tls AS verify_tls,
|
||||||
|
sb.health_check_providers AS providers_json,
|
||||||
|
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
||||||
|
COALESCE(sb.health_check_provider, 'local') AS provider,
|
||||||
|
d.zone_name AS zone_name,
|
||||||
|
sb.service_id AS service_id
|
||||||
FROM service_bindings sb
|
FROM service_bindings sb
|
||||||
JOIN domains d ON d.id = sb.domain_id
|
JOIN domains d ON d.id = sb.domain_id
|
||||||
JOIN services s ON s.id = sb.service_id
|
JOIN services s ON s.id = sb.service_id
|
||||||
@@ -1996,8 +2339,11 @@ function listHealthCheckTargets(db) {
|
|||||||
AND sb.cname_target IS NOT NULL
|
AND sb.cname_target IS NOT NULL
|
||||||
AND sb.cname_target <> ''
|
AND sb.cname_target <> ''
|
||||||
AND s.enabled = 1
|
AND s.enabled = 1
|
||||||
`);
|
`)
|
||||||
const groupInheritedCnameBindingTargets = db.all(sql2`
|
);
|
||||||
|
const groupInheritedCnameBindingTargets = expandCnameHealthTargets(
|
||||||
|
db,
|
||||||
|
db.all(sql2`
|
||||||
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
SELECT 'binding' AS scope, sb.id AS ref_id, sb.cname_target AS ip,
|
||||||
${fqdnExpr} AS hostname,
|
${fqdnExpr} AS hostname,
|
||||||
sg.health_check_type AS type,
|
sg.health_check_type AS type,
|
||||||
@@ -2005,7 +2351,12 @@ function listHealthCheckTargets(db) {
|
|||||||
sg.health_check_path AS path,
|
sg.health_check_path AS path,
|
||||||
sg.health_check_expected_status AS expected_status,
|
sg.health_check_expected_status AS expected_status,
|
||||||
sg.health_check_timeout_ms AS timeout_ms,
|
sg.health_check_timeout_ms AS timeout_ms,
|
||||||
sg.health_check_verify_tls AS verify_tls
|
sg.health_check_verify_tls AS verify_tls,
|
||||||
|
sg.health_check_providers AS providers_json,
|
||||||
|
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||||
|
COALESCE(sg.health_check_provider, 'local') AS provider,
|
||||||
|
d.zone_name AS zone_name,
|
||||||
|
sb.service_id AS service_id
|
||||||
FROM service_bindings sb
|
FROM service_bindings sb
|
||||||
JOIN domains d ON d.id = sb.domain_id
|
JOIN domains d ON d.id = sb.domain_id
|
||||||
JOIN services s ON s.id = sb.service_id
|
JOIN services s ON s.id = sb.service_id
|
||||||
@@ -2017,17 +2368,33 @@ function listHealthCheckTargets(db) {
|
|||||||
AND sb.health_check_enabled = 0
|
AND sb.health_check_enabled = 0
|
||||||
AND sb.cname_target IS NOT NULL
|
AND sb.cname_target IS NOT NULL
|
||||||
AND sb.cname_target <> ''
|
AND sb.cname_target <> ''
|
||||||
`);
|
`)
|
||||||
|
);
|
||||||
return [
|
return [
|
||||||
...bindingTargets,
|
...bindingTargets,
|
||||||
...groupTargets,
|
...groupTargets,
|
||||||
...groupInheritedBindingTargets,
|
...groupInheritedBindingTargets,
|
||||||
...cnameBindingTargets,
|
...cnameBindingTargets,
|
||||||
...groupInheritedCnameBindingTargets
|
...groupInheritedCnameBindingTargets
|
||||||
].map((t) => ({
|
].map((t) => {
|
||||||
...t,
|
const row = t;
|
||||||
verify_tls: Boolean(t.verify_tls)
|
const providers = parseHealthProviders(row.providers_json, row.provider);
|
||||||
}));
|
return {
|
||||||
|
scope: row.scope,
|
||||||
|
ref_id: row.ref_id,
|
||||||
|
ip: row.ip,
|
||||||
|
hostname: row.hostname,
|
||||||
|
type: row.type,
|
||||||
|
port: row.port,
|
||||||
|
path: row.path,
|
||||||
|
expected_status: row.expected_status,
|
||||||
|
timeout_ms: row.timeout_ms,
|
||||||
|
verify_tls: Boolean(row.verify_tls),
|
||||||
|
providers,
|
||||||
|
aggregate: parseHealthAggregate(row.aggregate),
|
||||||
|
provider: derivePrimaryProvider(providers)
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function listDomainTags(db, domainId) {
|
function listDomainTags(db, domainId) {
|
||||||
return db.select({ tag: domainTags.tag }).from(domainTags).where(eq3(domainTags.domain_id, domainId)).all().map((r) => r.tag);
|
return db.select({ tag: domainTags.tag }).from(domainTags).where(eq3(domainTags.domain_id, domainId)).all().map((r) => r.tag);
|
||||||
@@ -2121,6 +2488,46 @@ function listDomainMonitorResultsForDomain(db, domainId, limit = 50) {
|
|||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
var HEALTH_PROBE_LOG_KEEP = 50;
|
||||||
|
function insertHealthProbeLog(db, entry) {
|
||||||
|
db.insert(healthProbeLog).values({
|
||||||
|
scope: entry.scope,
|
||||||
|
ref_id: entry.refId,
|
||||||
|
ip: entry.ip,
|
||||||
|
provider: entry.provider,
|
||||||
|
status: entry.status,
|
||||||
|
ok: entry.ok,
|
||||||
|
latency_ms: entry.latencyMs,
|
||||||
|
colo: entry.colo,
|
||||||
|
error: entry.error
|
||||||
|
}).run();
|
||||||
|
db.run(sql2`
|
||||||
|
DELETE FROM health_probe_log
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM health_probe_log
|
||||||
|
WHERE scope = ${entry.scope} AND ref_id = ${entry.refId} AND ip = ${entry.ip}
|
||||||
|
ORDER BY checked_at DESC, id DESC
|
||||||
|
LIMIT -1 OFFSET ${HEALTH_PROBE_LOG_KEEP}
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
function listHealthProbeLogForService(db, serviceId, limit = 50) {
|
||||||
|
const rows = db.all(sql2`
|
||||||
|
SELECT l.id, l.scope, l.ref_id, l.ip, l.provider, l.status, l.ok,
|
||||||
|
l.latency_ms, l.colo, l.error, l.checked_at
|
||||||
|
FROM health_probe_log l
|
||||||
|
INNER JOIN service_bindings sb
|
||||||
|
ON l.scope = 'binding' AND l.ref_id = sb.id
|
||||||
|
WHERE sb.service_id = ${serviceId}
|
||||||
|
ORDER BY l.checked_at DESC, l.id DESC
|
||||||
|
LIMIT ${limit}
|
||||||
|
`);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
provider: parseHealthProviders(null, row.provider)[0] ?? "local",
|
||||||
|
ok: Boolean(row.ok)
|
||||||
|
}));
|
||||||
|
}
|
||||||
function insertNotificationLog(db, kind, refType, refId, title, message) {
|
function insertNotificationLog(db, kind, refType, refId, title, message) {
|
||||||
db.insert(notificationLog).values({
|
db.insert(notificationLog).values({
|
||||||
kind,
|
kind,
|
||||||
@@ -2158,6 +2565,7 @@ export {
|
|||||||
groups,
|
groups,
|
||||||
healthCheck,
|
healthCheck,
|
||||||
healthChecks,
|
healthChecks,
|
||||||
|
healthProbeLog,
|
||||||
ipHealthStatus,
|
ipHealthStatus,
|
||||||
listAudit,
|
listAudit,
|
||||||
nodes,
|
nodes,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE service_ips ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Local health-check engine settings (cron + state-machine thresholds).
|
||||||
|
-- NULL = inherit from process env (HEALTH_CHECK_CRON / HEALTH_*).
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_check_cron TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_degraded_failures INTEGER;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_down_failures INTEGER;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_latency_warn_ms INTEGER;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_success_recoveries INTEGER;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- XOR health-check: persist provider on bindings/groups; Worker URL/token;
|
||||||
|
-- colo + probe journal. Cloudflare = Worker edge probe, not Health Checks API.
|
||||||
|
ALTER TABLE service_bindings ADD COLUMN health_check_provider TEXT NOT NULL DEFAULT 'local';
|
||||||
|
ALTER TABLE service_groups ADD COLUMN health_check_provider TEXT NOT NULL DEFAULT 'local';
|
||||||
|
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_worker_url TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_worker_token TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE ip_health_status ADD COLUMN colo TEXT;
|
||||||
|
ALTER TABLE ip_health_status ADD COLUMN provider TEXT NOT NULL DEFAULT 'local';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS health_probe_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
ref_id INTEGER NOT NULL,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
ok INTEGER NOT NULL,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
colo TEXT,
|
||||||
|
error TEXT,
|
||||||
|
checked_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_health_probe_log_target
|
||||||
|
ON health_probe_log(scope, ref_id, ip, checked_at DESC);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE app_settings ADD COLUMN health_worker_account_id TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_worker_kv_namespace_id TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_worker_error TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_worker_deployed_at TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_worker_last_ingest_at TEXT;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
ALTER TABLE service_bindings ADD COLUMN health_check_providers TEXT;
|
||||||
|
ALTER TABLE service_bindings ADD COLUMN health_check_aggregate TEXT NOT NULL DEFAULT 'majority';
|
||||||
|
UPDATE service_bindings
|
||||||
|
SET health_check_providers = CASE
|
||||||
|
WHEN health_check_provider = 'cloudflare' THEN '["cloudflare"]'
|
||||||
|
ELSE '["local"]'
|
||||||
|
END
|
||||||
|
WHERE health_check_providers IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE service_groups ADD COLUMN health_check_providers TEXT;
|
||||||
|
ALTER TABLE service_groups ADD COLUMN health_check_aggregate TEXT NOT NULL DEFAULT 'majority';
|
||||||
|
UPDATE service_groups
|
||||||
|
SET health_check_providers = CASE
|
||||||
|
WHEN health_check_provider = 'cloudflare' THEN '["cloudflare"]'
|
||||||
|
ELSE '["local"]'
|
||||||
|
END
|
||||||
|
WHERE health_check_providers IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE app_settings ADD COLUMN globalping_token TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN globalping_locations TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN globalping_limit INTEGER;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
ALTER TABLE service_bindings ADD COLUMN cert_monitoring TEXT NOT NULL DEFAULT 'auto'
|
||||||
|
CHECK (cert_monitoring IN ('auto', 'required', 'skipped'));
|
||||||
|
|
||||||
|
ALTER TABLE certificates ADD COLUMN service_id INTEGER REFERENCES services(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
UPDATE service_bindings
|
||||||
|
SET cert_monitoring = COALESCE(
|
||||||
|
(
|
||||||
|
SELECT d.cert_monitoring FROM domains d
|
||||||
|
WHERE d.id = service_bindings.domain_id
|
||||||
|
),
|
||||||
|
'auto'
|
||||||
|
)
|
||||||
|
WHERE hostname = '@';
|
||||||
|
|
||||||
|
UPDATE service_bindings
|
||||||
|
SET cert_monitoring = COALESCE(
|
||||||
|
(
|
||||||
|
SELECT s.cert_monitoring FROM subdomains s
|
||||||
|
WHERE s.domain_id = service_bindings.domain_id
|
||||||
|
AND s.name = service_bindings.hostname
|
||||||
|
),
|
||||||
|
'auto'
|
||||||
|
)
|
||||||
|
WHERE hostname != '@';
|
||||||
|
|
||||||
|
UPDATE certificates
|
||||||
|
SET service_id = (
|
||||||
|
SELECT sb.service_id
|
||||||
|
FROM service_bindings sb
|
||||||
|
JOIN domains d ON d.id = sb.domain_id
|
||||||
|
WHERE CASE
|
||||||
|
WHEN sb.hostname = '@' THEN d.zone_name
|
||||||
|
ELSE sb.hostname || '.' || d.zone_name
|
||||||
|
END = certificates.hostname
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user