Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d97e463a | ||
|
|
be8f94143f | ||
|
|
fa7d2b6df3 | ||
|
|
7938d2f707 | ||
|
|
994e79e118 | ||
|
|
87b0f1a894 | ||
|
|
ba4e04a224 | ||
|
|
4c59780ff0 | ||
|
|
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 | ||
|
|
50c5c21c18 | ||
|
|
ab4ccbd7a1 | ||
|
|
b2dbb4ad98 | ||
|
|
3f6f402872 | ||
|
|
9c00b268dc | ||
|
|
a185b3a2cb |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ node_modules
|
|||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
**/dist
|
||||||
**/.turbo
|
**/.turbo
|
||||||
|
# Генерируется `tsr generate` в `web` build (tsc идёт до Vite-плагина).
|
||||||
apps/web/src/routeTree.gen.ts
|
apps/web/src/routeTree.gen.ts
|
||||||
apps/web/playwright-report
|
apps/web/playwright-report
|
||||||
apps/web/test-results
|
apps/web/test-results
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -35,13 +35,13 @@ Runner: `ubuntu-latest`, Docker для **docker-check** (PR) и **publish** (CD)
|
|||||||
|
|
||||||
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
||||||
|
|
||||||
Job **update-wiki** идёт **параллельно** publish (не блокирует образы): при diff `docs/Home.md` копирует файл в wiki-репозиторий (`GITEA_TOKEN`).
|
Job **update-wiki** идёт **параллельно** publish (не блокирует образы): при diff `docs/Home.md` копирует файл в wiki-репозиторий. Clone/push идут на публичный **`https://git.shx.one`** (не внутренний `gitea.server_url` / `192.168.x.x:3000`): Gitea `ROOT_URL` совпадает с Host, иначе `git-receive-pack` wiki отвечает `Repository not found`. Токен в URL `https://oauth2:<PAT>@…/*.wiki.git` — Gitea на неаутентифицированный wiki push даёт **404, не 401**, поэтому `http.extraHeader` / ASKPASS не срабатывают. Секрет: **`ACTIONS_PAT`**, fallback **`GITEA_TOKEN`**.
|
||||||
|
|
||||||
### Секреты
|
### Секреты
|
||||||
|
|
||||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `gitea.token`. Push OCI — **только PAT** (у job token Gitea нет права packages).
|
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `gitea.token`. Push OCI — **только PAT** (у job token Gitea нет права packages).
|
||||||
|
|
||||||
**`GITEA_TOKEN`**: clone/push wiki.
|
**`GITEA_TOKEN`**: опциональный wiki-only PAT (fallback, если нет `ACTIONS_PAT`).
|
||||||
|
|
||||||
### Теги образов
|
### Теги образов
|
||||||
|
|
||||||
|
|||||||
@@ -37,21 +37,40 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
- name: Clone Wiki repository
|
|
||||||
if: steps.check_changes.outputs.changed == 'true'
|
|
||||||
run: |
|
|
||||||
WIKI_URL=$(echo "${{ github.server_url }}/${{ github.repository }}.wiki.git" | sed -e "s|://|://gitea-actions:${{ secrets.GITEA_TOKEN }}@|")
|
|
||||||
git clone "${WIKI_URL}" cfdm.wiki
|
|
||||||
- name: Update and push Wiki content
|
- name: Update and push Wiki content
|
||||||
if: steps.check_changes.outputs.changed == 'true'
|
if: steps.check_changes.outputs.changed == 'true'
|
||||||
|
env:
|
||||||
|
# ACTIONS_PAT уже пишет git (tags/releases). GITEA_TOKEN — опциональный
|
||||||
|
# wiki-only PAT; если он задан без write, Gitea отвечает 404, не 403.
|
||||||
|
WIKI_TOKEN: ${{ secrets.ACTIONS_PAT || secrets.GITEA_TOKEN }}
|
||||||
|
# Не gitea.server_url: на runner это внутренний http://192.168.x.x:3000,
|
||||||
|
# а ROOT_URL = git.shx.one — git-receive-pack wiki тогда даёт 404.
|
||||||
|
GITEA_PUBLIC_URL: https://git.shx.one
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${WIKI_TOKEN:-}" ]; then
|
||||||
|
echo "ACTIONS_PAT / GITEA_TOKEN is empty — cannot push wiki"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
PUBLIC_URL="${GITEA_PUBLIC_URL%/}"
|
||||||
|
TOKEN_ENC="$(python3 -c 'import urllib.parse,os; print(urllib.parse.quote(os.environ["WIKI_TOKEN"], safe=""))')"
|
||||||
|
WIKI_URL="${PUBLIC_URL}/${REPO}.wiki.git"
|
||||||
|
# Gitea на неаутентифицированный wiki push отвечает 404, не 401 —
|
||||||
|
# extraHeader/ASKPASS не помогают: токен должен быть в URL с первого запроса.
|
||||||
|
AUTH_INSTEAD="url.https://oauth2:${TOKEN_ENC}@${PUBLIC_URL#https://}/.insteadOf=${PUBLIC_URL}/"
|
||||||
|
GIT_TERMINAL_PROMPT=0 git -c "${AUTH_INSTEAD}" clone "${WIKI_URL}" cfdm.wiki
|
||||||
cp docs/Home.md cfdm.wiki/Home.md
|
cp docs/Home.md cfdm.wiki/Home.md
|
||||||
cd cfdm.wiki
|
cd cfdm.wiki
|
||||||
git config user.name "Gitea Actions"
|
git config user.name "Gitea Actions"
|
||||||
git config user.email "actions@gitea"
|
git config user.email "actions@gitea"
|
||||||
git add Home.md
|
git add Home.md
|
||||||
git diff --staged --quiet || git commit -m "docs: Update Wiki from main repository"
|
if git diff --staged --quiet; then
|
||||||
git push
|
echo "Wiki Home.md already up to date"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "docs: Update Wiki from main repository"
|
||||||
|
GIT_TERMINAL_PROMPT=0 git -c "${AUTH_INSTEAD}" push origin HEAD
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: [quality]
|
needs: [quality]
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
+21
-67
@@ -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";
|
||||||
@@ -24,6 +23,7 @@ import { dnsRoutes } from "./routes/dns.js";
|
|||||||
import { subdomainRoutes } from "./routes/subdomains.js";
|
import { subdomainRoutes } from "./routes/subdomains.js";
|
||||||
import { certificateRoutes } from "./routes/certificates.js";
|
import { certificateRoutes } from "./routes/certificates.js";
|
||||||
import { syncRoutes } from "./routes/sync.js";
|
import { syncRoutes } from "./routes/sync.js";
|
||||||
|
import { originHealthCheckRoutes } from "./routes/origin-health-checks.js";
|
||||||
import { healthCheckRoutes } from "./routes/health-check.js";
|
import { healthCheckRoutes } from "./routes/health-check.js";
|
||||||
import {
|
import {
|
||||||
domainMonitorRoutes,
|
domainMonitorRoutes,
|
||||||
@@ -33,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 {
|
||||||
@@ -81,6 +85,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
await protectedApi.register(certificateRoutes);
|
await protectedApi.register(certificateRoutes);
|
||||||
await protectedApi.register(syncRoutes);
|
await protectedApi.register(syncRoutes);
|
||||||
await protectedApi.register(healthCheckRoutes);
|
await protectedApi.register(healthCheckRoutes);
|
||||||
|
await protectedApi.register(originHealthCheckRoutes);
|
||||||
await protectedApi.register(domainMonitorRoutes);
|
await protectedApi.register(domainMonitorRoutes);
|
||||||
await protectedApi.register(notificationRoutes);
|
await protectedApi.register(notificationRoutes);
|
||||||
await protectedApi.register(settingsRoutes);
|
await protectedApi.register(settingsRoutes);
|
||||||
@@ -123,70 +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(
|
||||||
};
|
app.db,
|
||||||
const n = await healthCheckService.runAllChecks(app.db, {
|
app.cf,
|
||||||
thresholds,
|
healthEngineFallbacksFromConfig(config),
|
||||||
probeGapMs: config.healthProbeGapMs,
|
app.log,
|
||||||
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",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
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;
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ export interface AppConfig {
|
|||||||
healthCheckCron: string;
|
healthCheckCron: string;
|
||||||
healthDegradedFailures: number;
|
healthDegradedFailures: number;
|
||||||
healthDownFailures: number;
|
healthDownFailures: number;
|
||||||
|
healthSuccessRecoveries: number;
|
||||||
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;
|
||||||
@@ -55,9 +58,13 @@ export function loadConfig(): AppConfig {
|
|||||||
healthDegradedFailures:
|
healthDegradedFailures:
|
||||||
Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1,
|
Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1,
|
||||||
healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2,
|
healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2,
|
||||||
|
healthSuccessRecoveries:
|
||||||
|
Number(process.env.HEALTH_SUCCESS_RECOVERIES ?? "2") || 2,
|
||||||
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:
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ export type ErrorCode =
|
|||||||
| "FORBIDDEN"
|
| "FORBIDDEN"
|
||||||
| "CONFLICT"
|
| "CONFLICT"
|
||||||
| "CLOUDFLARE_ERROR"
|
| "CLOUDFLARE_ERROR"
|
||||||
|
| "DNS_UPDATE_FAILED"
|
||||||
|
| "HEALTHCHECK_CREATE_FAILED"
|
||||||
|
| "ZONE_NOT_FOUND"
|
||||||
|
| "INVALID_IP"
|
||||||
|
| "INVALID_HOSTNAME"
|
||||||
|
| "RATE_LIMITED"
|
||||||
|
| "CLOUDFLARE_AUTH_FAILED"
|
||||||
|
| "SYNC_FAILED"
|
||||||
| "INTERNAL_ERROR";
|
| "INTERNAL_ERROR";
|
||||||
|
|
||||||
export class AppError extends Error {
|
export class AppError extends Error {
|
||||||
@@ -44,6 +52,42 @@ export class AppError extends Error {
|
|||||||
return new AppError("CLOUDFLARE_ERROR", message, 502);
|
return new AppError("CLOUDFLARE_ERROR", message, 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static dnsUpdateFailed(message: string) {
|
||||||
|
return new AppError(
|
||||||
|
"DNS_UPDATE_FAILED",
|
||||||
|
message,
|
||||||
|
502,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static healthcheckCreateFailed(message: string) {
|
||||||
|
return new AppError("HEALTHCHECK_CREATE_FAILED", message, 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
static zoneNotFound(message = "зона Cloudflare не найдена") {
|
||||||
|
return new AppError("ZONE_NOT_FOUND", message, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
static invalidIp(message = "Некорректный IP-адрес") {
|
||||||
|
return new AppError("INVALID_IP", message, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
static invalidHostname(message = "Некорректное имя хоста") {
|
||||||
|
return new AppError("INVALID_HOSTNAME", message, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
static rateLimited(message = "Cloudflare временно ограничил запросы. Повторите попытку.") {
|
||||||
|
return new AppError("RATE_LIMITED", message, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
static cloudflareAuthFailed(message = "Cloudflare отклонил токен доступа") {
|
||||||
|
return new AppError("CLOUDFLARE_AUTH_FAILED", message, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
static syncFailed(message: string) {
|
||||||
|
return new AppError("SYNC_FAILED", message, 502);
|
||||||
|
}
|
||||||
|
|
||||||
static internal(message: string) {
|
static internal(message: string) {
|
||||||
return new AppError("INTERNAL_ERROR", message, 500);
|
return new AppError("INTERNAL_ERROR", message, 500);
|
||||||
}
|
}
|
||||||
|
|||||||
+110
-125
@@ -1,152 +1,137 @@
|
|||||||
import type {
|
import type {
|
||||||
CfDnsRecord,
|
CfDnsRecord,
|
||||||
|
CfHealthCheck,
|
||||||
CfZone,
|
CfZone,
|
||||||
CreateDnsRecordPayload,
|
CreateDnsRecordPayload,
|
||||||
|
PatchDnsRecordPayload,
|
||||||
} from "@cfdm/shared";
|
} from "@cfdm/shared";
|
||||||
import { AppError } from "../errors.js";
|
import { createDnsAdapter } from "./cloudflare/dns-service.js";
|
||||||
import { withRetry, parseRetryAfter } from "./cf-retry.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";
|
||||||
|
|
||||||
const BASE_URL = "https://api.cloudflare.com/client/v4";
|
export type { CfHealthCheckPayload };
|
||||||
|
|
||||||
interface CfResponse<T> {
|
|
||||||
success: boolean;
|
|
||||||
result?: T;
|
|
||||||
errors?: Array<{ code: number; message: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CloudflareClient {
|
export class CloudflareClient {
|
||||||
constructor(private readonly token: string) {}
|
private readonly zones;
|
||||||
|
private readonly dns;
|
||||||
|
private readonly healthchecks;
|
||||||
|
private readonly kv;
|
||||||
|
private readonly workers;
|
||||||
|
private readonly token;
|
||||||
|
|
||||||
private async handleResponse<T>(
|
constructor(token: string) {
|
||||||
response: Response,
|
this.token = token.trim();
|
||||||
operation: string,
|
this.zones = createZoneAdapter(token);
|
||||||
): Promise<T> {
|
this.dns = createDnsAdapter(token);
|
||||||
if (response.status === 429) {
|
this.healthchecks = createHealthCheckAdapter(token);
|
||||||
const wait = parseRetryAfter(response.headers) ?? 5000;
|
this.kv = createKvAdapter(token);
|
||||||
throw AppError.cloudflare(`rate limited, retry after ${wait}ms`);
|
this.workers = createWorkersAdapter(token);
|
||||||
}
|
|
||||||
|
|
||||||
const body = (await response.json()) as CfResponse<T>;
|
|
||||||
if (!body.success) {
|
|
||||||
const msg =
|
|
||||||
body.errors?.map((e) => e.message).join("; ") ??
|
|
||||||
"unknown cloudflare error";
|
|
||||||
throw AppError.cloudflare(`${operation}: ${msg}`);
|
|
||||||
}
|
|
||||||
if (body.result === undefined) {
|
|
||||||
throw AppError.cloudflare(`${operation}: empty result`);
|
|
||||||
}
|
|
||||||
return body.result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listZones(): Promise<CfZone[]> {
|
get isConfigured(): boolean {
|
||||||
return withRetry(async () => {
|
return this.token.length > 0;
|
||||||
const all: CfZone[] = [];
|
|
||||||
let page = 1;
|
|
||||||
while (true) {
|
|
||||||
const url = new URL(`${BASE_URL}/zones`);
|
|
||||||
url.searchParams.set("per_page", "50");
|
|
||||||
url.searchParams.set("page", String(page));
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: { Authorization: `Bearer ${this.token}` },
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
if (response.status >= 500 || response.status === 429) {
|
|
||||||
throw AppError.cloudflare(String(response.status));
|
|
||||||
}
|
|
||||||
const batch = await this.handleResponse<CfZone[]>(
|
|
||||||
response,
|
|
||||||
"list_zones",
|
|
||||||
);
|
|
||||||
if (batch.length === 0) break;
|
|
||||||
all.push(...batch);
|
|
||||||
if (batch.length < 50) break;
|
|
||||||
page += 1;
|
|
||||||
}
|
|
||||||
return all;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getZone(zoneId: string): Promise<CfZone> {
|
listZones(): Promise<CfZone[]> {
|
||||||
const response = await fetch(`${BASE_URL}/zones/${zoneId}`, {
|
return this.zones.listZones();
|
||||||
headers: { Authorization: `Bearer ${this.token}` },
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
return this.handleResponse(response, "get_zone");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
|
getZone(zoneId: string): Promise<CfZone> {
|
||||||
return withRetry(async () => {
|
return this.zones.getZone(zoneId);
|
||||||
const all: CfDnsRecord[] = [];
|
|
||||||
let page = 1;
|
|
||||||
while (page <= 50) {
|
|
||||||
const url = new URL(`${BASE_URL}/zones/${zoneId}/dns_records`);
|
|
||||||
url.searchParams.set("per_page", "100");
|
|
||||||
url.searchParams.set("page", String(page));
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: { Authorization: `Bearer ${this.token}` },
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
if (response.status >= 500 || response.status === 429) {
|
|
||||||
throw AppError.cloudflare(String(response.status));
|
|
||||||
}
|
|
||||||
const batch = await this.handleResponse<CfDnsRecord[]>(
|
|
||||||
response,
|
|
||||||
"list_dns_records",
|
|
||||||
);
|
|
||||||
if (batch.length === 0) break;
|
|
||||||
all.push(...batch);
|
|
||||||
page += 1;
|
|
||||||
}
|
|
||||||
return all;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createDnsRecord(
|
listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
|
||||||
zoneId: string,
|
return this.dns.listDnsRecords(zoneId);
|
||||||
payload: CreateDnsRecordPayload,
|
|
||||||
): Promise<CfDnsRecord> {
|
|
||||||
const response = await fetch(`${BASE_URL}/zones/${zoneId}/dns_records`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.token}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
});
|
|
||||||
return this.handleResponse(response, "create_dns_record");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateDnsRecord(
|
createDnsRecord(zoneId: string, payload: CreateDnsRecordPayload): Promise<CfDnsRecord> {
|
||||||
|
return this.dns.createDnsRecord(zoneId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDnsRecord(
|
||||||
zoneId: string,
|
zoneId: string,
|
||||||
recordId: string,
|
recordId: string,
|
||||||
payload: CreateDnsRecordPayload,
|
payload: CreateDnsRecordPayload,
|
||||||
): Promise<CfDnsRecord> {
|
): Promise<CfDnsRecord> {
|
||||||
const response = await fetch(
|
return this.dns.updateDnsRecord(zoneId, recordId, payload);
|
||||||
`${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${this.token}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
signal: AbortSignal.timeout(30_000),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return this.handleResponse(response, "update_dns_record");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
|
patchDnsRecord(
|
||||||
const response = await fetch(
|
zoneId: string,
|
||||||
`${BASE_URL}/zones/${zoneId}/dns_records/${recordId}`,
|
recordId: string,
|
||||||
{
|
payload: PatchDnsRecordPayload,
|
||||||
method: "DELETE",
|
): Promise<CfDnsRecord> {
|
||||||
headers: { Authorization: `Bearer ${this.token}` },
|
return this.dns.patchDnsRecord(zoneId, recordId, payload);
|
||||||
signal: AbortSignal.timeout(30_000),
|
}
|
||||||
},
|
|
||||||
);
|
deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
|
||||||
await this.handleResponse(response, "delete_dns_record");
|
return this.dns.deleteDnsRecord(zoneId, recordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
listHealthChecks(zoneId: string): Promise<CfHealthCheck[]> {
|
||||||
|
return this.healthchecks.listHealthChecks(zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getHealthCheck(zoneId: string, id: string): Promise<CfHealthCheck> {
|
||||||
|
return this.healthchecks.getHealthCheck(zoneId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
createHealthCheck(zoneId: string, payload: CfHealthCheckPayload): Promise<CfHealthCheck> {
|
||||||
|
return this.healthchecks.createHealthCheck(zoneId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHealthCheck(
|
||||||
|
zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: CfHealthCheckPayload,
|
||||||
|
): Promise<CfHealthCheck> {
|
||||||
|
return this.healthchecks.updateHealthCheck(zoneId, id, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteHealthCheck(zoneId: string, id: string): Promise<void> {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { CfDnsRecord, CreateDnsRecordPayload, PatchDnsRecordPayload } from "@cfdm/shared";
|
||||||
|
import { withRetry } from "../cf-retry.js";
|
||||||
|
import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
|
||||||
|
|
||||||
|
export function createDnsAdapter(token: string) {
|
||||||
|
return {
|
||||||
|
async listDnsRecords(zoneId: string): Promise<CfDnsRecord[]> {
|
||||||
|
return withRetry(async () => {
|
||||||
|
const all: CfDnsRecord[] = [];
|
||||||
|
let page = 1;
|
||||||
|
while (page <= 50) {
|
||||||
|
const url = new URL(`${CF_API_BASE}/zones/${zoneId}/dns_records`);
|
||||||
|
url.searchParams.set("per_page", "100");
|
||||||
|
url.searchParams.set("page", String(page));
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure(
|
||||||
|
"list_dns_records",
|
||||||
|
response.status,
|
||||||
|
String(response.status),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const batch = await handleCfResponse<CfDnsRecord[]>(
|
||||||
|
response,
|
||||||
|
"list_dns_records",
|
||||||
|
);
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
all.push(...batch);
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async createDnsRecord(
|
||||||
|
zoneId: string,
|
||||||
|
payload: CreateDnsRecordPayload,
|
||||||
|
): Promise<CfDnsRecord> {
|
||||||
|
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}/dns_records`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
return handleCfResponse(response, "create_dns_record");
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateDnsRecord(
|
||||||
|
zoneId: string,
|
||||||
|
recordId: string,
|
||||||
|
payload: CreateDnsRecordPayload,
|
||||||
|
): Promise<CfDnsRecord> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleCfResponse(response, "update_dns_record");
|
||||||
|
},
|
||||||
|
|
||||||
|
async patchDnsRecord(
|
||||||
|
zoneId: string,
|
||||||
|
recordId: string,
|
||||||
|
payload: PatchDnsRecordPayload,
|
||||||
|
): Promise<CfDnsRecord> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleCfResponse(response, "patch_dns_record");
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteDnsRecord(zoneId: string, recordId: string): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/dns_records/${recordId}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await handleCfResponse(response, "delete_dns_record");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { CfHealthCheck } from "@cfdm/shared";
|
||||||
|
import { CF_API_BASE, handleCfResponse } from "./http.js";
|
||||||
|
|
||||||
|
export interface CfHealthCheckHttpConfig {
|
||||||
|
method?: string;
|
||||||
|
path?: string;
|
||||||
|
expected_codes?: string[];
|
||||||
|
header?: Record<string, string[]>;
|
||||||
|
port?: number;
|
||||||
|
follow_redirects?: boolean;
|
||||||
|
allow_insecure?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CfHealthCheckTcpConfig {
|
||||||
|
method?: "connection_established";
|
||||||
|
port?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CfHealthCheckPayload {
|
||||||
|
address: string;
|
||||||
|
name: string;
|
||||||
|
type?: "HTTP" | "HTTPS" | "TCP";
|
||||||
|
description?: string;
|
||||||
|
interval?: number;
|
||||||
|
timeout?: number;
|
||||||
|
retries?: number;
|
||||||
|
consecutive_fails?: number;
|
||||||
|
consecutive_successes?: number;
|
||||||
|
suspended?: boolean;
|
||||||
|
check_regions?: string[];
|
||||||
|
http_config?: CfHealthCheckHttpConfig;
|
||||||
|
tcp_config?: CfHealthCheckTcpConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHealthCheckAdapter(token: string) {
|
||||||
|
return {
|
||||||
|
async listHealthChecks(zoneId: string): Promise<CfHealthCheck[]> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/healthchecks`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleCfResponse(response, "list_healthchecks");
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHealthCheck(zoneId: string, id: string): Promise<CfHealthCheck> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleCfResponse(response, "get_healthcheck");
|
||||||
|
},
|
||||||
|
|
||||||
|
async createHealthCheck(
|
||||||
|
zoneId: string,
|
||||||
|
payload: CfHealthCheckPayload,
|
||||||
|
): Promise<CfHealthCheck> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/healthchecks`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleCfResponse(response, "create_healthcheck");
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateHealthCheck(
|
||||||
|
zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: CfHealthCheckPayload,
|
||||||
|
): Promise<CfHealthCheck> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return handleCfResponse(response, "update_healthcheck");
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteHealthCheck(zoneId: string, id: string): Promise<void> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${CF_API_BASE}/zones/${zoneId}/healthchecks/${id}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await handleCfResponse(response, "delete_healthcheck");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { CfDnsRecord } from "@cfdm/shared";
|
||||||
|
import { AppError } from "../../errors.js";
|
||||||
|
import { parseRetryAfter } from "../cf-retry.js";
|
||||||
|
|
||||||
|
export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
||||||
|
|
||||||
|
export interface CfResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
result?: T;
|
||||||
|
errors?: Array<{ code: number; message: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapCloudflareFailure(
|
||||||
|
operation: string,
|
||||||
|
status: number,
|
||||||
|
message: string,
|
||||||
|
): AppError {
|
||||||
|
const lower = message.toLowerCase();
|
||||||
|
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(
|
||||||
|
"Cloudflare отклонил токен. Проверьте CLOUDFLARE_API_TOKEN.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 429 || lower.includes("rate limit")) {
|
||||||
|
return AppError.rateLimited();
|
||||||
|
}
|
||||||
|
if (lower.includes("zone") && (lower.includes("not found") || status === 404)) {
|
||||||
|
return AppError.zoneNotFound();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
operation.includes("healthcheck") &&
|
||||||
|
(lower.includes("plan") ||
|
||||||
|
lower.includes("not entitled") ||
|
||||||
|
lower.includes("not allowed") ||
|
||||||
|
lower.includes("permission"))
|
||||||
|
) {
|
||||||
|
return AppError.healthcheckCreateFailed(
|
||||||
|
"Cloudflare Health Checks недоступны для этой зоны. Используйте локальные проверки.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (operation.includes("dns") || operation.includes("dns_record")) {
|
||||||
|
return AppError.dnsUpdateFailed(`Не удалось обновить DNS в Cloudflare: ${message}`);
|
||||||
|
}
|
||||||
|
return AppError.cloudflare(`${operation}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCfResponse<T>(
|
||||||
|
response: Response,
|
||||||
|
operation: string,
|
||||||
|
): Promise<T> {
|
||||||
|
if (response.status === 429) {
|
||||||
|
const wait = parseRetryAfter(response.headers) ?? 5000;
|
||||||
|
throw AppError.rateLimited(
|
||||||
|
`Cloudflare временно ограничил запросы. Повторите через ${Math.ceil(wait / 1000)} с.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json()) as CfResponse<T>;
|
||||||
|
if (!body.success) {
|
||||||
|
const msg =
|
||||||
|
body.errors?.map((e) => e.message).join("; ") ?? "unknown cloudflare error";
|
||||||
|
throw mapCloudflareFailure(operation, response.status, msg);
|
||||||
|
}
|
||||||
|
if (body.result === undefined) {
|
||||||
|
throw mapCloudflareFailure(operation, response.status, "empty 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>(
|
||||||
|
token: string,
|
||||||
|
path: string,
|
||||||
|
operation: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(`${CF_API_BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||||
|
...init.headers,
|
||||||
|
},
|
||||||
|
signal: init.signal ?? AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure(operation, response.status, String(response.status));
|
||||||
|
}
|
||||||
|
return handleCfResponse<T>(response, operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DnsRecordResult = CfDnsRecord;
|
||||||
@@ -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,40 @@
|
|||||||
|
import type { CfZone } from "@cfdm/shared";
|
||||||
|
import { withRetry } from "../cf-retry.js";
|
||||||
|
import { CF_API_BASE, handleCfResponse, mapCloudflareFailure } from "./http.js";
|
||||||
|
|
||||||
|
export function createZoneAdapter(token: string) {
|
||||||
|
return {
|
||||||
|
async listZones(): Promise<CfZone[]> {
|
||||||
|
return withRetry(async () => {
|
||||||
|
const all: CfZone[] = [];
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const url = new URL(`${CF_API_BASE}/zones`);
|
||||||
|
url.searchParams.set("per_page", "50");
|
||||||
|
url.searchParams.set("page", String(page));
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
if (response.status >= 500 || response.status === 429) {
|
||||||
|
throw mapCloudflareFailure("list_zones", response.status, String(response.status));
|
||||||
|
}
|
||||||
|
const batch = await handleCfResponse<CfZone[]>(response, "list_zones");
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
all.push(...batch);
|
||||||
|
if (batch.length < 50) break;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getZone(zoneId: string): Promise<CfZone> {
|
||||||
|
const response = await fetch(`${CF_API_BASE}/zones/${zoneId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
});
|
||||||
|
return handleCfResponse(response, "get_zone");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -94,14 +94,17 @@ const RULES: Rule[] = [
|
|||||||
methods: ["GET"],
|
methods: ["GET"],
|
||||||
match: (p) =>
|
match: (p) =>
|
||||||
p.startsWith("/api/v1/services") ||
|
p.startsWith("/api/v1/services") ||
|
||||||
p.startsWith("/api/v1/service-bindings"),
|
p.startsWith("/api/v1/service-bindings") ||
|
||||||
|
p.startsWith("/api/v1/health-checks") ||
|
||||||
|
p === "/api/v1/ops-summary",
|
||||||
permission: "cfdm:services:read",
|
permission: "cfdm:services:read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
match: (p) =>
|
match: (p) =>
|
||||||
p.startsWith("/api/v1/services") ||
|
p.startsWith("/api/v1/services") ||
|
||||||
p.startsWith("/api/v1/service-bindings"),
|
p.startsWith("/api/v1/service-bindings") ||
|
||||||
|
p.startsWith("/api/v1/health-checks"),
|
||||||
permission: "cfdm:services:write",
|
permission: "cfdm:services:write",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -114,8 +117,7 @@ const RULES: Rule[] = [
|
|||||||
match: (p) =>
|
match: (p) =>
|
||||||
p.startsWith("/api/v1/settings") ||
|
p.startsWith("/api/v1/settings") ||
|
||||||
p.startsWith("/api/v1/notifications") ||
|
p.startsWith("/api/v1/notifications") ||
|
||||||
p.startsWith("/api/v1/health-check") ||
|
p.startsWith("/api/v1/health-check"),
|
||||||
p.startsWith("/api/v1/health-checks"),
|
|
||||||
permission: "cfdm:settings:admin",
|
permission: "cfdm:settings:admin",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,14 +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: 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 =
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import {
|
||||||
|
createOriginHealthCheckSchema,
|
||||||
|
} from "@cfdm/shared";
|
||||||
|
import * as originHealth from "../services/origin-health-check-service.js";
|
||||||
|
import { recordAudit } from "../lib/audit.js";
|
||||||
|
|
||||||
|
export async function originHealthCheckRoutes(app: FastifyInstance) {
|
||||||
|
app.get("/health-checks", async (request) => {
|
||||||
|
return originHealth.listOriginHealthChecks(request.server.db);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/health-checks", async (request) => {
|
||||||
|
const body = createOriginHealthCheckSchema.parse(request.body);
|
||||||
|
const check = await originHealth.createOriginHealthCheck(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "healthcheck.create",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(check.id),
|
||||||
|
summary: `Создан health check «${check.name}» (${check.provider})`,
|
||||||
|
});
|
||||||
|
return check;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/health-checks/:id", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return originHealth.getOriginHealthCheck(request.server.db, Number(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/health-checks/:id", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = createOriginHealthCheckSchema.partial().parse(request.body);
|
||||||
|
const check = await originHealth.updateOriginHealthCheck(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
Number(id),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "healthcheck.update",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: id,
|
||||||
|
summary: `Обновлён health check «${check.name}»`,
|
||||||
|
});
|
||||||
|
return check;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/health-checks/:id", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
await originHealth.deleteOriginHealthCheck(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
Number(id),
|
||||||
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "healthcheck.delete",
|
||||||
|
severity: "warning",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: id,
|
||||||
|
summary: `Удалён health check ${id}`,
|
||||||
|
});
|
||||||
|
return { deleted: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/health-checks/:id/sync", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return originHealth.syncOriginHealthCheck(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
Number(id),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
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 { recordAudit } from "../lib/audit.js";
|
||||||
|
|
||||||
export async function serviceBindingRoutes(app: FastifyInstance) {
|
export async function serviceBindingRoutes(app: FastifyInstance) {
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
@@ -14,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) => {
|
||||||
@@ -52,6 +56,27 @@ export async function serviceBindingRoutes(app: FastifyInstance) {
|
|||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/service-bindings/:id/change-ip", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = changeIpSchema.parse(request.body);
|
||||||
|
const result = await changeIp.changeBindingIp(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
Number(id),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
if (result.applied) {
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "binding.change_ip",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: id,
|
||||||
|
summary: `Сменён IP: ${result.message}`,
|
||||||
|
details: result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/domains/:id/service-bindings", async (request) => {
|
app.get("/domains/:id/service-bindings", async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
return bindingService.listByDomain(request.server.db, Number(id));
|
return bindingService.listByDomain(request.server.db, Number(id));
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { reorderServicesSchema, updateServiceConfigSchema } from "@cfdm/shared";
|
import {
|
||||||
|
changeDomainSchema,
|
||||||
|
createServiceNodeSchema,
|
||||||
|
reorderServicesSchema,
|
||||||
|
toggleServiceIpSchema,
|
||||||
|
updateServiceConfigSchema,
|
||||||
|
updateServiceNodeSchema,
|
||||||
|
} from "@cfdm/shared";
|
||||||
import { repos } from "@cfdm/db";
|
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 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) {
|
||||||
@@ -56,6 +66,129 @@ 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/failover-log", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
repos.getService(request.server.db, Number(id));
|
||||||
|
return {
|
||||||
|
items: repos.listFailoverLogForService(
|
||||||
|
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) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return nodeService.getOverview(request.server.db, Number(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/services/:id/nodes", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return nodeService.listNodes(request.server.db, Number(id));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/services/:id/nodes", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = createServiceNodeSchema.parse(request.body);
|
||||||
|
const node = nodeService.createNode(request.server.db, Number(id), body);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "node.create",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(node.id),
|
||||||
|
summary: `Добавлена нода ${node.address}`,
|
||||||
|
details: { service_id: Number(id), address: node.address },
|
||||||
|
});
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/services/:id/nodes/:nodeId", async (request) => {
|
||||||
|
const { id, nodeId } = request.params as { id: string; nodeId: string };
|
||||||
|
const body = updateServiceNodeSchema.parse(request.body);
|
||||||
|
const node = nodeService.updateNode(
|
||||||
|
request.server.db,
|
||||||
|
Number(id),
|
||||||
|
Number(nodeId),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "node.update",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(node.id),
|
||||||
|
summary: `Обновлена нода ${node.address}`,
|
||||||
|
details: body,
|
||||||
|
});
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/services/:id/nodes/:nodeId", async (request) => {
|
||||||
|
const { id, nodeId } = request.params as { id: string; nodeId: string };
|
||||||
|
const node = repos.getNode(request.server.db, Number(nodeId));
|
||||||
|
nodeService.deleteNode(request.server.db, Number(id), Number(nodeId));
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "node.delete",
|
||||||
|
severity: "warning",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: nodeId,
|
||||||
|
summary: `Удалена нода ${node.address}`,
|
||||||
|
});
|
||||||
|
return { deleted: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/services/:id/change-domain", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = changeDomainSchema.parse(request.body);
|
||||||
|
const result = await changeDomain.changeServiceDomain(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
Number(id),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
if (result.applied) {
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "service.change_domain",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: id,
|
||||||
|
summary: result.message,
|
||||||
|
details: result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/ops-summary", async (request) => {
|
||||||
|
return nodeService.opsSummary(request.server.db);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
app.patch("/services/:id", async (request) => {
|
app.patch("/services/:id", async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
@@ -101,4 +234,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,98 +193,40 @@ export function buildServiceCertificateFqdns(
|
|||||||
? repos.getServiceGroup(db, service.service_group_id)
|
? repos.getServiceGroup(db, service.service_group_id)
|
||||||
: null;
|
: null;
|
||||||
if (!shouldMonitorService(service, group)) continue;
|
if (!shouldMonitorService(service, group)) continue;
|
||||||
if (
|
|
||||||
!hasSslHealthGate(
|
|
||||||
{
|
|
||||||
health_check_enabled: binding.health_check_enabled,
|
|
||||||
health_check_verify_tls: binding.health_check_verify_tls,
|
|
||||||
},
|
|
||||||
group,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
|
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
|
||||||
if (subdomain && !subdomain.enabled) continue;
|
if (subdomain && !subdomain.enabled) continue;
|
||||||
|
|
||||||
|
const mode = binding.cert_monitoring ?? CERT_MONITOR_AUTO;
|
||||||
|
if (mode === CERT_MONITOR_SKIPPED) continue;
|
||||||
|
if (mode === CERT_MONITOR_AUTO) {
|
||||||
|
if (
|
||||||
|
!hasSslHealthGate(
|
||||||
|
{
|
||||||
|
health_check_enabled: binding.health_check_enabled,
|
||||||
|
health_check_verify_tls: binding.health_check_verify_tls,
|
||||||
|
},
|
||||||
|
group,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if (mode !== CERT_MONITOR_REQUIRED) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const fqdn = fqdnToDisplay(binding.hostname, binding.zone_name);
|
const fqdn = fqdnToDisplay(binding.hostname, binding.zone_name);
|
||||||
result.set(fqdn, {
|
if (seen.has(fqdn)) continue;
|
||||||
|
seen.add(fqdn);
|
||||||
|
targets.push({
|
||||||
domainId: binding.domain_id,
|
domainId: binding.domain_id,
|
||||||
subdomainId: subdomain?.id ?? null,
|
subdomainId: subdomain?.id ?? null,
|
||||||
|
serviceId: binding.service_id,
|
||||||
hostname: fqdn,
|
hostname: fqdn,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name);
|
return targets;
|
||||||
for (const group of repos.listServiceGroups(db)) {
|
|
||||||
if (!group.enabled || !group.domain?.trim()) continue;
|
|
||||||
if (!group.health_check_enabled || !group.health_check_verify_tls) continue;
|
|
||||||
|
|
||||||
const parsed = parseFqdn(group.domain, knownZones);
|
|
||||||
if (!parsed) continue;
|
|
||||||
|
|
||||||
const domain = repos.findDomainByZoneName(db, parsed.zoneName);
|
|
||||||
if (!domain) continue;
|
|
||||||
|
|
||||||
const subdomain =
|
|
||||||
parsed.hostname === "@"
|
|
||||||
? null
|
|
||||||
: bindingSubdomain(db, domain.id, parsed.hostname);
|
|
||||||
if (subdomain && !subdomain.enabled) continue;
|
|
||||||
|
|
||||||
result.set(parsed.fqdn, {
|
|
||||||
domainId: domain.id,
|
|
||||||
subdomainId: subdomain?.id ?? null,
|
|
||||||
hostname: parsed.fqdn,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
|
|
||||||
const serviceFqdns = buildServiceCertificateFqdns(db);
|
|
||||||
const targets = new Map<string, CertificateTarget>();
|
|
||||||
|
|
||||||
for (const domain of repos.listAllDomains(db)) {
|
|
||||||
if (domain.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
|
|
||||||
if (domain.cert_monitoring === CERT_MONITOR_REQUIRED) {
|
|
||||||
targets.set(domain.zone_name, {
|
|
||||||
domainId: domain.id,
|
|
||||||
subdomainId: null,
|
|
||||||
hostname: domain.zone_name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const sub of repos.listAllSubdomains(db)) {
|
|
||||||
if (sub.cert_monitoring === CERT_MONITOR_SKIPPED) continue;
|
|
||||||
if (sub.cert_monitoring === CERT_MONITOR_REQUIRED) {
|
|
||||||
targets.set(sub.fqdn, {
|
|
||||||
domainId: sub.domain_id,
|
|
||||||
subdomainId: sub.id,
|
|
||||||
hostname: sub.fqdn,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [fqdn, meta] of serviceFqdns) {
|
|
||||||
const domain = repos.getDomain(db, meta.domainId);
|
|
||||||
const subdomain = meta.subdomainId
|
|
||||||
? repos.getSubdomain(db, meta.subdomainId)
|
|
||||||
: null;
|
|
||||||
const monitoring = resolveMonitoringMode(domain, subdomain, fqdn);
|
|
||||||
if (monitoring === CERT_MONITOR_SKIPPED) continue;
|
|
||||||
if (
|
|
||||||
monitoring === CERT_MONITOR_AUTO ||
|
|
||||||
monitoring === CERT_MONITOR_REQUIRED
|
|
||||||
) {
|
|
||||||
targets.set(fqdn, meta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...targets.values()];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runAllChecks(db: Db): Promise<number> {
|
export async function runAllChecks(db: Db): Promise<number> {
|
||||||
@@ -278,6 +237,7 @@ export async function runAllChecks(db: Db): Promise<number> {
|
|||||||
target.domainId,
|
target.domainId,
|
||||||
target.subdomainId,
|
target.subdomainId,
|
||||||
target.hostname,
|
target.hostname,
|
||||||
|
target.serviceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
repos.deleteCertificatesNotIn(
|
repos.deleteCertificatesNotIn(
|
||||||
@@ -287,6 +247,26 @@ export async function runAllChecks(db: Db): Promise<number> {
|
|||||||
return targets.length;
|
return targets.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function runServiceChecks(
|
||||||
|
db: Db,
|
||||||
|
serviceId: number,
|
||||||
|
): Promise<number> {
|
||||||
|
repos.getService(db, serviceId);
|
||||||
|
const targets = resolveCertificateTargets(db).filter(
|
||||||
|
(target) => target.serviceId === serviceId,
|
||||||
|
);
|
||||||
|
for (const target of targets) {
|
||||||
|
await checkAndStore(
|
||||||
|
db,
|
||||||
|
target.domainId,
|
||||||
|
target.subdomainId,
|
||||||
|
target.hostname,
|
||||||
|
target.serviceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return targets.length;
|
||||||
|
}
|
||||||
|
|
||||||
export function statusSummary(db: Db): Array<[string, number]> {
|
export function statusSummary(db: Db): Array<[string, number]> {
|
||||||
pruneStaleCertificates(db);
|
pruneStaleCertificates(db);
|
||||||
return repos.countCertificatesByStatus(db);
|
return repos.countCertificatesByStatus(db);
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { Db } from "@cfdm/db";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type { ChangeDomainInput } from "@cfdm/shared";
|
||||||
|
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
import * as dnsService from "./dns-service.js";
|
||||||
|
import { withBindingLock } from "./routing/index.js";
|
||||||
|
import { applyBindingDesiredDns, fqdnToDisplay } from "./service-config-service.js";
|
||||||
|
|
||||||
|
export interface ChangeDomainItem {
|
||||||
|
binding_id: number;
|
||||||
|
hostname: string;
|
||||||
|
from_fqdn: string;
|
||||||
|
to_fqdn: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChangeDomainPreview {
|
||||||
|
from_domain_id: number;
|
||||||
|
to_domain_id: number;
|
||||||
|
from_zone: string;
|
||||||
|
to_zone: string;
|
||||||
|
items: ChangeDomainItem[];
|
||||||
|
dry_run: boolean;
|
||||||
|
applied: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeServiceDomain(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
serviceId: number,
|
||||||
|
input: ChangeDomainInput,
|
||||||
|
): Promise<ChangeDomainPreview> {
|
||||||
|
repos.getService(db, serviceId);
|
||||||
|
if (input.from_domain_id === input.to_domain_id) {
|
||||||
|
throw AppError.validation("укажите другой целевой домен");
|
||||||
|
}
|
||||||
|
const fromDomain = repos.getDomain(db, input.from_domain_id);
|
||||||
|
const toDomain = repos.getDomain(db, input.to_domain_id);
|
||||||
|
const bindings = repos
|
||||||
|
.listBindingsByService(db, serviceId)
|
||||||
|
.filter((b) => b.domain_id === input.from_domain_id);
|
||||||
|
const selected = input.hostnames?.length
|
||||||
|
? bindings.filter((b) => input.hostnames!.includes(b.hostname))
|
||||||
|
: bindings;
|
||||||
|
if (selected.length === 0) {
|
||||||
|
throw AppError.validation("нет привязок для переноса");
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: ChangeDomainItem[] = selected.map((binding) => ({
|
||||||
|
binding_id: binding.id,
|
||||||
|
hostname: binding.hostname,
|
||||||
|
from_fqdn: fqdnToDisplay(binding.hostname, fromDomain.zone_name),
|
||||||
|
to_fqdn: fqdnToDisplay(binding.hostname, toDomain.zone_name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const preview: ChangeDomainPreview = {
|
||||||
|
from_domain_id: fromDomain.id,
|
||||||
|
to_domain_id: toDomain.id,
|
||||||
|
from_zone: fromDomain.zone_name,
|
||||||
|
to_zone: toDomain.zone_name,
|
||||||
|
items,
|
||||||
|
dry_run: Boolean(input.dry_run),
|
||||||
|
applied: false,
|
||||||
|
message: `Перенос ${items.length} привязок ${fromDomain.zone_name} → ${toDomain.zone_name}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.dry_run) return preview;
|
||||||
|
|
||||||
|
const createdRecordIds: number[] = [];
|
||||||
|
try {
|
||||||
|
for (const binding of selected) {
|
||||||
|
const existing = repos.findBinding(
|
||||||
|
db,
|
||||||
|
serviceId,
|
||||||
|
toDomain.id,
|
||||||
|
binding.hostname,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw AppError.conflict(
|
||||||
|
`привязка ${fqdnToDisplay(binding.hostname, toDomain.zone_name)} уже существует`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await withBindingLock(binding.id, async () => {
|
||||||
|
repos.bumpBindingVersion(db, binding.id);
|
||||||
|
const ips = repos.listBindingIps(db, binding.id);
|
||||||
|
repos.updateBindingDomain(db, binding.id, toDomain.id, binding.hostname);
|
||||||
|
await applyBindingDesiredDns(db, cf, binding.id, ips);
|
||||||
|
const newRecords = repos.listRecordsForBinding(db, binding.id);
|
||||||
|
createdRecordIds.push(...newRecords.map((r) => r.id));
|
||||||
|
|
||||||
|
const oldRecords = newRecords.filter((r) => r.domain_id === fromDomain.id);
|
||||||
|
for (const record of oldRecords) {
|
||||||
|
repos.unlinkBindingRecord(db, binding.id, record.id);
|
||||||
|
try {
|
||||||
|
await dnsService.deleteRecord(db, cf, fromDomain.id, record.id);
|
||||||
|
} catch {
|
||||||
|
// best-effort cleanup of old zone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw err instanceof AppError
|
||||||
|
? err
|
||||||
|
: AppError.syncFailed(
|
||||||
|
err instanceof Error ? err.message : "не удалось перенести привязки",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void createdRecordIds;
|
||||||
|
return {
|
||||||
|
...preview,
|
||||||
|
dry_run: false,
|
||||||
|
applied: true,
|
||||||
|
message: `Привязки перенесены в ${toDomain.zone_name}. Старые записи зоны удалены.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import type { Db } from "@cfdm/db";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type { ChangeIpInput } from "@cfdm/shared";
|
||||||
|
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
import { isValidIpv4 } from "../lib/validators.js";
|
||||||
|
import { withBindingLock } from "./routing/index.js";
|
||||||
|
import { applyBindingDesiredDns } from "./service-config-service.js";
|
||||||
|
|
||||||
|
export interface ChangeIpPreview {
|
||||||
|
binding_id: number;
|
||||||
|
hostname: string;
|
||||||
|
zone_name: string;
|
||||||
|
from_ip: string;
|
||||||
|
to_ip: string;
|
||||||
|
dry_run: boolean;
|
||||||
|
applied: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchRecordContent(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
domainId: number,
|
||||||
|
recordId: number,
|
||||||
|
content: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const domain = repos.getDomain(db, domainId);
|
||||||
|
const record = repos.getDnsRecord(db, domainId, recordId);
|
||||||
|
if (!record.cf_record_id) {
|
||||||
|
throw AppError.dnsUpdateFailed("у DNS-записи нет идентификатора Cloudflare");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const patched = await cf.patchDnsRecord(domain.cf_zone_id, record.cf_record_id, {
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
repos.updateDnsFields(
|
||||||
|
db,
|
||||||
|
record.id,
|
||||||
|
patched.type ?? record.record_type,
|
||||||
|
patched.name ?? record.name,
|
||||||
|
patched.content ?? content,
|
||||||
|
patched.ttl ?? record.ttl,
|
||||||
|
patched.proxied ?? record.proxied,
|
||||||
|
patched.priority ?? record.priority,
|
||||||
|
"synced",
|
||||||
|
patched.id ?? record.cf_record_id,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AppError) throw err;
|
||||||
|
throw AppError.dnsUpdateFailed(
|
||||||
|
err instanceof Error ? err.message : "не удалось обновить запись в Cloudflare",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeBindingIp(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
bindingId: number,
|
||||||
|
input: ChangeIpInput,
|
||||||
|
): Promise<ChangeIpPreview> {
|
||||||
|
const binding = repos.getBinding(db, bindingId);
|
||||||
|
const domain = repos.getDomain(db, binding.domain_id);
|
||||||
|
const current = repos.listBindingIpsWithMeta(db, bindingId);
|
||||||
|
if (current.length === 0) {
|
||||||
|
throw AppError.validation("у привязки нет IP для замены");
|
||||||
|
}
|
||||||
|
|
||||||
|
let fromIp = input.from_ip?.trim();
|
||||||
|
let toIp = input.to_ip?.trim();
|
||||||
|
|
||||||
|
if (input.node_id) {
|
||||||
|
const node = repos.getNode(db, input.node_id);
|
||||||
|
if (node.service_id !== binding.service_id) {
|
||||||
|
throw AppError.validation("нода не принадлежит сервису этой привязки");
|
||||||
|
}
|
||||||
|
toIp = node.address;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fromIp) {
|
||||||
|
fromIp = current[0]!.ip;
|
||||||
|
}
|
||||||
|
if (!toIp) {
|
||||||
|
throw AppError.invalidIp("укажите новый IP или ноду");
|
||||||
|
}
|
||||||
|
if (!isValidIpv4(toIp)) {
|
||||||
|
throw AppError.invalidIp(`Некорректный IP-адрес: ${toIp}`);
|
||||||
|
}
|
||||||
|
if (!current.some((row) => row.ip === fromIp)) {
|
||||||
|
throw AppError.validation(`IP ${fromIp} нет в привязке`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview: ChangeIpPreview = {
|
||||||
|
binding_id: bindingId,
|
||||||
|
hostname: binding.hostname,
|
||||||
|
zone_name: domain.zone_name,
|
||||||
|
from_ip: fromIp,
|
||||||
|
to_ip: toIp,
|
||||||
|
dry_run: Boolean(input.dry_run),
|
||||||
|
applied: false,
|
||||||
|
message: `${fromIp} → ${toIp}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (input.dry_run || fromIp === toIp) {
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
return withBindingLock(bindingId, async () => {
|
||||||
|
repos.bumpBindingVersion(db, bindingId);
|
||||||
|
const next = current.map((row) =>
|
||||||
|
row.ip === fromIp ? { ...row, ip: toIp } : row,
|
||||||
|
);
|
||||||
|
repos.replaceBindingIpsWithMeta(db, bindingId, next);
|
||||||
|
|
||||||
|
const records = repos.listRecordsForBinding(db, bindingId);
|
||||||
|
const match = records.find(
|
||||||
|
(record) =>
|
||||||
|
record.content === fromIp &&
|
||||||
|
(record.record_type.toUpperCase() === "A" ||
|
||||||
|
record.record_type.toUpperCase() === "AAAA"),
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
await patchRecordContent(db, cf, binding.domain_id, match.id, toIp);
|
||||||
|
} else {
|
||||||
|
await applyBindingDesiredDns(
|
||||||
|
db,
|
||||||
|
cf,
|
||||||
|
bindingId,
|
||||||
|
next.map((row) => row.ip),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...preview,
|
||||||
|
dry_run: false,
|
||||||
|
applied: true,
|
||||||
|
message: `Запись обновлена в Cloudflare (${fromIp} → ${toIp}). Распространение зависит от TTL.`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Db } from "@cfdm/db";
|
import type { Db } from "@cfdm/db";
|
||||||
import { repos, type DnsListFilter } from "@cfdm/db";
|
import { repos, type DnsListFilter } from "@cfdm/db";
|
||||||
import type { CreateDnsRecordPayload, DnsRecord } from "@cfdm/shared";
|
import type { CreateDnsRecordPayload, DnsRecord, PatchDnsRecordPayload } from "@cfdm/shared";
|
||||||
import {
|
import {
|
||||||
SYNC_CONFLICT,
|
SYNC_CONFLICT,
|
||||||
SYNC_ERROR,
|
SYNC_ERROR,
|
||||||
@@ -179,6 +179,52 @@ export async function update(
|
|||||||
return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);
|
return pushRecord(db, cf, domainId, domain.cf_zone_id, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function patchContent(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
domainId: number,
|
||||||
|
recordId: number,
|
||||||
|
payload: PatchDnsRecordPayload,
|
||||||
|
): Promise<DnsRecord> {
|
||||||
|
const domain = repos.getDomain(db, domainId);
|
||||||
|
const existing = repos.getDnsRecord(db, domainId, recordId);
|
||||||
|
if (!existing.cf_record_id) {
|
||||||
|
throw AppError.dnsUpdateFailed("у DNS-записи нет идентификатора Cloudflare");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const cfRec = await cf.patchDnsRecord(
|
||||||
|
domain.cf_zone_id,
|
||||||
|
existing.cf_record_id,
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
repos.updateDnsFields(
|
||||||
|
db,
|
||||||
|
existing.id,
|
||||||
|
cfRec.type ?? existing.record_type,
|
||||||
|
cfRec.name ?? existing.name,
|
||||||
|
cfRec.content ?? existing.content,
|
||||||
|
cfRec.ttl ?? existing.ttl,
|
||||||
|
cfRec.proxied ?? existing.proxied,
|
||||||
|
cfRec.priority ?? existing.priority,
|
||||||
|
SYNC_SYNCED,
|
||||||
|
cfRec.id ?? existing.cf_record_id,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
return repos.getDnsRecord(db, domainId, existing.id);
|
||||||
|
} catch (e) {
|
||||||
|
repos.setDnsSyncStatus(
|
||||||
|
db,
|
||||||
|
existing.id,
|
||||||
|
SYNC_ERROR,
|
||||||
|
existing.cf_record_id,
|
||||||
|
e instanceof Error ? e.message : String(e),
|
||||||
|
);
|
||||||
|
throw e instanceof AppError
|
||||||
|
? e
|
||||||
|
: AppError.dnsUpdateFailed(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteRecord(
|
export async function deleteRecord(
|
||||||
db: Db,
|
db: Db,
|
||||||
cf: CloudflareClient,
|
cf: CloudflareClient,
|
||||||
|
|||||||
@@ -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,19 +3,41 @@ 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 { 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;
|
||||||
downFailures: number;
|
downFailures: number;
|
||||||
latencyWarnMs: number;
|
latencyWarnMs: number;
|
||||||
|
successRecoveries?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProbeResult {
|
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. */
|
||||||
@@ -234,29 +256,36 @@ export async function probeTarget(
|
|||||||
function deriveState(
|
function deriveState(
|
||||||
ok: boolean,
|
ok: boolean,
|
||||||
latencyMs: number,
|
latencyMs: number,
|
||||||
prev: { consecutive_failures: number; status: string } | null,
|
prev: {
|
||||||
|
consecutive_failures: number;
|
||||||
|
consecutive_successes?: number;
|
||||||
|
status: string;
|
||||||
|
} | null,
|
||||||
thresholds: HealthCheckThresholds,
|
thresholds: HealthCheckThresholds,
|
||||||
): { state: IpHealthState; failures: number } {
|
): { state: IpHealthState; failures: number; successes: number; node: string } {
|
||||||
if (!ok) {
|
const next = nextHealthState(ok, latencyMs, prev, {
|
||||||
const failures = (prev?.consecutive_failures ?? 0) + 1;
|
degradedFailures: thresholds.degradedFailures,
|
||||||
if (failures >= thresholds.downFailures) {
|
downFailures: thresholds.downFailures,
|
||||||
return { state: "down", failures };
|
latencyWarnMs: thresholds.latencyWarnMs,
|
||||||
}
|
successRecoveries: thresholds.successRecoveries ?? 2,
|
||||||
if (failures >= thresholds.degradedFailures) {
|
});
|
||||||
return { state: "degraded", failures };
|
return {
|
||||||
}
|
state: next.legacy,
|
||||||
return { state: "degraded", failures };
|
failures: next.failures,
|
||||||
}
|
successes: next.successes,
|
||||||
if (latencyMs > thresholds.latencyWarnMs) {
|
node: next.node,
|
||||||
return { state: "degraded", failures: 0 };
|
};
|
||||||
}
|
|
||||||
return { state: "up", failures: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunAllChecksOptions {
|
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,
|
||||||
@@ -269,25 +298,116 @@ 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}`;
|
|
||||||
|
function logSourceResult(
|
||||||
|
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,
|
||||||
|
): void {
|
||||||
|
const policy = parseHealthAggregate(target.aggregate);
|
||||||
|
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 prev = repos.getIpHealthStatusRow(
|
||||||
|
db,
|
||||||
|
target.scope,
|
||||||
|
target.ref_id,
|
||||||
|
target.ip,
|
||||||
|
);
|
||||||
|
const { state, failures, successes, node } = deriveState(
|
||||||
|
aggregatedOk,
|
||||||
|
latencyMs,
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
consecutive_failures: prev.consecutive_failures,
|
||||||
|
consecutive_successes: prev.consecutive_successes,
|
||||||
|
status: prev.status,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
options.thresholds,
|
||||||
|
);
|
||||||
|
const prevState: IpHealthState | null = prev
|
||||||
|
? (prev.status as IpHealthState)
|
||||||
|
: null;
|
||||||
|
repos.upsertIpHealthStatus(
|
||||||
|
db,
|
||||||
|
target.scope,
|
||||||
|
target.ref_id,
|
||||||
|
target.ip,
|
||||||
|
state,
|
||||||
|
latencyMs,
|
||||||
|
failures,
|
||||||
|
error,
|
||||||
|
successes,
|
||||||
|
{ colo, provider: statusProvider },
|
||||||
|
);
|
||||||
|
const matchedNode = repos.findNodeByIp(db, target.ip);
|
||||||
|
// Binding-scope only: group apply must not clobber node with its own fetch failed.
|
||||||
|
if (matchedNode && matchedNode.enabled && target.scope === "binding") {
|
||||||
|
repos.updateNode(db, matchedNode.id, {
|
||||||
|
health_status: node,
|
||||||
|
consecutive_failures: failures,
|
||||||
|
consecutive_successes: successes,
|
||||||
|
last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||||
|
last_failure_reason: error,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (target.type === "tcp") return `tcp|${ip}|${port}`;
|
if (prevState !== state) {
|
||||||
if (target.type === "ping") {
|
options.onStatusChange?.(target, prevState, state);
|
||||||
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
|
||||||
}
|
}
|
||||||
if (target.type === "dns") {
|
}
|
||||||
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
|
||||||
}
|
function staleWorkerResult(colo: string | null): ProbeResult {
|
||||||
return `${target.type}|${ip}|${port}`;
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: "Cloudflare Worker: результаты устарели или KV пуст",
|
||||||
|
colo,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runAllChecks(
|
export async function runAllChecks(
|
||||||
@@ -296,64 +416,107 @@ export async function runAllChecks(
|
|||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const targets = repos.listHealthCheckTargets(db);
|
const targets = repos.listHealthCheckTargets(db);
|
||||||
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
|
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
|
||||||
|
const local = new LocalHealthCheckProvider();
|
||||||
|
const staleAfterMs = options.staleAfterMs ?? 10 * 60_000;
|
||||||
|
|
||||||
const byPhysical = new Map<string, HealthCheckTarget[]>();
|
const byOrigin = new Map<string, HealthCheckTarget[]>();
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const key = physicalProbeKey(target);
|
const key = originProbeKey(target);
|
||||||
const list = byPhysical.get(key);
|
const list = byOrigin.get(key);
|
||||||
if (list) list.push(target);
|
if (list) list.push(target);
|
||||||
else byPhysical.set(key, [target]);
|
else byOrigin.set(key, [target]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let probeIndex = 0;
|
const needsCloudflare = targets.some((t) =>
|
||||||
for (const group of byPhysical.values()) {
|
targetProviders(t).includes("cloudflare"),
|
||||||
if (probeIndex > 0 && gapMs > 0) {
|
);
|
||||||
await sleep(gapMs);
|
let mailboxResults = new Map<string, { ok: boolean; latencyMs: number; error: string | null }>();
|
||||||
}
|
let mailboxColo: string | null = null;
|
||||||
probeIndex += 1;
|
let mailboxStale = true;
|
||||||
|
const mailbox = options.mailbox ?? null;
|
||||||
// Prefer binding hostname for SNI when several scopes share one IP.
|
if (needsCloudflare) {
|
||||||
const representative =
|
const resultsDoc = mailbox ? await mailbox.getResults() : null;
|
||||||
group.find((t) => t.scope === "binding") ?? group[0]!;
|
mailboxResults = indexResults(resultsDoc);
|
||||||
const result = await probeTarget(representative);
|
mailboxStale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);
|
||||||
|
mailboxColo = resultsDoc?.colo ?? null;
|
||||||
for (const target of group) {
|
if (mailbox) {
|
||||||
const prev = repos.getIpHealthStatusRow(
|
try {
|
||||||
db,
|
const next = buildTargetsDoc(targets);
|
||||||
target.scope,
|
const current = await mailbox.getTargets();
|
||||||
target.ref_id,
|
if (current?.fingerprint !== next.fingerprint) {
|
||||||
target.ip,
|
await mailbox.putTargets(next);
|
||||||
);
|
}
|
||||||
const { state, failures } = deriveState(
|
} catch {
|
||||||
result.ok,
|
// ingest still proceeds
|
||||||
result.latencyMs,
|
|
||||||
prev
|
|
||||||
? {
|
|
||||||
consecutive_failures: prev.consecutive_failures,
|
|
||||||
status: prev.status,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
options.thresholds,
|
|
||||||
);
|
|
||||||
const prevState: IpHealthState | null = prev
|
|
||||||
? (prev.status as IpHealthState)
|
|
||||||
: null;
|
|
||||||
repos.upsertIpHealthStatus(
|
|
||||||
db,
|
|
||||||
target.scope,
|
|
||||||
target.ref_id,
|
|
||||||
target.ip,
|
|
||||||
state,
|
|
||||||
result.latencyMs,
|
|
||||||
failures,
|
|
||||||
result.error,
|
|
||||||
);
|
|
||||||
if (prevState !== state) {
|
|
||||||
options.onStatusChange?.(target, prevState, state);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Orphan rows (old IPs / hostname keys) still feed MAX latency on group badge.
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -376,6 +539,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") {
|
||||||
@@ -402,6 +568,7 @@ export async function runDomainMonitors(
|
|||||||
result.latencyMs,
|
result.latencyMs,
|
||||||
{
|
{
|
||||||
consecutive_failures: result.ok ? 0 : 1,
|
consecutive_failures: result.ok ? 0 : 1,
|
||||||
|
consecutive_successes: result.ok ? 1 : 0,
|
||||||
status: prevStatus,
|
status: prevStatus,
|
||||||
},
|
},
|
||||||
thresholds,
|
thresholds,
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { Db } from "@cfdm/db";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type { CfHealthCheck, HealthCheckTarget, OriginHealthCheck } from "@cfdm/shared";
|
||||||
|
import type { CloudflareClient } from "../../lib/cf-client.js";
|
||||||
|
import type { CfHealthCheckPayload } from "../../lib/cloudflare/healthcheck-service.js";
|
||||||
|
import { AppError } from "../../errors.js";
|
||||||
|
import type { ProbeResult } from "../health-check-service.js";
|
||||||
|
import type { HealthCheckProvider } from "./provider.js";
|
||||||
|
|
||||||
|
function toPayload(
|
||||||
|
check: OriginHealthCheck,
|
||||||
|
address: string,
|
||||||
|
): CfHealthCheckPayload {
|
||||||
|
const type = (check.protocol || "TCP").toUpperCase() as "HTTP" | "HTTPS" | "TCP";
|
||||||
|
const payload: CfHealthCheckPayload = {
|
||||||
|
address,
|
||||||
|
name: check.name,
|
||||||
|
type,
|
||||||
|
interval: check.interval_sec,
|
||||||
|
timeout: check.timeout,
|
||||||
|
retries: check.retries,
|
||||||
|
consecutive_fails: check.consecutive_fails,
|
||||||
|
consecutive_successes: check.consecutive_successes,
|
||||||
|
suspended: check.suspended,
|
||||||
|
};
|
||||||
|
if (type === "HTTP" || type === "HTTPS") {
|
||||||
|
payload.http_config = {
|
||||||
|
method: check.method ?? "GET",
|
||||||
|
path: check.path ?? "/",
|
||||||
|
expected_codes: check.expected_status != null ? [String(check.expected_status)] : ["200"],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
payload.tcp_config = { method: "connection_established" };
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CloudflareHealthCheckProvider implements HealthCheckProvider {
|
||||||
|
readonly kind = "cloudflare" as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly db: Db,
|
||||||
|
private readonly cf: CloudflareClient,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async probe(target: HealthCheckTarget): Promise<ProbeResult> {
|
||||||
|
const node = repos.findNodeByIp(this.db, target.ip);
|
||||||
|
if (!node?.health_check_id) {
|
||||||
|
return { ok: false, latencyMs: 0, error: "нет Cloudflare Health Check" };
|
||||||
|
}
|
||||||
|
const check = repos.getHealthCheck(this.db, node.health_check_id);
|
||||||
|
if (!check.cf_zone_id || !check.cf_healthcheck_id) {
|
||||||
|
return { ok: false, latencyMs: 0, error: "Cloudflare Health Check не синхронизирован" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const remote = await this.cf.getHealthCheck(check.cf_zone_id, check.cf_healthcheck_id);
|
||||||
|
const status = (remote.status ?? "").toLowerCase();
|
||||||
|
const ok = status === "healthy" || status === "ok";
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: ok ? null : remote.status ?? "unhealthy",
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
latencyMs: 0,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncCreate(
|
||||||
|
check: OriginHealthCheck,
|
||||||
|
zoneId: string,
|
||||||
|
address: string,
|
||||||
|
): Promise<CfHealthCheck> {
|
||||||
|
try {
|
||||||
|
const remote = await this.cf.createHealthCheck(zoneId, toPayload(check, address));
|
||||||
|
repos.updateHealthCheck(this.db, check.id, {
|
||||||
|
cf_healthcheck_id: remote.id,
|
||||||
|
cf_zone_id: zoneId,
|
||||||
|
provider: "cloudflare",
|
||||||
|
});
|
||||||
|
return remote;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AppError) throw err;
|
||||||
|
throw AppError.healthcheckCreateFailed(
|
||||||
|
err instanceof Error ? err.message : "не удалось создать Cloudflare Health Check",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncUpdate(
|
||||||
|
check: OriginHealthCheck,
|
||||||
|
address: string,
|
||||||
|
): Promise<CfHealthCheck> {
|
||||||
|
if (!check.cf_zone_id || !check.cf_healthcheck_id) {
|
||||||
|
throw AppError.healthcheckCreateFailed("Cloudflare Health Check не привязан к зоне");
|
||||||
|
}
|
||||||
|
return this.cf.updateHealthCheck(
|
||||||
|
check.cf_zone_id,
|
||||||
|
check.cf_healthcheck_id,
|
||||||
|
toPayload(check, address),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncDelete(check: OriginHealthCheck): Promise<void> {
|
||||||
|
if (!check.cf_zone_id || !check.cf_healthcheck_id) return;
|
||||||
|
await this.cf.deleteHealthCheck(check.cf_zone_id, check.cf_healthcheck_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,11 @@
|
|||||||
|
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||||
|
import { probeTarget, type ProbeResult } from "../health-check-service.js";
|
||||||
|
import type { HealthCheckProvider } from "./provider.js";
|
||||||
|
|
||||||
|
export class LocalHealthCheckProvider implements HealthCheckProvider {
|
||||||
|
readonly kind = "local" as const;
|
||||||
|
|
||||||
|
probe(target: HealthCheckTarget): Promise<ProbeResult> {
|
||||||
|
return probeTarget(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,7 @@
|
|||||||
|
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||||
|
import type { ProbeResult } from "../health-check-service.js";
|
||||||
|
|
||||||
|
export interface HealthCheckProvider {
|
||||||
|
readonly kind: "local" | "cloudflare";
|
||||||
|
probe(target: HealthCheckTarget): Promise<ProbeResult>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { IpHealthState, NodeHealthState } from "@cfdm/shared";
|
||||||
|
|
||||||
|
export interface HealthThresholds {
|
||||||
|
degradedFailures: number;
|
||||||
|
downFailures: number;
|
||||||
|
successRecoveries: number;
|
||||||
|
latencyWarnMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthCounters {
|
||||||
|
status: string;
|
||||||
|
consecutive_failures: number;
|
||||||
|
consecutive_successes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NextHealth {
|
||||||
|
legacy: IpHealthState;
|
||||||
|
node: NodeHealthState;
|
||||||
|
failures: number;
|
||||||
|
successes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wasHealthy(status: string | undefined): boolean {
|
||||||
|
return status === "up" || status === "healthy";
|
||||||
|
}
|
||||||
|
|
||||||
|
function wasUnhealthy(status: string | undefined): boolean {
|
||||||
|
return (
|
||||||
|
status === "down" ||
|
||||||
|
status === "unhealthy" ||
|
||||||
|
status === "checking" ||
|
||||||
|
status === "degraded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextHealthState(
|
||||||
|
ok: boolean,
|
||||||
|
latencyMs: number,
|
||||||
|
prev: HealthCounters | null,
|
||||||
|
thresholds: HealthThresholds,
|
||||||
|
): NextHealth {
|
||||||
|
if (!ok) {
|
||||||
|
const failures = (prev?.consecutive_failures ?? 0) + 1;
|
||||||
|
if (failures >= thresholds.downFailures) {
|
||||||
|
return { legacy: "down", node: "unhealthy", failures, successes: 0 };
|
||||||
|
}
|
||||||
|
return { legacy: "degraded", node: "degraded", failures, successes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latencyMs > thresholds.latencyWarnMs) {
|
||||||
|
return { legacy: "degraded", node: "degraded", failures: 0, successes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prev || wasHealthy(prev.status) || !wasUnhealthy(prev.status)) {
|
||||||
|
return {
|
||||||
|
legacy: "up",
|
||||||
|
node: "healthy",
|
||||||
|
failures: 0,
|
||||||
|
successes: (prev?.consecutive_successes ?? 0) + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const successes = (prev.consecutive_successes ?? 0) + 1;
|
||||||
|
if (successes >= thresholds.successRecoveries) {
|
||||||
|
return { legacy: "up", node: "healthy", failures: 0, successes };
|
||||||
|
}
|
||||||
|
return { legacy: "unknown", node: "checking", failures: 0, successes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toLegacyHealth(status: NodeHealthState | IpHealthState): IpHealthState {
|
||||||
|
if (status === "healthy" || status === "up") return "up";
|
||||||
|
if (status === "unhealthy" || status === "down") return "down";
|
||||||
|
if (status === "degraded") return "degraded";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import type { Db } from "@cfdm/db";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type {
|
||||||
|
CreateServiceNodeInput,
|
||||||
|
ServiceNode,
|
||||||
|
ServiceOverview,
|
||||||
|
UpdateServiceNodeInput,
|
||||||
|
} from "@cfdm/shared";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
import { isValidIpv4 } from "../lib/validators.js";
|
||||||
|
import { getView } from "./service-config-service.js";
|
||||||
|
import { selectActiveIpsByMode } from "./routing/index.js";
|
||||||
|
|
||||||
|
function assertAddress(address: string): void {
|
||||||
|
if (!isValidIpv4(address)) {
|
||||||
|
throw AppError.invalidIp(`Некорректный IP-адрес: ${address}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listNodes(db: Db, serviceId: number): ServiceNode[] {
|
||||||
|
repos.getService(db, serviceId);
|
||||||
|
return repos.listNodes(db, serviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNode(
|
||||||
|
db: Db,
|
||||||
|
serviceId: number,
|
||||||
|
input: CreateServiceNodeInput,
|
||||||
|
): ServiceNode {
|
||||||
|
repos.getService(db, serviceId);
|
||||||
|
assertAddress(input.address);
|
||||||
|
try {
|
||||||
|
return repos.createNode(db, serviceId, {
|
||||||
|
address: input.address,
|
||||||
|
protocol: input.protocol,
|
||||||
|
port: input.port,
|
||||||
|
enabled: input.enabled,
|
||||||
|
priority: input.priority,
|
||||||
|
weight: input.weight,
|
||||||
|
health_check_id: input.health_check_id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === "ConflictError") {
|
||||||
|
throw AppError.conflict(err.message);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateNode(
|
||||||
|
db: Db,
|
||||||
|
serviceId: number,
|
||||||
|
nodeId: number,
|
||||||
|
patch: UpdateServiceNodeInput,
|
||||||
|
): ServiceNode {
|
||||||
|
const node = repos.getNode(db, nodeId);
|
||||||
|
if (node.service_id !== serviceId) {
|
||||||
|
throw AppError.notFound(`node ${nodeId}`);
|
||||||
|
}
|
||||||
|
if (patch.address) assertAddress(patch.address);
|
||||||
|
return repos.updateNode(db, nodeId, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteNode(db: Db, serviceId: number, nodeId: number): void {
|
||||||
|
const node = repos.getNode(db, nodeId);
|
||||||
|
if (node.service_id !== serviceId) {
|
||||||
|
throw AppError.notFound(`node ${nodeId}`);
|
||||||
|
}
|
||||||
|
repos.deleteNode(db, nodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOverview(
|
||||||
|
db: Db,
|
||||||
|
serviceId: number,
|
||||||
|
): Promise<ServiceOverview> {
|
||||||
|
const service = await getView(db, serviceId);
|
||||||
|
const nodes = repos.listNodes(db, serviceId);
|
||||||
|
const bindings = repos.listBindingsByService(db, serviceId);
|
||||||
|
const first = bindings[0];
|
||||||
|
const routing = first?.routing_strategy ?? first?.lb_mode ?? "round_robin";
|
||||||
|
const healthCheck =
|
||||||
|
nodes
|
||||||
|
.map((n) => n.health_check_id)
|
||||||
|
.find((id): id is number => id != null) != null
|
||||||
|
? repos.getHealthCheck(
|
||||||
|
db,
|
||||||
|
nodes.find((n) => n.health_check_id != null)!.health_check_id!,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const active = new Set<string>();
|
||||||
|
for (const binding of bindings) {
|
||||||
|
const metas = repos.listBindingIpsWithMeta(db, binding.id);
|
||||||
|
const rows = metas.map((entry) => {
|
||||||
|
const status = repos.getIpHealthStatusRow(db, "binding", binding.id, entry.ip);
|
||||||
|
return {
|
||||||
|
ip: entry.ip,
|
||||||
|
weight: entry.weight,
|
||||||
|
priority: entry.priority,
|
||||||
|
health: status ? status.status : ("unknown" as const),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
for (const ip of selectActiveIpsByMode(
|
||||||
|
{
|
||||||
|
lb_mode: binding.lb_mode,
|
||||||
|
health_check_enabled: binding.health_check_enabled,
|
||||||
|
},
|
||||||
|
rows,
|
||||||
|
)) {
|
||||||
|
active.add(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
nodes,
|
||||||
|
health_check: healthCheck,
|
||||||
|
routing_strategy: routing,
|
||||||
|
active_addresses: [...active],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function opsSummary(db: Db) {
|
||||||
|
const allNodes = repos.listAllNodes(db);
|
||||||
|
const services = repos.listServices(db);
|
||||||
|
const domains = repos.listDomains(db);
|
||||||
|
const healthy = allNodes.filter(
|
||||||
|
(n) => n.health_status === "healthy" || n.health_status === "up",
|
||||||
|
).length;
|
||||||
|
const unhealthy = allNodes.filter(
|
||||||
|
(n) => n.health_status === "unhealthy" || n.health_status === "down",
|
||||||
|
).length;
|
||||||
|
const failoverActive = repos.listAllBindings(db).filter((binding) => {
|
||||||
|
if (binding.lb_mode !== "failover" || !binding.health_check_enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return binding.target_ips.some((ip) => {
|
||||||
|
const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip);
|
||||||
|
return row?.status === "down";
|
||||||
|
});
|
||||||
|
}).length;
|
||||||
|
return {
|
||||||
|
domains: domains.length,
|
||||||
|
services: services.length,
|
||||||
|
nodes: allNodes.length,
|
||||||
|
healthy,
|
||||||
|
unhealthy,
|
||||||
|
active_failovers: failoverActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { Db } from "@cfdm/db";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type {
|
||||||
|
CreateOriginHealthCheckInput,
|
||||||
|
OriginHealthCheck,
|
||||||
|
} from "@cfdm/shared";
|
||||||
|
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
import { CloudflareHealthCheckProvider } from "./health/cloudflare.js";
|
||||||
|
|
||||||
|
export function listOriginHealthChecks(db: Db): OriginHealthCheck[] {
|
||||||
|
return repos.listHealthChecks(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOriginHealthCheck(db: Db, id: number): OriginHealthCheck {
|
||||||
|
return repos.getHealthCheck(db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOriginHealthCheck(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
input: CreateOriginHealthCheckInput,
|
||||||
|
): Promise<OriginHealthCheck> {
|
||||||
|
const protocol = (input.protocol ?? "tcp").toLowerCase();
|
||||||
|
const check = repos.createHealthCheck(db, {
|
||||||
|
provider: input.provider,
|
||||||
|
name: input.name,
|
||||||
|
cf_zone_id: input.cf_zone_id ?? null,
|
||||||
|
protocol,
|
||||||
|
path: input.path,
|
||||||
|
method: input.method,
|
||||||
|
timeout: input.timeout,
|
||||||
|
interval_sec: input.interval_sec,
|
||||||
|
retries: input.retries,
|
||||||
|
expected_status: input.expected_status,
|
||||||
|
consecutive_fails: input.consecutive_fails,
|
||||||
|
consecutive_successes: input.consecutive_successes,
|
||||||
|
suspended: input.suspended,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.node_id) {
|
||||||
|
const node = repos.getNode(db, input.node_id);
|
||||||
|
repos.updateNode(db, node.id, { health_check_id: check.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.provider === "cloudflare") {
|
||||||
|
const zoneId = input.cf_zone_id;
|
||||||
|
if (!zoneId) {
|
||||||
|
throw AppError.zoneNotFound("укажите зону Cloudflare для Health Check");
|
||||||
|
}
|
||||||
|
const address = input.node_id
|
||||||
|
? repos.getNode(db, input.node_id).address
|
||||||
|
: check.name;
|
||||||
|
const provider = new CloudflareHealthCheckProvider(db, cf);
|
||||||
|
await provider.syncCreate(check, zoneId, address);
|
||||||
|
return repos.getHealthCheck(db, check.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return check;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOriginHealthCheck(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
id: number,
|
||||||
|
patch: Partial<CreateOriginHealthCheckInput>,
|
||||||
|
): Promise<OriginHealthCheck> {
|
||||||
|
const current = repos.getHealthCheck(db, id);
|
||||||
|
const updated = repos.updateHealthCheck(db, id, {
|
||||||
|
provider: patch.provider,
|
||||||
|
name: patch.name,
|
||||||
|
cf_zone_id: patch.cf_zone_id,
|
||||||
|
protocol: patch.protocol?.toLowerCase(),
|
||||||
|
path: patch.path,
|
||||||
|
method: patch.method,
|
||||||
|
timeout: patch.timeout,
|
||||||
|
interval_sec: patch.interval_sec,
|
||||||
|
retries: patch.retries,
|
||||||
|
expected_status: patch.expected_status,
|
||||||
|
consecutive_fails: patch.consecutive_fails,
|
||||||
|
consecutive_successes: patch.consecutive_successes,
|
||||||
|
suspended: patch.suspended,
|
||||||
|
});
|
||||||
|
if (updated.provider === "cloudflare" && updated.cf_healthcheck_id) {
|
||||||
|
const address = repos.findNodeByIp(db, updated.name)?.address ?? updated.name;
|
||||||
|
const provider = new CloudflareHealthCheckProvider(db, cf);
|
||||||
|
await provider.syncUpdate(updated, address);
|
||||||
|
}
|
||||||
|
void current;
|
||||||
|
return repos.getHealthCheck(db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOriginHealthCheck(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
id: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const check = repos.getHealthCheck(db, id);
|
||||||
|
if (check.provider === "cloudflare") {
|
||||||
|
const provider = new CloudflareHealthCheckProvider(db, cf);
|
||||||
|
await provider.syncDelete(check);
|
||||||
|
}
|
||||||
|
repos.deleteHealthCheck(db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncOriginHealthCheck(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
id: number,
|
||||||
|
): Promise<OriginHealthCheck> {
|
||||||
|
const check = repos.getHealthCheck(db, id);
|
||||||
|
if (check.provider !== "cloudflare") {
|
||||||
|
throw AppError.validation("синхронизация доступна только для Cloudflare Health Checks");
|
||||||
|
}
|
||||||
|
if (!check.cf_zone_id) {
|
||||||
|
throw AppError.zoneNotFound();
|
||||||
|
}
|
||||||
|
const provider = new CloudflareHealthCheckProvider(db, cf);
|
||||||
|
const address = repos.findNodeByIp(db, check.name)?.address ?? check.name;
|
||||||
|
if (check.cf_healthcheck_id) {
|
||||||
|
await provider.syncUpdate(check, address);
|
||||||
|
} else {
|
||||||
|
await provider.syncCreate(check, check.cf_zone_id, address);
|
||||||
|
}
|
||||||
|
return repos.getHealthCheck(db, id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const locks = new Map<number, Promise<void>>();
|
||||||
|
|
||||||
|
export async function withBindingLock<T>(
|
||||||
|
bindingId: number,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const previous = locks.get(bindingId) ?? Promise.resolve();
|
||||||
|
let release!: () => void;
|
||||||
|
const current = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
locks.set(
|
||||||
|
bindingId,
|
||||||
|
previous.then(() => current).catch(() => current),
|
||||||
|
);
|
||||||
|
await previous.catch(() => undefined);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
if (locks.get(bindingId) === current) {
|
||||||
|
locks.delete(bindingId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { LbIpRow } from "./types.js";
|
||||||
|
import { isHealthy } from "./health.js";
|
||||||
|
|
||||||
|
export function failoverDesired(rows: LbIpRow[]): string[] {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
const healthy = rows.filter((r) => isHealthy(r.health));
|
||||||
|
const pool = healthy.length > 0 ? healthy : rows;
|
||||||
|
const sorted = [...pool].sort(
|
||||||
|
(a, b) => a.priority - b.priority || a.weight - b.weight,
|
||||||
|
);
|
||||||
|
const minPriority = sorted[0]!.priority;
|
||||||
|
const primaries = sorted.filter((r) => r.priority === minPriority);
|
||||||
|
if (healthy.length > 0) {
|
||||||
|
return primaries.map((r) => r.ip);
|
||||||
|
}
|
||||||
|
return [sorted[0]!.ip];
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { IpHealthState, NodeHealthState } from "@cfdm/shared";
|
||||||
|
|
||||||
|
export function isHealthy(state: IpHealthState | NodeHealthState | string): boolean {
|
||||||
|
return state === "up" || state === "healthy";
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { LbMode } from "@cfdm/shared";
|
||||||
|
import { failoverDesired } from "./failover.js";
|
||||||
|
import { roundRobinDesired } from "./round-robin.js";
|
||||||
|
import type { LbIpRow, LbTargetConfig } from "./types.js";
|
||||||
|
|
||||||
|
export type { LbIpRow, LbTargetConfig } from "./types.js";
|
||||||
|
export { isHealthy } from "./health.js";
|
||||||
|
export { withBindingLock } from "./binding-lock.js";
|
||||||
|
|
||||||
|
export function selectActiveIpsByMode(
|
||||||
|
config: LbTargetConfig,
|
||||||
|
rows: LbIpRow[],
|
||||||
|
): string[] {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
if (config.lb_mode === "failover") {
|
||||||
|
return failoverDesired(rows);
|
||||||
|
}
|
||||||
|
// weighted = round_robin on DNS (one A per IP)
|
||||||
|
return roundRobinDesired(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strategyLabel(mode: LbMode): string {
|
||||||
|
if (mode === "failover") return "Failover";
|
||||||
|
if (mode === "weighted") return "Round Robin (weighted alias)";
|
||||||
|
return "Round Robin";
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { LbIpRow } from "./types.js";
|
||||||
|
import { isHealthy } from "./health.js";
|
||||||
|
|
||||||
|
export function roundRobinDesired(rows: LbIpRow[]): string[] {
|
||||||
|
const healthy = rows.filter((r) => isHealthy(r.health));
|
||||||
|
const pool = healthy.length > 0 ? healthy : rows;
|
||||||
|
return pool.map((r) => r.ip);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { IpHealthState, LbMode } from "@cfdm/shared";
|
||||||
|
|
||||||
|
export interface LbTargetConfig {
|
||||||
|
lb_mode: LbMode;
|
||||||
|
health_check_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LbIpRow {
|
||||||
|
ip: string;
|
||||||
|
weight: number;
|
||||||
|
priority: number;
|
||||||
|
health: IpHealthState;
|
||||||
|
}
|
||||||
@@ -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,54 @@ 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 {
|
||||||
|
isHealthy,
|
||||||
|
selectActiveIpsByMode,
|
||||||
|
withBindingLock,
|
||||||
|
type LbIpRow,
|
||||||
|
type LbTargetConfig,
|
||||||
|
} from "./routing/index.js";
|
||||||
|
|
||||||
|
export type { LbIpRow, LbTargetConfig };
|
||||||
|
export { selectActiveIpsByMode };
|
||||||
|
|
||||||
|
export function failoverARecordDiff(
|
||||||
|
existingA: readonly string[],
|
||||||
|
desiredIps: readonly string[],
|
||||||
|
): { added: string[]; removed: string[] } {
|
||||||
|
const before = new Set(existingA);
|
||||||
|
const after = new Set(desiredIps);
|
||||||
|
return {
|
||||||
|
added: desiredIps.filter((ip) => !before.has(ip)),
|
||||||
|
removed: existingA.filter((ip) => !after.has(ip)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFailoverDnsDiff(
|
||||||
|
db: Db,
|
||||||
|
bindingId: number,
|
||||||
|
hostname: string,
|
||||||
|
zoneName: string,
|
||||||
|
existingRecords: DnsRecord[],
|
||||||
|
desiredIps: string[],
|
||||||
|
): void {
|
||||||
|
const existingA = existingRecords
|
||||||
|
.filter((record) => record.record_type.toUpperCase() === "A")
|
||||||
|
.map((record) => record.content);
|
||||||
|
const { added, removed } = failoverARecordDiff(existingA, desiredIps);
|
||||||
|
if (added.length === 0 && removed.length === 0) return;
|
||||||
|
const binding = repos.getBinding(db, bindingId);
|
||||||
|
repos.insertFailoverLog(db, {
|
||||||
|
serviceId: binding.service_id,
|
||||||
|
bindingId,
|
||||||
|
fqdn: fqdnToDisplay(hostname, zoneName),
|
||||||
|
entries: [
|
||||||
|
...added.map((ip) => ({ ip, action: "added" as const })),
|
||||||
|
...removed.map((ip) => ({ ip, action: "removed" as const })),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServiceDomainInput {
|
export interface ServiceDomainInput {
|
||||||
fqdn: string;
|
fqdn: string;
|
||||||
@@ -41,6 +92,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 {
|
||||||
@@ -61,6 +115,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 {
|
||||||
@@ -77,6 +134,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 {
|
||||||
@@ -137,54 +197,6 @@ function aggregateSyncStatus(statuses: string[]): string | null {
|
|||||||
return statuses[0] ?? null;
|
return statuses[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHealthy(state: IpHealthState): boolean {
|
|
||||||
return state === "up" || state === "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LbTargetConfig {
|
|
||||||
lb_mode: LbMode;
|
|
||||||
health_check_enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LbIpRow {
|
|
||||||
ip: string;
|
|
||||||
weight: number;
|
|
||||||
priority: number;
|
|
||||||
health: IpHealthState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectActiveIpsByMode(
|
|
||||||
config: LbTargetConfig,
|
|
||||||
rows: LbIpRow[],
|
|
||||||
): string[] {
|
|
||||||
if (rows.length === 0) return [];
|
|
||||||
|
|
||||||
const healthy = rows.filter((r) => isHealthy(r.health));
|
|
||||||
const pool = healthy.length > 0 ? healthy : rows;
|
|
||||||
|
|
||||||
if (config.lb_mode === "failover") {
|
|
||||||
const sorted = [...pool].sort(
|
|
||||||
(a, b) => a.priority - b.priority || a.weight - b.weight,
|
|
||||||
);
|
|
||||||
const minPriority = sorted[0]!.priority;
|
|
||||||
const primaries = sorted.filter((r) => r.priority === minPriority);
|
|
||||||
if (healthy.length > 0) {
|
|
||||||
return primaries.map((r) => r.ip);
|
|
||||||
}
|
|
||||||
return [sorted[0]!.ip];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.lb_mode === "weighted") {
|
|
||||||
// Cloudflare не допускает дублирования A-записей с одинаковым name+content,
|
|
||||||
// поэтому weighted на уровне DNS реализован как RR по одному A на IP.
|
|
||||||
// Веса сохраняются в БД и используются для приоритизации/отображения;
|
|
||||||
// точное weighted-распределение требует CF Load Balancer (см. README).
|
|
||||||
return pool.map((r) => r.ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pool.map((r) => r.ip);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBindingLbState(
|
function getBindingLbState(
|
||||||
db: Db,
|
db: Db,
|
||||||
bindingId: number,
|
bindingId: number,
|
||||||
@@ -281,7 +293,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) => {
|
||||||
@@ -306,6 +322,11 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
|||||||
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
|
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { config, rows } = getBindingLbState(db, binding.id);
|
||||||
|
const bindingActiveIps = targetCname
|
||||||
|
? []
|
||||||
|
: selectActiveIpsByMode(config, rows);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
binding_id: binding.id,
|
binding_id: binding.id,
|
||||||
domain_id: binding.domain_id,
|
domain_id: binding.domain_id,
|
||||||
@@ -326,10 +347,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),
|
||||||
|
active_ips: bindingActiveIps,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activeIps = new Set<string>();
|
||||||
|
for (const domain of domainViews) {
|
||||||
|
for (const ip of domain.active_ips) {
|
||||||
|
activeIps.add(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: service.id,
|
id: service.id,
|
||||||
name: service.name,
|
name: service.name,
|
||||||
@@ -343,26 +378,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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -411,7 +528,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
|
||||||
@@ -440,6 +557,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: {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -600,6 +719,14 @@ async function syncBindingADns(
|
|||||||
|
|
||||||
if (desiredIps.length === 0) {
|
if (desiredIps.length === 0) {
|
||||||
repos.setBindingDnsRecordId(db, bindingId, null);
|
repos.setBindingDnsRecordId(db, bindingId, null);
|
||||||
|
recordFailoverDnsDiff(
|
||||||
|
db,
|
||||||
|
bindingId,
|
||||||
|
hostname,
|
||||||
|
zoneName,
|
||||||
|
existingRecords,
|
||||||
|
desiredIps,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,6 +775,14 @@ async function syncBindingADns(
|
|||||||
}
|
}
|
||||||
|
|
||||||
repos.setBindingDnsRecordId(db, bindingId, primaryId);
|
repos.setBindingDnsRecordId(db, bindingId, primaryId);
|
||||||
|
recordFailoverDnsDiff(
|
||||||
|
db,
|
||||||
|
bindingId,
|
||||||
|
hostname,
|
||||||
|
zoneName,
|
||||||
|
existingRecords,
|
||||||
|
desiredIps,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupBindingDns(
|
async function cleanupBindingDns(
|
||||||
@@ -1174,7 +1309,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,
|
||||||
@@ -1186,6 +1324,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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1260,6 +1401,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!;
|
||||||
}
|
}
|
||||||
@@ -1271,7 +1414,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,
|
||||||
@@ -1287,8 +1430,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(
|
||||||
@@ -1323,6 +1471,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) {
|
||||||
@@ -1330,6 +1481,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1371,6 +1523,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,
|
||||||
@@ -1411,6 +1616,25 @@ export function reorderServices(
|
|||||||
repos.reorderServices(db, groupId, serviceIds);
|
repos.reorderServices(db, groupId, serviceIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function applyBindingDesiredDns(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
bindingId: number,
|
||||||
|
desiredIps: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const binding = repos.getBinding(db, bindingId);
|
||||||
|
const cnameTarget = binding.cname_target?.trim() || null;
|
||||||
|
await syncBindingDns(
|
||||||
|
db,
|
||||||
|
cf,
|
||||||
|
binding.id,
|
||||||
|
binding.domain_id,
|
||||||
|
binding.hostname,
|
||||||
|
desiredIps,
|
||||||
|
cnameTarget,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function reconcileDnsForTarget(
|
export async function reconcileDnsForTarget(
|
||||||
db: Db,
|
db: Db,
|
||||||
cf: CloudflareClient,
|
cf: CloudflareClient,
|
||||||
@@ -1418,26 +1642,28 @@ export async function reconcileDnsForTarget(
|
|||||||
refId: number,
|
refId: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (scope === "binding") {
|
if (scope === "binding") {
|
||||||
const binding = repos.getBinding(db, refId);
|
await withBindingLock(refId, async () => {
|
||||||
if (!binding.health_check_enabled) return;
|
const binding = repos.getBinding(db, refId);
|
||||||
const service = repos.getService(db, binding.service_id);
|
if (!binding.health_check_enabled) return;
|
||||||
if (!shouldPushDns(db, service)) return;
|
const service = repos.getService(db, binding.service_id);
|
||||||
const cnameTarget = binding.cname_target?.trim() || null;
|
if (!shouldPushDns(db, service)) return;
|
||||||
if (cnameTarget) return;
|
const cnameTarget = binding.cname_target?.trim() || null;
|
||||||
const ips = repos.listServiceIps(db, service.id);
|
if (cnameTarget) return;
|
||||||
const targetIps = repos.listBindingIps(db, binding.id);
|
const ips = repos.listServiceIps(db, service.id);
|
||||||
validateTargetIpsInPool(targetIps, ips);
|
const targetIps = repos.listBindingIps(db, binding.id);
|
||||||
const activeIps = computeActiveIps(db, "binding", refId);
|
validateTargetIpsInPool(targetIps, ips);
|
||||||
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
|
const activeIps = computeActiveIps(db, "binding", refId);
|
||||||
await syncBindingDns(
|
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
|
||||||
db,
|
await syncBindingDns(
|
||||||
cf,
|
db,
|
||||||
binding.id,
|
cf,
|
||||||
binding.domain_id,
|
binding.id,
|
||||||
binding.hostname,
|
binding.domain_id,
|
||||||
desiredIps,
|
binding.hostname,
|
||||||
null,
|
desiredIps,
|
||||||
);
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
|
||||||
|
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||||
|
const config = loadConfig();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
payload: { username: config.adminUsername, password: "admin" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const { token } = res.json() as { token: string };
|
||||||
|
return { authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("origin health checks", () => {
|
||||||
|
it("creates a local health check", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/health-checks",
|
||||||
|
headers,
|
||||||
|
payload: { provider: "local", name: "origin-1", protocol: "tcp" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as { provider: string; name: string };
|
||||||
|
expect(body.provider).toBe("local");
|
||||||
|
expect(body.name).toBe("origin-1");
|
||||||
|
expect(repos.listHealthChecks(app.db)).toHaveLength(1);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { changeServiceDomain } from "../src/services/change-domain-service.js";
|
||||||
|
import { updateConfig } from "../src/services/service-config-service.js";
|
||||||
|
|
||||||
|
function mockCf(): CloudflareClient {
|
||||||
|
return {
|
||||||
|
listDnsRecords: async () => [],
|
||||||
|
createDnsRecord: async (_zoneId: string, payload: { type: string; name: string; content: string }) => ({
|
||||||
|
id: `cf-${payload.name}-${payload.content}`,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
updateDnsRecord: async (
|
||||||
|
_zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: { type: string; name: string; content: string },
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
patchDnsRecord: async (
|
||||||
|
_zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: { content?: string },
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
type: "A",
|
||||||
|
name: "app.example.com",
|
||||||
|
content: payload.content ?? "1.1.1.1",
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
deleteDnsRecord: async () => undefined,
|
||||||
|
listZones: async () => [
|
||||||
|
{ id: "zone-1", name: "example.com", status: "active" },
|
||||||
|
{ id: "zone-2", name: "other.com", status: "active" },
|
||||||
|
],
|
||||||
|
} as unknown as CloudflareClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("change-domain", () => {
|
||||||
|
it("dry-run lists FQDN from → to", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const cf = mockCf();
|
||||||
|
const from = repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||||
|
const to = repos.createDomain(app.db, null, "other.com", "zone-2");
|
||||||
|
const service = repos.createService(app.db, "App", "app");
|
||||||
|
await updateConfig(app.db, cf, service.id, {
|
||||||
|
ips: ["1.1.1.1"],
|
||||||
|
domains: [{ fqdn: "app.example.com", target_ips: ["1.1.1.1"] }],
|
||||||
|
});
|
||||||
|
const preview = await changeServiceDomain(app.db, cf, service.id, {
|
||||||
|
from_domain_id: from.id,
|
||||||
|
to_domain_id: to.id,
|
||||||
|
dry_run: true,
|
||||||
|
});
|
||||||
|
expect(preview.applied).toBe(false);
|
||||||
|
expect(preview.items[0]?.from_fqdn).toBe("app.example.com");
|
||||||
|
expect(preview.items[0]?.to_fqdn).toBe("app.other.com");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { changeBindingIp } from "../src/services/change-ip-service.js";
|
||||||
|
import { withBindingLock } from "../src/services/routing/index.js";
|
||||||
|
import { updateConfig } from "../src/services/service-config-service.js";
|
||||||
|
|
||||||
|
function mockCf(): CloudflareClient {
|
||||||
|
return {
|
||||||
|
listDnsRecords: async () => [],
|
||||||
|
createDnsRecord: async (_zoneId: string, payload: { type: string; name: string; content: string }) => ({
|
||||||
|
id: `cf-${payload.name}-${payload.content}`,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
updateDnsRecord: async (
|
||||||
|
_zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: { type: string; name: string; content: string },
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
patchDnsRecord: async (
|
||||||
|
_zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: { content?: string },
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
type: "A",
|
||||||
|
name: "panel.example.com",
|
||||||
|
content: payload.content ?? "0.0.0.0",
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
deleteDnsRecord: async () => undefined,
|
||||||
|
listZones: async () => [{ id: "zone-1", name: "example.com", status: "active" }],
|
||||||
|
} as unknown as CloudflareClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("change-ip", () => {
|
||||||
|
it("dry-run previews from → to without writing", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const cf = mockCf();
|
||||||
|
repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||||
|
const service = repos.createService(app.db, "Panel", "panel");
|
||||||
|
await updateConfig(app.db, cf, service.id, {
|
||||||
|
ips: ["10.0.0.10"],
|
||||||
|
domains: [{ fqdn: "panel.example.com", target_ips: ["10.0.0.10"] }],
|
||||||
|
});
|
||||||
|
const bindings = repos.listBindingsByService(app.db, service.id);
|
||||||
|
const preview = await changeBindingIp(app.db, cf, bindings[0]!.id, {
|
||||||
|
from_ip: "10.0.0.10",
|
||||||
|
to_ip: "10.0.0.20",
|
||||||
|
dry_run: true,
|
||||||
|
});
|
||||||
|
expect(preview.applied).toBe(false);
|
||||||
|
expect(preview.message).toBe("10.0.0.10 → 10.0.0.20");
|
||||||
|
expect(repos.listBindingIps(app.db, bindings[0]!.id)).toEqual(["10.0.0.10"]);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("apply patches binding IP", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const cf = mockCf();
|
||||||
|
repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||||
|
const service = repos.createService(app.db, "Panel", "panel");
|
||||||
|
await updateConfig(app.db, cf, service.id, {
|
||||||
|
ips: ["10.0.0.10"],
|
||||||
|
domains: [{ fqdn: "panel.example.com", target_ips: ["10.0.0.10"] }],
|
||||||
|
});
|
||||||
|
const binding = repos.listBindingsByService(app.db, service.id)[0]!;
|
||||||
|
const result = await changeBindingIp(app.db, cf, binding.id, {
|
||||||
|
from_ip: "10.0.0.10",
|
||||||
|
to_ip: "10.0.0.20",
|
||||||
|
});
|
||||||
|
expect(result.applied).toBe(true);
|
||||||
|
expect(repos.listBindingIps(app.db, binding.id)).toEqual(["10.0.0.20"]);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes concurrent binding locks", async () => {
|
||||||
|
const order: number[] = [];
|
||||||
|
await Promise.all([
|
||||||
|
withBindingLock(1, async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
order.push(1);
|
||||||
|
}),
|
||||||
|
withBindingLock(1, async () => {
|
||||||
|
order.push(2);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(order).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { failoverARecordDiff } from "../src/services/service-config-service.js";
|
||||||
|
|
||||||
|
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||||
|
const config = loadConfig();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
payload: { username: config.adminUsername, password: "admin" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const { token } = res.json() as { token: string };
|
||||||
|
return { authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("failoverARecordDiff", () => {
|
||||||
|
it("diffs added and removed A contents", () => {
|
||||||
|
expect(
|
||||||
|
failoverARecordDiff(
|
||||||
|
["130.49.213.153", "93.115.203.183"],
|
||||||
|
["93.115.203.183"],
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
added: [],
|
||||||
|
removed: ["130.49.213.153"],
|
||||||
|
});
|
||||||
|
expect(failoverARecordDiff(["10.0.0.1"], ["10.0.0.1", "10.0.0.2"])).toEqual({
|
||||||
|
added: ["10.0.0.2"],
|
||||||
|
removed: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /services/:id/failover-log", () => {
|
||||||
|
it("returns add/remove rows for the service", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(app.db, null, "rkns.top", "zone-1");
|
||||||
|
const service = repos.createService(app.db, "MSK Hip", "msk-hip");
|
||||||
|
const binding = repos.insertBinding(app.db, domain.id, service.id, "gt", null);
|
||||||
|
|
||||||
|
repos.insertFailoverLog(app.db, {
|
||||||
|
serviceId: service.id,
|
||||||
|
bindingId: binding.id,
|
||||||
|
fqdn: "gt.rkns.top",
|
||||||
|
entries: [
|
||||||
|
{ ip: "130.49.213.153", action: "removed" },
|
||||||
|
{ ip: "93.115.203.183", action: "added" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v1/services/${service.id}/failover-log`,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as {
|
||||||
|
items: Array<{ ip: string; fqdn: string; action: string }>;
|
||||||
|
};
|
||||||
|
expect(body.items).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
ip: "130.49.213.153",
|
||||||
|
fqdn: "gt.rkns.top",
|
||||||
|
action: "removed",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
ip: "93.115.203.183",
|
||||||
|
fqdn: "gt.rkns.top",
|
||||||
|
action: "added",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("failover_log table", () => {
|
||||||
|
it("lists newest first", () => {
|
||||||
|
const { db, sqlite } = createMemoryDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||||
|
const service = repos.createService(db, "Panel", "panel");
|
||||||
|
const binding = repos.insertBinding(db, domain.id, service.id, "panel", null);
|
||||||
|
|
||||||
|
repos.insertFailoverLog(db, {
|
||||||
|
serviceId: service.id,
|
||||||
|
bindingId: binding.id,
|
||||||
|
fqdn: "panel.example.com",
|
||||||
|
entries: [{ ip: "1.1.1.1", action: "removed" }],
|
||||||
|
});
|
||||||
|
repos.insertFailoverLog(db, {
|
||||||
|
serviceId: service.id,
|
||||||
|
bindingId: binding.id,
|
||||||
|
fqdn: "panel.example.com",
|
||||||
|
entries: [{ ip: "1.1.1.1", action: "added" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = repos.listFailoverLogForService(db, service.id);
|
||||||
|
expect(rows.map((row) => row.action)).toEqual(["added", "removed"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,205 @@ 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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mark node unhealthy when binding majority is OK and group local fails", async () => {
|
||||||
|
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||||
|
const { db, sqlite } = createMemoryDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
|
||||||
|
const tcp = await startTcpServer();
|
||||||
|
try {
|
||||||
|
const domain = repos.createDomain(db, null, "example.com", "zone-id");
|
||||||
|
const group = repos.createServiceGroup(
|
||||||
|
db,
|
||||||
|
"VPN",
|
||||||
|
"vpn",
|
||||||
|
null,
|
||||||
|
"vpn.example.com",
|
||||||
|
{
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "http",
|
||||||
|
health_check_port: 1,
|
||||||
|
health_check_timeout_ms: 200,
|
||||||
|
health_check_path: "/",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const service = repos.createService(db, "Svc", "svc");
|
||||||
|
repos.setServiceGroup(db, service.id, group.id);
|
||||||
|
repos.setServiceEnabled(db, service.id, true);
|
||||||
|
const binding = repos.insertBinding(db, domain.id, service.id, "@", null);
|
||||||
|
repos.updateBindingLbConfig(db, binding.id, {
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: tcp.port,
|
||||||
|
health_check_timeout_ms: 500,
|
||||||
|
});
|
||||||
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||||
|
{ ip: "127.0.0.1", weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
const node = repos.findNodeByIp(db, "127.0.0.1");
|
||||||
|
expect(node).not.toBeNull();
|
||||||
|
|
||||||
|
await healthCheckService.runAllChecks(db, {
|
||||||
|
probeGapMs: 0,
|
||||||
|
thresholds: {
|
||||||
|
degradedFailures: 1,
|
||||||
|
downFailures: 1,
|
||||||
|
latencyWarnMs: 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bindingHealth = repos.getIpHealthStatusRow(
|
||||||
|
db,
|
||||||
|
"binding",
|
||||||
|
binding.id,
|
||||||
|
"127.0.0.1",
|
||||||
|
);
|
||||||
|
const groupHealth = repos.getIpHealthStatusRow(
|
||||||
|
db,
|
||||||
|
"group",
|
||||||
|
group.id,
|
||||||
|
"127.0.0.1",
|
||||||
|
);
|
||||||
|
const after = repos.getNode(db, node!.id);
|
||||||
|
|
||||||
|
expect(bindingHealth?.status).toBe("up");
|
||||||
|
expect(groupHealth?.status).toBe("down");
|
||||||
|
expect(after.health_status).toBe("healthy");
|
||||||
|
expect(after.consecutive_failures).toBe(0);
|
||||||
|
expect(after.last_failure_reason).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => tcp.server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getView is up when any binding IP is up", 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, "MSK Hip", "msk-hip");
|
||||||
|
repos.replaceServiceIps(db, service.id, ["10.0.0.1", "10.0.0.2"]);
|
||||||
|
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
|
||||||
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||||
|
{ ip: "10.0.0.1", weight: 1, priority: 1 },
|
||||||
|
{ ip: "10.0.0.2", weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
repos.upsertIpHealthStatus(
|
||||||
|
db,
|
||||||
|
"binding",
|
||||||
|
binding.id,
|
||||||
|
"10.0.0.1",
|
||||||
|
"up",
|
||||||
|
12,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
repos.upsertIpHealthStatus(
|
||||||
|
db,
|
||||||
|
"binding",
|
||||||
|
binding.id,
|
||||||
|
"10.0.0.2",
|
||||||
|
"down",
|
||||||
|
null,
|
||||||
|
5,
|
||||||
|
"timeout",
|
||||||
|
);
|
||||||
|
|
||||||
|
const view = await getView(db, service.id);
|
||||||
|
expect(view.health_status).toBe("up");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,62 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { nextHealthState } from "../src/services/health/state-machine.js";
|
||||||
|
|
||||||
|
const thresholds = {
|
||||||
|
degradedFailures: 1,
|
||||||
|
downFailures: 2,
|
||||||
|
successRecoveries: 2,
|
||||||
|
latencyWarnMs: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("health state machine", () => {
|
||||||
|
it("first success from unknown is healthy immediately", () => {
|
||||||
|
const next = nextHealthState(true, 20, null, thresholds);
|
||||||
|
expect(next.legacy).toBe("up");
|
||||||
|
expect(next.node).toBe("healthy");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovery from down goes checking until consecutive successes", () => {
|
||||||
|
const first = nextHealthState(
|
||||||
|
true,
|
||||||
|
10,
|
||||||
|
{ status: "down", consecutive_failures: 3, consecutive_successes: 0 },
|
||||||
|
thresholds,
|
||||||
|
);
|
||||||
|
expect(first.node).toBe("checking");
|
||||||
|
expect(first.legacy).toBe("unknown");
|
||||||
|
const second = nextHealthState(
|
||||||
|
true,
|
||||||
|
10,
|
||||||
|
{
|
||||||
|
status: "checking",
|
||||||
|
consecutive_failures: 0,
|
||||||
|
consecutive_successes: first.successes,
|
||||||
|
},
|
||||||
|
thresholds,
|
||||||
|
);
|
||||||
|
expect(second.node).toBe("healthy");
|
||||||
|
expect(second.legacy).toBe("up");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two failures mark unhealthy", () => {
|
||||||
|
const first = nextHealthState(
|
||||||
|
false,
|
||||||
|
5,
|
||||||
|
{ status: "up", consecutive_failures: 0, consecutive_successes: 1 },
|
||||||
|
thresholds,
|
||||||
|
);
|
||||||
|
expect(first.node).toBe("degraded");
|
||||||
|
const second = nextHealthState(
|
||||||
|
false,
|
||||||
|
5,
|
||||||
|
{
|
||||||
|
status: "degraded",
|
||||||
|
consecutive_failures: first.failures,
|
||||||
|
consecutive_successes: 0,
|
||||||
|
},
|
||||||
|
thresholds,
|
||||||
|
);
|
||||||
|
expect(second.node).toBe("unhealthy");
|
||||||
|
expect(second.legacy).toBe("down");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,7 +39,10 @@ describe("selectActiveIpsByMode", () => {
|
|||||||
lb_mode: "round_robin",
|
lb_mode: "round_robin",
|
||||||
health_check_enabled: true,
|
health_check_enabled: true,
|
||||||
};
|
};
|
||||||
const rows = [row("1.1.1.1", { health: "unknown" }), row("2.2.2.2")];
|
const rows = [
|
||||||
|
row("1.1.1.1", { health: "unknown" }),
|
||||||
|
row("2.2.2.2", { health: "unknown" }),
|
||||||
|
];
|
||||||
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
||||||
"1.1.1.1",
|
"1.1.1.1",
|
||||||
"2.2.2.2",
|
"2.2.2.2",
|
||||||
@@ -88,6 +91,18 @@ describe("selectActiveIpsByMode", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("round_robin excludes unknown when another ip is up", () => {
|
||||||
|
const config: LbTargetConfig = {
|
||||||
|
lb_mode: "round_robin",
|
||||||
|
health_check_enabled: true,
|
||||||
|
};
|
||||||
|
const rows = [
|
||||||
|
row("1.1.1.1", { health: "up" }),
|
||||||
|
row("2.2.2.2", { health: "unknown" }),
|
||||||
|
];
|
||||||
|
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns empty array for no rows", () => {
|
it("returns empty array for no rows", () => {
|
||||||
const config: LbTargetConfig = {
|
const config: LbTargetConfig = {
|
||||||
lb_mode: "round_robin",
|
lb_mode: "round_robin",
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
|
||||||
|
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||||
|
const config = loadConfig();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
payload: { username: config.adminUsername, password: "admin" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const { token } = res.json() as { token: string };
|
||||||
|
return { authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("service nodes API", () => {
|
||||||
|
it("creates and lists nodes without changing empty DNS pool until bound", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const service = repos.createService(app.db, "Panel", "panel");
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/v1/services/${service.id}/nodes`,
|
||||||
|
headers,
|
||||||
|
payload: { address: "10.0.0.8", protocol: "tcp" },
|
||||||
|
});
|
||||||
|
expect(created.statusCode).toBe(200);
|
||||||
|
const node = created.json() as { address: string };
|
||||||
|
expect(node.address).toBe("10.0.0.8");
|
||||||
|
expect(repos.listServiceIps(app.db, service.id)).toContain("10.0.0.8");
|
||||||
|
|
||||||
|
const listed = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v1/services/${service.id}/nodes`,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(listed.statusCode).toBe(200);
|
||||||
|
expect(listed.json()).toHaveLength(1);
|
||||||
|
|
||||||
|
const overview = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v1/services/${service.id}/overview`,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(overview.statusCode).toBe(200);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,4 +78,53 @@ describe("service bindings prune", () => {
|
|||||||
expect(bindings).toHaveLength(2);
|
expect(bindings).toHaveLength(2);
|
||||||
expect(bindings.map((b) => b.hostname).sort()).toEqual(["api", "www"]);
|
expect(bindings.map((b) => b.hostname).sort()).toEqual(["api", "www"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updateConfig with extra FQDN per IP does not throw when group health-check is on", async () => {
|
||||||
|
const db = setupDb();
|
||||||
|
const cf = mockCf();
|
||||||
|
const health = {
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "tcp" as const,
|
||||||
|
health_check_port: 443,
|
||||||
|
health_check_providers: ["local", "cloudflare", "globalping"] as const,
|
||||||
|
health_check_aggregate: "majority" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
repos.createDomain(db, null, "example.com", "cf-zone-example");
|
||||||
|
const group = repos.createServiceGroup(
|
||||||
|
db,
|
||||||
|
"VPN",
|
||||||
|
"vpn",
|
||||||
|
null,
|
||||||
|
"vpn.example.com",
|
||||||
|
{ ...health },
|
||||||
|
);
|
||||||
|
const service = repos.createService(db, "GT", "gt");
|
||||||
|
repos.setServiceGroup(db, service.id, group.id);
|
||||||
|
repos.setServiceEnabled(db, service.id, true);
|
||||||
|
|
||||||
|
const view = await updateConfig(db, cf, service.id, {
|
||||||
|
ips: ["93.115.203.183", "130.49.213.153"],
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
fqdn: "gt.example.com",
|
||||||
|
target_ips: ["93.115.203.183", "130.49.213.153"],
|
||||||
|
...health,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fqdn: "rutg.example.com",
|
||||||
|
target_ips: ["93.115.203.183"],
|
||||||
|
...health,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fqdn: "nsgt.example.com",
|
||||||
|
target_ips: ["130.49.213.153"],
|
||||||
|
...health,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view.domains).toHaveLength(3);
|
||||||
|
expect(view.ips.sort()).toEqual(["130.49.213.153", "93.115.203.183"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.find((s) => s.id === created.id);
|
||||||
?.services.some((s) => s.id === created.id),
|
expect(listed).toBeDefined();
|
||||||
).toBe(true);
|
expect(listed?.lb_mode).toBe("round_robin");
|
||||||
|
expect(listed?.active_ips).toEqual(["1.2.3.4"]);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsr generate && tsc -b && vite build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@tanstack/router-cli": "^1.167.31",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -25,15 +25,18 @@ import {
|
|||||||
const infrastructureNav = [
|
const infrastructureNav = [
|
||||||
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
|
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
|
||||||
{ to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false },
|
{ to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false },
|
||||||
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const operationsNav = [
|
const operationsNav = [
|
||||||
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
|
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
|
||||||
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
|
||||||
{ to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
|
{ to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const secondaryNav = [
|
||||||
|
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
|
||||||
|
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
||||||
|
] as const
|
||||||
|
|
||||||
function isNavActive(
|
function isNavActive(
|
||||||
pathname: string,
|
pathname: string,
|
||||||
to: string,
|
to: string,
|
||||||
@@ -106,6 +109,7 @@ export function AppSidebar() {
|
|||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
|
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
|
||||||
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
|
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
|
||||||
|
<NavSection label="Прочее" items={secondaryNav} pathname={pathname} />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser />
|
<NavUser />
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@cfdm/ui/components/select'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { changeServiceDomain, domainsListQueryOptions } from '@/queries'
|
||||||
|
|
||||||
|
interface ChangeDomainSheetProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
serviceId: number
|
||||||
|
fromDomainId?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
from_domain_id: string
|
||||||
|
to_domain_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChangeDomainSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
serviceId,
|
||||||
|
fromDomainId,
|
||||||
|
}: ChangeDomainSheetProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const domainsQuery = useQuery(domainsListQueryOptions())
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: {
|
||||||
|
from_domain_id: fromDomainId ? String(fromDomainId) : '',
|
||||||
|
to_domain_id: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
|
const [preview, setPreview] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.reset({
|
||||||
|
from_domain_id: fromDomainId ? String(fromDomainId) : '',
|
||||||
|
to_domain_id: '',
|
||||||
|
})
|
||||||
|
setPreview(null)
|
||||||
|
}
|
||||||
|
}, [open, fromDomainId, form])
|
||||||
|
|
||||||
|
const domains = domainsQuery.data ?? []
|
||||||
|
const fromId = form.watch('from_domain_id')
|
||||||
|
const toId = form.watch('to_domain_id')
|
||||||
|
const fromZone = domains.find((d) => String(d.id) === fromId)?.zone_name
|
||||||
|
const toZone = domains.find((d) => String(d.id) === toId)?.zone_name
|
||||||
|
|
||||||
|
const mutate = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
changeServiceDomain(serviceId, {
|
||||||
|
from_domain_id: Number(fromId),
|
||||||
|
to_domain_id: Number(toId),
|
||||||
|
dry_run: false,
|
||||||
|
}),
|
||||||
|
onSuccess: async (result: { message?: string }) => {
|
||||||
|
toast.success(result.message ?? 'Привязки перенесены')
|
||||||
|
await queryClient.invalidateQueries()
|
||||||
|
setConfirmOpen(false)
|
||||||
|
onOpenChange(false)
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось перенести домен'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title="Сменить домен"
|
||||||
|
description="Перенос привязок между зонами Cloudflare без orphan-записей."
|
||||||
|
form={form}
|
||||||
|
onSubmit={() => {
|
||||||
|
setPreview(
|
||||||
|
fromZone && toZone
|
||||||
|
? `${fromZone} → ${toZone}`
|
||||||
|
: 'Проверьте выбранные зоны',
|
||||||
|
)
|
||||||
|
setConfirmOpen(true)
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!fromId || !toId || fromId === toId}>
|
||||||
|
Предпросмотр
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormFieldSimple label="Исходная зона" htmlFor="from_domain_id">
|
||||||
|
<Select
|
||||||
|
value={fromId || null}
|
||||||
|
onValueChange={(value) => form.setValue('from_domain_id', value ?? '')}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="from_domain_id">
|
||||||
|
<SelectValue placeholder="Откуда" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{domains.map((domain) => (
|
||||||
|
<SelectItem key={domain.id} value={String(domain.id)}>
|
||||||
|
{domain.zone_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormFieldSimple>
|
||||||
|
<FormFieldSimple label="Целевая зона" htmlFor="to_domain_id">
|
||||||
|
<Select
|
||||||
|
value={toId || null}
|
||||||
|
onValueChange={(value) => form.setValue('to_domain_id', value ?? '')}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="to_domain_id">
|
||||||
|
<SelectValue placeholder="Куда" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{domains.map((domain) => (
|
||||||
|
<SelectItem key={domain.id} value={String(domain.id)}>
|
||||||
|
{domain.zone_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormFieldSimple>
|
||||||
|
{fromZone && toZone ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Предпросмотр FQDN</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Привязки будут перенесены из {fromZone} в {toZone}. Старые DNS-записи
|
||||||
|
исходной зоны будут удалены.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</FormSheet>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmOpen}
|
||||||
|
onOpenChange={setConfirmOpen}
|
||||||
|
title="Подтвердить перенос"
|
||||||
|
description={preview ?? 'Перенести привязки в другую зону?'}
|
||||||
|
confirmLabel="Перенести"
|
||||||
|
onConfirm={() => mutate.mutate()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@cfdm/ui/components/select'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
|
import { changeBindingIp, serviceNodesQueryOptions } from '@/queries'
|
||||||
|
|
||||||
|
interface ChangeIpSheetProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
bindingId: number | null
|
||||||
|
serviceId?: number | null
|
||||||
|
currentIp?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
from_ip: string
|
||||||
|
to_ip: string
|
||||||
|
node_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChangeIpSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
bindingId,
|
||||||
|
serviceId,
|
||||||
|
currentIp,
|
||||||
|
}: ChangeIpSheetProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: { from_ip: currentIp ?? '', to_ip: '', node_id: '' },
|
||||||
|
})
|
||||||
|
const [preview, setPreview] = useState<string | null>(null)
|
||||||
|
const nodesQuery = useQuery({
|
||||||
|
...serviceNodesQueryOptions(serviceId ?? 0),
|
||||||
|
enabled: open && serviceId != null,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.reset({ from_ip: currentIp ?? '', to_ip: '', node_id: '' })
|
||||||
|
setPreview(null)
|
||||||
|
}
|
||||||
|
}, [open, currentIp, form])
|
||||||
|
|
||||||
|
const fromIp = form.watch('from_ip')
|
||||||
|
const toIp = form.watch('to_ip')
|
||||||
|
const nodeId = form.watch('node_id')
|
||||||
|
const nodes = (nodesQuery.data ?? []) as Array<{ id: number; address: string }>
|
||||||
|
|
||||||
|
const previewText = useMemo(() => {
|
||||||
|
const next = nodeId
|
||||||
|
? nodes.find((n) => String(n.id) === nodeId)?.address
|
||||||
|
: toIp
|
||||||
|
if (!fromIp || !next) return null
|
||||||
|
return `${fromIp} → ${next}`
|
||||||
|
}, [fromIp, toIp, nodeId, nodes])
|
||||||
|
|
||||||
|
const mutate = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (bindingId == null) throw new Error('нет привязки')
|
||||||
|
const selectedNode = nodeId ? Number(nodeId) : undefined
|
||||||
|
return changeBindingIp(bindingId, {
|
||||||
|
from_ip: fromIp || undefined,
|
||||||
|
to_ip: selectedNode ? undefined : toIp || undefined,
|
||||||
|
node_id: selectedNode,
|
||||||
|
dry_run: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
setPreview(result.message)
|
||||||
|
toast.success(result.message)
|
||||||
|
await queryClient.invalidateQueries()
|
||||||
|
onOpenChange(false)
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось сменить IP'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title="Сменить IP"
|
||||||
|
description="Обновить A-запись в Cloudflare без перехода на страницу DNS."
|
||||||
|
form={form}
|
||||||
|
onSubmit={() => mutate.mutate()}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<LoadingButton
|
||||||
|
type="submit"
|
||||||
|
isLoading={mutate.isPending}
|
||||||
|
loadingLabel="Updating…"
|
||||||
|
>
|
||||||
|
Сменить IP
|
||||||
|
</LoadingButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FormFieldSimple label="Текущий IP" htmlFor="from_ip">
|
||||||
|
<Input id="from_ip" {...form.register('from_ip')} />
|
||||||
|
</FormFieldSimple>
|
||||||
|
{nodes.length > 0 ? (
|
||||||
|
<FormFieldSimple label="Нода" htmlFor="node_id">
|
||||||
|
<Select
|
||||||
|
value={nodeId || null}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
form.setValue('node_id', value ?? '')
|
||||||
|
const node = nodes.find((n) => String(n.id) === value)
|
||||||
|
if (node) form.setValue('to_ip', node.address)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="node_id">
|
||||||
|
<SelectValue placeholder="Выберите ноду" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<SelectItem key={node.id} value={String(node.id)}>
|
||||||
|
{node.address}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormFieldSimple>
|
||||||
|
) : null}
|
||||||
|
<FormFieldSimple label="Новый IP" htmlFor="to_ip" hint="IPv4">
|
||||||
|
<Input id="to_ip" {...form.register('to_ip')} placeholder="10.0.0.20" />
|
||||||
|
</FormFieldSimple>
|
||||||
|
{previewText ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Предпросмотр</AlertTitle>
|
||||||
|
<AlertDescription>{preview ?? previewText}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</FormSheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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: 'Статус',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { InboxIcon, type LucideIcon } from 'lucide-react'
|
import { InboxIcon, type LucideIcon } from 'lucide-react'
|
||||||
import { IconStack } from '@/components/reui/icon-stack'
|
import { IconStack } from '@/components/reui/icon-stack'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
EmptyContent,
|
EmptyContent,
|
||||||
@@ -46,20 +47,29 @@ export function EmptyState({
|
|||||||
!centered && className,
|
!centered && className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<EmptyHeader className="gap-5 text-center">
|
<EmptyHeader className={cn('text-center', stackedIcon ? 'gap-5' : 'gap-3')}>
|
||||||
<EmptyMedia className="mb-0">
|
<EmptyMedia className="mb-0">
|
||||||
{stackedIcon ? (
|
{stackedIcon ? (
|
||||||
<IconStack aria-hidden="true" className="h-14 w-12">
|
<IconStack aria-hidden="true" className="h-14 w-12">
|
||||||
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
|
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
|
||||||
</IconStack>
|
</IconStack>
|
||||||
) : (
|
) : (
|
||||||
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
|
<IconTile
|
||||||
<Icon aria-hidden="true" />
|
variant="elevated"
|
||||||
</span>
|
className="size-10.5 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
</IconTile>
|
||||||
)}
|
)}
|
||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<EmptyTitle className="text-base font-semibold tracking-tight">
|
<EmptyTitle
|
||||||
|
className={cn(
|
||||||
|
'font-semibold tracking-tight',
|
||||||
|
stackedIcon ? 'text-base' : 'text-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</EmptyTitle>
|
</EmptyTitle>
|
||||||
{description ? (
|
{description ? (
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { ShieldCheckIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Timeline,
|
||||||
|
TimelineContent,
|
||||||
|
TimelineDate,
|
||||||
|
TimelineHeader,
|
||||||
|
TimelineIndicator,
|
||||||
|
TimelineItem,
|
||||||
|
TimelineSeparator,
|
||||||
|
TimelineTitle,
|
||||||
|
} from '@/components/reui/timeline'
|
||||||
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { formatDate, formatRelative, sqliteUtcToIso } from '@/lib/format'
|
||||||
|
import {
|
||||||
|
failoverEventCopy,
|
||||||
|
type FailoverEvent,
|
||||||
|
type FailoverHistoryItem,
|
||||||
|
} from '@/lib/failover-events'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
import type { ComponentProps } from 'react'
|
||||||
|
|
||||||
|
type HealthBadgeStatus = ComponentProps<typeof HealthCheckBadge>['status']
|
||||||
|
|
||||||
|
function failStreakLabel(count: number): string {
|
||||||
|
const mod10 = count % 10
|
||||||
|
const mod100 = count % 100
|
||||||
|
if (mod10 === 1 && mod100 !== 11) return `${count} ошибка подряд`
|
||||||
|
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
|
||||||
|
return `${count} ошибки подряд`
|
||||||
|
}
|
||||||
|
return `${count} ошибок подряд`
|
||||||
|
}
|
||||||
|
|
||||||
|
function indicatorClass(tone: 'down' | 'added' | 'removed'): string {
|
||||||
|
if (tone === 'added') {
|
||||||
|
return 'border-success bg-success/15 group-data-completed/timeline-item:border-success'
|
||||||
|
}
|
||||||
|
return 'border-destructive bg-destructive/15 group-data-completed/timeline-item:border-destructive'
|
||||||
|
}
|
||||||
|
|
||||||
|
function separatorClass(tone: 'down' | 'added' | 'removed'): string {
|
||||||
|
if (tone === 'added') return 'bg-success/25'
|
||||||
|
return 'bg-destructive/25'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Failover как sibling «Смены статуса»: ReUI Timeline + Badge.
|
||||||
|
* Preview: https://reui.io/preview/base/components/c-timeline-10
|
||||||
|
* Preview: https://reui.io/preview/base/empty-state-12
|
||||||
|
* Docs: https://reui.io/docs/components/base/timeline
|
||||||
|
* Docs: https://reui.io/docs/components/base/badge
|
||||||
|
*/
|
||||||
|
export function FailoverTimeline({
|
||||||
|
events,
|
||||||
|
history = [],
|
||||||
|
}: {
|
||||||
|
events: FailoverEvent[]
|
||||||
|
history?: readonly FailoverHistoryItem[]
|
||||||
|
}) {
|
||||||
|
if (events.length === 0 && history.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={ShieldCheckIcon}
|
||||||
|
title="Нет инцидентов Failover"
|
||||||
|
description="Нет Down и нет выходов из пула"
|
||||||
|
stackedIcon={false}
|
||||||
|
centered={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{events.length > 0 ? (
|
||||||
|
<Timeline defaultValue={0} className="gap-0">
|
||||||
|
{events.map((event, index) => {
|
||||||
|
const checkedIso = event.lastCheckAt
|
||||||
|
? (sqliteUtcToIso(event.lastCheckAt) ?? event.lastCheckAt)
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TimelineItem key={event.id} step={index + 1}>
|
||||||
|
<TimelineSeparator className={separatorClass('down')} />
|
||||||
|
<TimelineIndicator className={indicatorClass('down')} />
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineTitle className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-mono text-sm">{event.address}</span>
|
||||||
|
<HealthCheckBadge
|
||||||
|
status={event.status as HealthBadgeStatus}
|
||||||
|
lastError={event.lastFailureReason}
|
||||||
|
lastCheckedAt={checkedIso}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</TimelineTitle>
|
||||||
|
<TimelineDate>
|
||||||
|
{event.consecutiveFailures > 0
|
||||||
|
? failStreakLabel(event.consecutiveFailures)
|
||||||
|
: null}
|
||||||
|
{checkedIso
|
||||||
|
? `${event.consecutiveFailures > 0 ? ' · ' : ''}${formatRelative(checkedIso)} · ${formatDate(checkedIso)}`
|
||||||
|
: null}
|
||||||
|
</TimelineDate>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineContent className="flex flex-col gap-2">
|
||||||
|
<p className="text-foreground text-sm">
|
||||||
|
{failoverEventCopy(event)}
|
||||||
|
</p>
|
||||||
|
{event.lastFailureReason ? (
|
||||||
|
<code
|
||||||
|
className={cn(
|
||||||
|
'bg-muted block overflow-x-auto rounded-md px-2 py-1.5 font-mono text-xs',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{event.lastFailureReason}
|
||||||
|
</code>
|
||||||
|
) : null}
|
||||||
|
</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Timeline>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{history.length > 0 ? (
|
||||||
|
<Timeline defaultValue={0} className="gap-0">
|
||||||
|
{history.map((item, index) => {
|
||||||
|
const checkedIso = sqliteUtcToIso(item.created_at) ?? item.created_at
|
||||||
|
const tone = item.action === 'added' ? 'added' : 'removed'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TimelineItem key={item.id} step={index + 1}>
|
||||||
|
<TimelineSeparator className={separatorClass(tone)} />
|
||||||
|
<TimelineIndicator className={indicatorClass(tone)} />
|
||||||
|
<TimelineHeader>
|
||||||
|
<TimelineTitle className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-mono text-sm">{item.ip}</span>
|
||||||
|
<HealthCheckBadge
|
||||||
|
status={item.action === 'added' ? 'up' : 'down'}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{item.fqdn}
|
||||||
|
</span>
|
||||||
|
</TimelineTitle>
|
||||||
|
<TimelineDate>
|
||||||
|
{formatRelative(checkedIso)} · {formatDate(checkedIso)}
|
||||||
|
</TimelineDate>
|
||||||
|
</TimelineHeader>
|
||||||
|
<TimelineContent>
|
||||||
|
<p className="text-foreground text-sm">{item.copy}</p>
|
||||||
|
</TimelineContent>
|
||||||
|
</TimelineItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Timeline>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,20 @@ import { formatDate } from '@/lib/format'
|
|||||||
|
|
||||||
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
type BadgeVariant = NonNullable<ComponentProps<typeof Badge>['variant']>
|
||||||
|
|
||||||
|
type HealthStatus =
|
||||||
|
| IpHealthStatus['status']
|
||||||
|
| 'healthy'
|
||||||
|
| 'unhealthy'
|
||||||
|
| 'checking'
|
||||||
|
| 'disabled'
|
||||||
|
|
||||||
|
function normalizeHealth(status: HealthStatus): IpHealthStatus['status'] {
|
||||||
|
if (status === 'healthy') return 'up'
|
||||||
|
if (status === 'unhealthy' || status === 'disabled') return 'down'
|
||||||
|
if (status === 'checking') return 'unknown'
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
const healthVariants: Record<IpHealthStatus['status'], BadgeVariant> = {
|
const healthVariants: Record<IpHealthStatus['status'], BadgeVariant> = {
|
||||||
up: 'success-light',
|
up: 'success-light',
|
||||||
degraded: 'warning-light',
|
degraded: 'warning-light',
|
||||||
@@ -21,6 +35,13 @@ const healthLabels: Record<IpHealthStatus['status'], string> = {
|
|||||||
unknown: '—',
|
unknown: '—',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extraLabels: Partial<Record<HealthStatus, string>> = {
|
||||||
|
healthy: 'Healthy',
|
||||||
|
unhealthy: 'Unhealthy',
|
||||||
|
checking: 'Checking',
|
||||||
|
disabled: 'Disabled',
|
||||||
|
}
|
||||||
|
|
||||||
const dotColor: Record<IpHealthStatus['status'], string> = {
|
const dotColor: Record<IpHealthStatus['status'], string> = {
|
||||||
up: 'bg-success',
|
up: 'bg-success',
|
||||||
degraded: 'bg-warning',
|
degraded: 'bg-warning',
|
||||||
@@ -29,10 +50,12 @@ const dotColor: Record<IpHealthStatus['status'], string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface HealthCheckBadgeProps {
|
interface HealthCheckBadgeProps {
|
||||||
status: IpHealthStatus['status']
|
status: HealthStatus
|
||||||
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'
|
||||||
@@ -44,19 +67,25 @@ export function HealthCheckBadge({
|
|||||||
latencyMs,
|
latencyMs,
|
||||||
lastCheckedAt,
|
lastCheckedAt,
|
||||||
lastError,
|
lastError,
|
||||||
|
colo,
|
||||||
|
provider,
|
||||||
title,
|
title,
|
||||||
showLatency = false,
|
showLatency = false,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
className,
|
className,
|
||||||
}: HealthCheckBadgeProps) {
|
}: HealthCheckBadgeProps) {
|
||||||
const variant = healthVariants[status]
|
const normalized = normalizeHealth(status)
|
||||||
const label = healthLabels[status]
|
const variant = healthVariants[normalized]
|
||||||
|
const label = extraLabels[status] ?? healthLabels[normalized]
|
||||||
|
|
||||||
const tooltipParts: string[] = []
|
const tooltipParts: string[] = []
|
||||||
if (title) tooltipParts.push(title)
|
if (title) tooltipParts.push(title)
|
||||||
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 (
|
||||||
@@ -74,7 +103,7 @@ export function HealthCheckBadge({
|
|||||||
className={cn('gap-1.5', className)}
|
className={cn('gap-1.5', className)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn('size-1.5 shrink-0 rounded-full', dotColor[status])}
|
className={cn('size-1.5 shrink-0 rounded-full', dotColor[normalized])}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
{label}
|
{label}
|
||||||
|
|||||||
@@ -18,10 +18,21 @@ import {
|
|||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { ButtonGroup } from '@cfdm/ui/components/button-group'
|
||||||
|
import { CableIcon, GlobeIcon } from 'lucide-react'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { 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, HealthAggregate }
|
||||||
|
|
||||||
export interface HealthCheckConfig {
|
export interface HealthCheckConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -32,6 +43,13 @@ export interface HealthCheckConfig {
|
|||||||
interval_sec: number
|
interval_sec: number
|
||||||
timeout_ms: number
|
timeout_ms: number
|
||||||
verify_tls: boolean
|
verify_tls: boolean
|
||||||
|
provider: HealthProvider
|
||||||
|
providers: HealthProvider[]
|
||||||
|
aggregate: HealthAggregate
|
||||||
|
method?: string | null
|
||||||
|
retries?: number
|
||||||
|
consecutive_fails?: number
|
||||||
|
consecutive_successes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LbAndHealthConfig extends HealthCheckConfig {
|
export interface LbAndHealthConfig extends HealthCheckConfig {
|
||||||
@@ -44,21 +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
|
|
||||||
|
|
||||||
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,
|
||||||
@@ -100,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'
|
||||||
|
|
||||||
@@ -119,6 +135,7 @@ export function HealthCheckConfigFields({
|
|||||||
className={rowClass}
|
className={rowClass}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
|
modal={false}
|
||||||
value={value.lb_mode}
|
value={value.lb_mode}
|
||||||
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
|
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
|
||||||
>
|
>
|
||||||
@@ -136,6 +153,43 @@ export function HealthCheckConfigFields({
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Провайдер health-check"
|
||||||
|
description="Кто пробирует цель. Можно выбрать несколько источников."
|
||||||
|
labelFor={`${idPrefix}-provider`}
|
||||||
|
compact
|
||||||
|
stacked
|
||||||
|
className={rowClass}
|
||||||
|
contentClassName="min-w-0"
|
||||||
|
>
|
||||||
|
<HealthSourceTiles
|
||||||
|
value={providers}
|
||||||
|
onChange={(next) =>
|
||||||
|
patch({
|
||||||
|
providers: next,
|
||||||
|
provider: next[0] ?? 'local',
|
||||||
|
enabled: next.includes('cloudflare') ? true : value.enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{providers.length > 1 ? (
|
||||||
|
<SettingRow
|
||||||
|
title="Агрегация"
|
||||||
|
description="Как свести результаты источников в один статус IP для failover"
|
||||||
|
compact
|
||||||
|
stacked
|
||||||
|
className={rowClass}
|
||||||
|
contentClassName="min-w-0"
|
||||||
|
>
|
||||||
|
<HealthAggregateTiles
|
||||||
|
value={aggregate}
|
||||||
|
onChange={(next) => patch({ aggregate: next })}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Health-check"
|
title="Health-check"
|
||||||
description="TCP/HTTP проверка цели DNS"
|
description="TCP/HTTP проверка цели DNS"
|
||||||
@@ -163,21 +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">
|
||||||
value={value.type}
|
<Button
|
||||||
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })}
|
type="button"
|
||||||
>
|
size="sm"
|
||||||
<SelectTrigger id={`${idPrefix}-type`} className="w-full">
|
variant={value.type === 'tcp' ? 'secondary' : 'outline'}
|
||||||
<SelectValue placeholder="Тип" />
|
className="flex-1"
|
||||||
</SelectTrigger>
|
aria-pressed={value.type === 'tcp'}
|
||||||
<SelectContent>
|
onClick={() => patch({ type: 'tcp' })}
|
||||||
{healthCheckTypes.map((item) => (
|
>
|
||||||
<SelectItem key={item.value} value={item.value}>
|
<CableIcon data-icon="inline-start" />
|
||||||
{item.label}
|
TCP
|
||||||
</SelectItem>
|
</Button>
|
||||||
))}
|
<Button
|
||||||
</SelectContent>
|
type="button"
|
||||||
</Select>
|
size="sm"
|
||||||
|
variant={value.type === 'http' ? 'secondary' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
aria-pressed={value.type === 'http'}
|
||||||
|
onClick={() => patch({ type: 'http' })}
|
||||||
|
>
|
||||||
|
<GlobeIcon data-icon="inline-start" />
|
||||||
|
HTTP
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
</FormFieldSimple>
|
</FormFieldSimple>
|
||||||
|
|
||||||
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
|
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
|
||||||
@@ -239,32 +302,18 @@ export function HealthCheckConfigFields({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
|
||||||
<FormFieldSimple label="Интервал, сек" htmlFor={`${idPrefix}-interval`}>
|
<CompactNumberField
|
||||||
<CompactNumberField
|
id={`${idPrefix}-timeout`}
|
||||||
id={`${idPrefix}-interval`}
|
value={value.timeout_ms}
|
||||||
value={value.interval_sec}
|
min={100}
|
||||||
min={5}
|
max={30000}
|
||||||
max={3600}
|
placeholder="3000"
|
||||||
placeholder="30"
|
onValueChange={(next) =>
|
||||||
onValueChange={(next) =>
|
patch({ timeout_ms: next ?? 3000 })
|
||||||
patch({ interval_sec: next ?? 30 })
|
}
|
||||||
}
|
/>
|
||||||
/>
|
</FormFieldSimple>
|
||||||
</FormFieldSimple>
|
|
||||||
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
|
|
||||||
<CompactNumberField
|
|
||||||
id={`${idPrefix}-timeout`}
|
|
||||||
value={value.timeout_ms}
|
|
||||||
min={100}
|
|
||||||
max={30000}
|
|
||||||
placeholder="3000"
|
|
||||||
onValueChange={(next) =>
|
|
||||||
patch({ timeout_ms: next ?? 3000 })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormFieldSimple>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
|||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
|
import { ServiceFqdnList, ServiceIpList } from '@/components/services/service-fqdn-list'
|
||||||
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 {
|
||||||
@@ -78,7 +78,22 @@ export function ServiceKanbanCard({
|
|||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
|
|
||||||
<ItemContent className="min-w-0 gap-2">
|
<ItemContent className="min-w-0 gap-2">
|
||||||
<ServiceFqdnList service={service} />
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="text-muted-foreground text-xs">Общий домен</span>
|
||||||
|
<ServiceFqdnList
|
||||||
|
copyable
|
||||||
|
service={service}
|
||||||
|
emptyLabel="Не задан"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="text-muted-foreground text-xs">IP</span>
|
||||||
|
<ServiceIpList
|
||||||
|
copyable
|
||||||
|
ips={service.ips ?? []}
|
||||||
|
ipHealth={service.ip_health ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
||||||
<ItemFooter className="min-w-0 justify-between gap-2">
|
<ItemFooter className="min-w-0 justify-between gap-2">
|
||||||
|
|||||||
@@ -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,95 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sibling Frame columns for dashboard attention queue.
|
||||||
|
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
|
||||||
|
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
||||||
|
*/
|
||||||
|
export interface AttentionQueueColumn {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
icon: LucideIcon
|
||||||
|
iconClassName?: string
|
||||||
|
count: number
|
||||||
|
countVariant?: 'destructive' | 'warning' | 'secondary' | 'destructive-light' | 'warning-light'
|
||||||
|
emptyTitle: string
|
||||||
|
emptyDescription: string
|
||||||
|
emptyAction?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttentionQueueProps {
|
||||||
|
columns: AttentionQueueColumn[]
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||||
|
|
||||||
|
export function AttentionQueue({ columns, className }: AttentionQueueProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid min-w-0 items-start gap-2 @3xl:grid-cols-3',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{columns.map((column) => {
|
||||||
|
const Icon = column.icon
|
||||||
|
const isEmpty = column.count === 0
|
||||||
|
return (
|
||||||
|
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="sm"
|
||||||
|
className={cn(DEFAULT_ICON_CLASS, column.iconClassName)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
</IconTile>
|
||||||
|
<FrameTitle className="min-w-0 truncate">{column.title}</FrameTitle>
|
||||||
|
{column.count > 0 ? (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant={column.countVariant ?? 'secondary'}
|
||||||
|
className="tabular-nums"
|
||||||
|
>
|
||||||
|
{column.count}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="min-w-0">
|
||||||
|
{isEmpty ? (
|
||||||
|
<div className="flex min-h-28 items-center justify-center py-4">
|
||||||
|
<EmptyState
|
||||||
|
icon={Icon}
|
||||||
|
title={column.emptyTitle}
|
||||||
|
description={column.emptyDescription}
|
||||||
|
action={column.emptyAction}
|
||||||
|
centered={false}
|
||||||
|
stackedIcon={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
column.children
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,6 @@
|
|||||||
|
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
|
||||||
|
export { ServiceHealthMonitor } from './service-health-monitor'
|
||||||
|
export { ServiceFailoverPanel } from './service-failover-panel'
|
||||||
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'
|
||||||
@@ -13,7 +16,17 @@ export {
|
|||||||
type OpsKpiCard,
|
type OpsKpiCard,
|
||||||
} from './kpi-stat-grid'
|
} from './kpi-stat-grid'
|
||||||
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||||
|
export { AttentionQueue, type AttentionQueueColumn } from './attention-queue'
|
||||||
export { OpsDashboard } from './ops-dashboard'
|
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',
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
|
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
|
||||||
|
|
||||||
@@ -17,10 +10,18 @@ interface OpsDashboardProps {
|
|||||||
afterKpi?: ReactNode
|
afterKpi?: ReactNode
|
||||||
charts: ReactNode
|
charts: ReactNode
|
||||||
queue: ReactNode
|
queue: ReactNode
|
||||||
|
queueTitle?: string
|
||||||
|
queueDescription?: string
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Denser stack for KPI / charts / queue. PageHeader lives outside via PageShell. */
|
/**
|
||||||
|
* Ops dashboard: KPI → optional Quick Actions → charts → queue.
|
||||||
|
* Queue is a sibling Frame grid — never wrap Frames inside another Frame.
|
||||||
|
* @see https://reui.io/preview/base/dashboard-1
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
const rootClassName =
|
const rootClassName =
|
||||||
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
|
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
|
||||||
|
|
||||||
@@ -32,7 +33,11 @@ function OpsDashboardSkeleton() {
|
|||||||
<Skeleton className="h-64 w-full rounded-xl" />
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
<Skeleton className="h-64 w-full rounded-xl" />
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-48 w-full rounded-xl" />
|
<div className="grid gap-2 @3xl:grid-cols-3">
|
||||||
|
<Skeleton className="h-40 w-full rounded-xl" />
|
||||||
|
<Skeleton className="h-40 w-full rounded-xl" />
|
||||||
|
<Skeleton className="h-40 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -42,6 +47,8 @@ export function OpsDashboard({
|
|||||||
afterKpi,
|
afterKpi,
|
||||||
charts,
|
charts,
|
||||||
queue,
|
queue,
|
||||||
|
queueTitle = 'Требуют внимания',
|
||||||
|
queueDescription = 'Проблемы health-check, истекающие сертификаты и домены без группы',
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: OpsDashboardProps) {
|
}: OpsDashboardProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -63,16 +70,16 @@ export function OpsDashboard({
|
|||||||
{charts}
|
{charts}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section aria-label="Требуют внимания">
|
<section aria-label={queueTitle} className="flex min-w-0 flex-col gap-2">
|
||||||
<Frame dense spacing="sm" className="w-full">
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<FrameHeader>
|
<h2 className="text-sm font-semibold tracking-tight">{queueTitle}</h2>
|
||||||
<FrameTitle>Требуют внимания</FrameTitle>
|
{queueDescription ? (
|
||||||
<FrameDescription>
|
<p className="text-muted-foreground max-w-prose text-sm">
|
||||||
Проблемы health-check, истекающие сертификаты и домены без группы
|
{queueDescription}
|
||||||
</FrameDescription>
|
</p>
|
||||||
</FrameHeader>
|
) : null}
|
||||||
<FramePanel>{queue}</FramePanel>
|
</div>
|
||||||
</Frame>
|
{queue}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { useState, type KeyboardEvent, type ReactNode } from 'react'
|
||||||
|
import { ServerIcon, Trash2Icon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
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,
|
||||||
|
addCommonFqdn,
|
||||||
|
addressHasFqdn,
|
||||||
|
removeAddressNode,
|
||||||
|
removeCommonFqdn,
|
||||||
|
updateCommonFqdn,
|
||||||
|
type AddressBlockState,
|
||||||
|
} from '@/lib/service-address'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Field, FieldLabel } from '@cfdm/ui/components/field'
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupInput,
|
||||||
|
} from '@cfdm/ui/components/input-group'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemGroup,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@cfdm/ui/components/item'
|
||||||
|
|
||||||
|
function ZoneAddon({
|
||||||
|
fqdn,
|
||||||
|
zoneHints,
|
||||||
|
trailing,
|
||||||
|
}: {
|
||||||
|
fqdn: string
|
||||||
|
zoneHints: string[]
|
||||||
|
trailing?: ReactNode
|
||||||
|
}) {
|
||||||
|
const parsed = parseFqdn(fqdn, zoneHints)
|
||||||
|
if (!parsed && !fqdn.trim() && !trailing) return null
|
||||||
|
return (
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
{parsed ? (
|
||||||
|
<Badge variant="outline" size="xs" className="font-mono">
|
||||||
|
{parsed.zoneName}
|
||||||
|
</Badge>
|
||||||
|
) : fqdn.trim() ? (
|
||||||
|
<Badge variant="warning-light" size="xs">
|
||||||
|
зона не найдена
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{trailing}
|
||||||
|
</InputGroupAddon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Единый блок адресов сервиса: список общих 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 [pendingFqdn, setPendingFqdn] = useState('')
|
||||||
|
const [fqdnInvalid, setFqdnInvalid] = useState(false)
|
||||||
|
|
||||||
|
const pool = value.nodes.map((node) => node.ip)
|
||||||
|
const pendingIpTrimmed = pendingIp.trim()
|
||||||
|
const pendingFqdnTrimmed = pendingFqdn.trim()
|
||||||
|
const pendingIpInvalid =
|
||||||
|
ipInvalid && pendingIpTrimmed.length > 0 && !isValidIpv4(pendingIpTrimmed)
|
||||||
|
const pendingFqdnInvalid =
|
||||||
|
fqdnInvalid && pendingFqdnTrimmed.length > 0
|
||||||
|
|
||||||
|
function tryAddFqdn(raw: string) {
|
||||||
|
const trimmed = raw.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setFqdnInvalid(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (addressHasFqdn(value, trimmed)) {
|
||||||
|
setFqdnInvalid(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onChange(addCommonFqdn(value, trimmed))
|
||||||
|
setPendingFqdn('')
|
||||||
|
setFqdnInvalid(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 handleFqdnKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
tryAddFqdn(pendingFqdn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIpKeyDown(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,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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-add">Общие домены (FQDN)</FieldLabel>
|
||||||
|
<div className="flex w-full flex-col gap-2">
|
||||||
|
{value.commonFqdns.map((fqdn, index) => (
|
||||||
|
<InputGroup key={`common-fqdn-${index}`}>
|
||||||
|
<InputGroupInput
|
||||||
|
id={`service-common-fqdn-${index}`}
|
||||||
|
className="font-mono"
|
||||||
|
value={fqdn}
|
||||||
|
placeholder={zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange(updateCommonFqdn(value, index, event.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ZoneAddon
|
||||||
|
fqdn={fqdn}
|
||||||
|
zoneHints={zoneHints}
|
||||||
|
trailing={
|
||||||
|
<InputGroupButton
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label={`Удалить ${fqdn || 'FQDN'}`}
|
||||||
|
onClick={() => onChange(removeCommonFqdn(value, index))}
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
</InputGroupButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
))}
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
|
id="service-common-fqdn-add"
|
||||||
|
className="font-mono"
|
||||||
|
value={pendingFqdn}
|
||||||
|
placeholder={zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'}
|
||||||
|
aria-invalid={pendingFqdnInvalid || undefined}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPendingFqdn(event.target.value)
|
||||||
|
setFqdnInvalid(false)
|
||||||
|
}}
|
||||||
|
onKeyDown={handleFqdnKeyDown}
|
||||||
|
onBlur={() => tryAddFqdn(pendingFqdn)}
|
||||||
|
/>
|
||||||
|
<ZoneAddon
|
||||||
|
fqdn={pendingFqdn}
|
||||||
|
zoneHints={zoneHints}
|
||||||
|
trailing={
|
||||||
|
<InputGroupButton size="sm" onClick={() => tryAddFqdn(pendingFqdn)}>
|
||||||
|
Добавить
|
||||||
|
</InputGroupButton>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</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) => {
|
||||||
|
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={() => onChange(removeAddressNode(value, 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)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ZoneAddon fqdn={node.extraFqdn} zoneHints={zoneHints} />
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ItemGroup>
|
||||||
|
)}
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupInput
|
||||||
|
id="service-pool-ip-add"
|
||||||
|
className="font-mono"
|
||||||
|
value={pendingIp}
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
aria-invalid={pendingIpInvalid || undefined}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPendingIp(event.target.value)
|
||||||
|
setIpInvalid(false)
|
||||||
|
}}
|
||||||
|
onKeyDown={handleIpKeyDown}
|
||||||
|
onBlur={() => tryAddIp(pendingIp)}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon align="inline-end">
|
||||||
|
<InputGroupButton size="sm" onClick={() => tryAddIp(pendingIp)}>
|
||||||
|
Добавить
|
||||||
|
</InputGroupButton>
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { UnplugIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { FailoverTimeline } from '@/components/failover-timeline'
|
||||||
|
import {
|
||||||
|
mergeFailoverHistory,
|
||||||
|
toFailoverEvents,
|
||||||
|
type FailoverBindingPool,
|
||||||
|
type FailoverHealthInput,
|
||||||
|
} from '@/lib/failover-events'
|
||||||
|
import {
|
||||||
|
latestHealthByIp,
|
||||||
|
resolveIpDisplayHealth,
|
||||||
|
type HealthLogProbe,
|
||||||
|
type HealthLogStatus,
|
||||||
|
} from '@/lib/health-log'
|
||||||
|
import type { FailoverLogEntry } from '@/lib/schemas'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from '@/components/reui/alert'
|
||||||
|
|
||||||
|
function failoverCountLabel(count: number): string {
|
||||||
|
const mod10 = count % 10
|
||||||
|
const mod100 = count % 100
|
||||||
|
if (mod10 === 1 && mod100 !== 11) return `${count} адрес Down`
|
||||||
|
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
|
||||||
|
return `${count} адреса Down`
|
||||||
|
}
|
||||||
|
return `${count} адресов Down`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Failover — текущие Down + кто вышел из пула и кто вернулся.
|
||||||
|
* Preview: https://reui.io/preview/base/components/c-timeline-10
|
||||||
|
* Preview: https://reui.io/preview/base/empty-state-12
|
||||||
|
* Docs: https://reui.io/docs/components/base/frame
|
||||||
|
* Docs: https://reui.io/docs/components/base/timeline
|
||||||
|
* Docs: https://reui.io/docs/components/base/badge
|
||||||
|
* Docs: https://reui.io/docs/components/base/alert
|
||||||
|
*/
|
||||||
|
export function ServiceFailoverPanel({
|
||||||
|
ipHealth,
|
||||||
|
bindings,
|
||||||
|
history,
|
||||||
|
probes = [],
|
||||||
|
}: {
|
||||||
|
ipHealth: readonly FailoverHealthInput[]
|
||||||
|
bindings: readonly FailoverBindingPool[]
|
||||||
|
history: readonly FailoverLogEntry[]
|
||||||
|
probes?: readonly HealthLogProbe[]
|
||||||
|
}) {
|
||||||
|
const liveByIp = latestHealthByIp(probes)
|
||||||
|
const overlayHealth = ipHealth.map((row) => {
|
||||||
|
const live = liveByIp.get(row.ip)
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
status: resolveIpDisplayHealth(
|
||||||
|
row.status as HealthLogStatus,
|
||||||
|
live?.status,
|
||||||
|
),
|
||||||
|
last_error:
|
||||||
|
live && live.status !== 'unknown' ? live.last_error : row.last_error,
|
||||||
|
last_checked_at:
|
||||||
|
live && live.status !== 'unknown'
|
||||||
|
? live.last_checked_at
|
||||||
|
: row.last_checked_at,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const events = toFailoverEvents(overlayHealth, bindings)
|
||||||
|
const mergedHistory = mergeFailoverHistory(history, probes, bindings)
|
||||||
|
const removedCount = events.filter((event) => event.kind === 'removed').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
||||||
|
<FramePanel className="flex flex-col gap-3">
|
||||||
|
<FrameHeader className="gap-1 px-0 py-0">
|
||||||
|
<FrameTitle className="flex flex-wrap items-center gap-2">
|
||||||
|
Failover
|
||||||
|
{events.length > 0 ? (
|
||||||
|
<Badge variant="destructive-light" size="xs" radius="full">
|
||||||
|
{events.length}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="success-light" size="xs" radius="full">
|
||||||
|
OK
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Текущие Down и история: кто вышел из пула и кто вернулся
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
|
||||||
|
{events.length > 0 ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<UnplugIcon aria-hidden="true" />
|
||||||
|
<AlertTitle>{failoverCountLabel(events.length)}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{removedCount > 0
|
||||||
|
? 'Сняты с FQDN или остались last-resort'
|
||||||
|
: 'Остались в A-записях как last-resort'}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FailoverTimeline events={events} history={mergedHistory} />
|
||||||
|
</FramePanel>
|
||||||
|
</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,97 @@
|
|||||||
|
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('keeps live latency when another IP in the same bucket is down', () => {
|
||||||
|
const { points } = toAlignedSeries([
|
||||||
|
probe({
|
||||||
|
id: 1,
|
||||||
|
latency_ms: 18,
|
||||||
|
checked_at: '2026-01-01T00:00:10.000Z',
|
||||||
|
}),
|
||||||
|
probe({
|
||||||
|
id: 2,
|
||||||
|
status: 'down',
|
||||||
|
ok: false,
|
||||||
|
latency_ms: null,
|
||||||
|
checked_at: '2026-01-01T00:00:12.000Z',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(points).toHaveLength(1)
|
||||||
|
expect(points[0]?.local).toBe(18)
|
||||||
|
expect(points[0]?.localOk).toBe(true)
|
||||||
|
expect(points[0]?.ok).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user