Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa07f08684 | ||
|
|
2418344551 | ||
|
|
73f5380768 | ||
|
|
0ef57c673c | ||
|
|
b078fa0a03 | ||
|
|
75575a3243 | ||
|
|
5e2c301442 | ||
|
|
56cddedefe | ||
|
|
df5d5ef4ab | ||
|
|
de3dbe8521 | ||
|
|
7eb195c5d7 | ||
|
|
7f06466058 | ||
|
|
0d567379fa | ||
|
|
a228febc27 | ||
|
|
5cf39880f8 | ||
|
|
f1443f1db5 | ||
|
|
a9a84fabac | ||
|
|
2b150fa3a6 | ||
|
|
3d0ea33baf | ||
|
|
8a13888db7 | ||
|
|
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 |
No files matched your search
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -6,18 +6,18 @@ alwaysApply: false
|
||||
|
||||
---
|
||||
name: reui
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
|
||||
user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
|
||||
|
||||
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
|
||||
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
|
||||
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
|
||||
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
|
||||
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
|
||||
|
||||
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
|
||||
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
|
||||
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix
|
||||
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
|
||||
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
|
||||
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
|
||||
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -38,14 +38,11 @@ jobs:
|
||||
api: ${{ steps.detect.outputs.api }}
|
||||
docker: ${{ steps.detect.outputs.docker }}
|
||||
steps:
|
||||
- if: ${{ inputs.is_pull_request }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
# Push may contain several commits; github.event.before is then
|
||||
# more than one parent away. fetch-depth: 2 only has HEAD~1.
|
||||
fetch-depth: 0
|
||||
- if: ${{ inputs.is_pull_request == false }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- id: detect
|
||||
name: Detect changed paths per module
|
||||
env:
|
||||
@@ -72,12 +69,16 @@ jobs:
|
||||
done
|
||||
}
|
||||
|
||||
has_commit() {
|
||||
git cat-file -e "${1}^{commit}" 2>/dev/null
|
||||
}
|
||||
|
||||
if [ "$IS_PR" = "true" ]; then
|
||||
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
|
||||
else
|
||||
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||
before="$BEFORE_SHA"
|
||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ] && has_commit "$before"; then
|
||||
FILES="$(git diff --name-only "$before" "$after")"
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
|
||||
@@ -5,7 +5,7 @@ user-invocable: false
|
||||
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
|
||||
---
|
||||
|
||||
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
|
||||
|
||||
# ReUI for Agents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ReUI components
|
||||
|
||||
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
||||
|
||||
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
|
||||
|
||||
@@ -106,22 +106,60 @@ Common mistakes:
|
||||
|
||||
## filters
|
||||
|
||||
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
|
||||
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
const [filters, setFilters] = useState<Filter[]>([
|
||||
createFilter("priority", "is_any_of", ["low"]),
|
||||
])
|
||||
const fields: FilterFieldConfig[] = [
|
||||
{ key: "priority", label: "Priority", type: "multiselect",
|
||||
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
|
||||
const fields: FilterField[] = [
|
||||
{ id: "title", label: "Title", type: "text" },
|
||||
{
|
||||
id: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
options: [
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "archived", label: "Archived" },
|
||||
],
|
||||
},
|
||||
]
|
||||
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
|
||||
|
||||
<Filters filters={filters} fields={fields} onChange={setFilters} />
|
||||
<Filters fields={fields} query={query} onQueryChange={setQuery} />
|
||||
```
|
||||
|
||||
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
|
||||
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
|
||||
|
||||
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
|
||||
|
||||
## cascader
|
||||
|
||||
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
|
||||
**Shape:**
|
||||
|
||||
```tsx
|
||||
<Cascader items={items} value={value} onValueChange={setValue}>
|
||||
<CascaderTrigger render={<Button variant="outline" />}>
|
||||
<CascaderValue placeholder="Select an attribute" />
|
||||
</CascaderTrigger>
|
||||
<CascaderContent className="w-80">
|
||||
<CascaderPanel>
|
||||
<CascaderNav>
|
||||
<CascaderBreadcrumb />
|
||||
<CascaderInput />
|
||||
</CascaderNav>
|
||||
<CascaderEmpty />
|
||||
<CascaderList maxHeight={288}>
|
||||
<CascaderItems />
|
||||
</CascaderList>
|
||||
<CascaderStatus />
|
||||
</CascaderPanel>
|
||||
</CascaderContent>
|
||||
</Cascader>
|
||||
```
|
||||
|
||||
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
|
||||
|
||||
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
|
||||
|
||||
## date-selector
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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",
|
||||
"test": "vitest run"
|
||||
},
|
||||
|
||||
@@ -35,8 +35,14 @@ import { auditRoutes } from "./routes/audit.js";
|
||||
import * as certificateService from "./services/certificate-service.js";
|
||||
import {
|
||||
createHealthCheckTask,
|
||||
healthEngineFallbacksFromConfig,
|
||||
scheduleHealthCheckJob,
|
||||
} from "./services/health-check-scheduler.js";
|
||||
import {
|
||||
createWeightedDnsTask,
|
||||
scheduleWeightedDnsJob,
|
||||
} from "./services/weighted-dns-scheduler.js";
|
||||
import { fireEnsureHealthWorker } from "./services/health/health-worker-deploy.js";
|
||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||
|
||||
export interface BuildAppOptions {
|
||||
@@ -131,6 +137,15 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
app.decorate("reloadHealthCheckJob", () => {
|
||||
scheduleHealthCheckJob(app, config, healthTask);
|
||||
});
|
||||
scheduleWeightedDnsJob(app, createWeightedDnsTask(app));
|
||||
if (config.cloudflareApiToken) {
|
||||
fireEnsureHealthWorker(
|
||||
app.db,
|
||||
app.cf,
|
||||
healthEngineFallbacksFromConfig(config),
|
||||
app.log,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return app;
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface AppConfig {
|
||||
healthLatencyWarnMs: number;
|
||||
/** Min pause between probes to different physical targets (same IP is probed once). */
|
||||
healthProbeGapMs: number;
|
||||
healthWorkerUrl: string;
|
||||
healthWorkerToken: string;
|
||||
logLevel: string;
|
||||
/** Portal SSO — when true, require portal JWT with apps includes cfdm */
|
||||
authRequired: boolean;
|
||||
@@ -61,6 +63,8 @@ export function loadConfig(): AppConfig {
|
||||
healthLatencyWarnMs:
|
||||
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
|
||||
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",
|
||||
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||
authIssuer:
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
} from "@cfdm/shared";
|
||||
import { createDnsAdapter } from "./cloudflare/dns-service.js";
|
||||
import { createHealthCheckAdapter, type CfHealthCheckPayload } from "./cloudflare/healthcheck-service.js";
|
||||
import { createKvAdapter } from "./cloudflare/kv-service.js";
|
||||
import { createWorkersAdapter } from "./cloudflare/workers-service.js";
|
||||
import { createZoneAdapter } from "./cloudflare/zone-service.js";
|
||||
|
||||
export type { CfHealthCheckPayload };
|
||||
@@ -15,11 +17,21 @@ export class CloudflareClient {
|
||||
private readonly zones;
|
||||
private readonly dns;
|
||||
private readonly healthchecks;
|
||||
private readonly kv;
|
||||
private readonly workers;
|
||||
private readonly token;
|
||||
|
||||
constructor(token: string) {
|
||||
this.token = token.trim();
|
||||
this.zones = createZoneAdapter(token);
|
||||
this.dns = createDnsAdapter(token);
|
||||
this.healthchecks = createHealthCheckAdapter(token);
|
||||
this.kv = createKvAdapter(token);
|
||||
this.workers = createWorkersAdapter(token);
|
||||
}
|
||||
|
||||
get isConfigured(): boolean {
|
||||
return this.token.length > 0;
|
||||
}
|
||||
|
||||
listZones(): Promise<CfZone[]> {
|
||||
@@ -81,4 +93,45 @@ export class CloudflareClient {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,15 @@ export function mapCloudflareFailure(
|
||||
): 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.",
|
||||
);
|
||||
@@ -67,6 +76,40 @@ export async function handleCfResponse<T>(
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { CF_API_BASE, handleCfResponse, handleCfSuccess, mapCloudflareFailure } from "./http.js";
|
||||
|
||||
export interface CfKvNamespace {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function createKvAdapter(token: string) {
|
||||
return {
|
||||
async listNamespaces(accountId: string): Promise<CfKvNamespace[]> {
|
||||
const all: CfKvNamespace[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const url = new URL(
|
||||
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
|
||||
);
|
||||
url.searchParams.set("per_page", "100");
|
||||
url.searchParams.set("page", String(page));
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("kv_list", response.status, String(response.status));
|
||||
}
|
||||
const batch = await handleCfResponse<CfKvNamespace[]>(response, "kv_list");
|
||||
all.push(...batch);
|
||||
if (batch.length < 100) break;
|
||||
page += 1;
|
||||
}
|
||||
return all;
|
||||
},
|
||||
|
||||
async createNamespace(accountId: string, title: string): Promise<CfKvNamespace> {
|
||||
const response = await fetch(
|
||||
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ title }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("kv_create", response.status, String(response.status));
|
||||
}
|
||||
return handleCfResponse<CfKvNamespace>(response, "kv_create");
|
||||
},
|
||||
|
||||
async getValue(
|
||||
accountId: string,
|
||||
namespaceId: string,
|
||||
key: string,
|
||||
): Promise<string | null> {
|
||||
const response = await fetch(
|
||||
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
if (response.status === 404) return null;
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("kv_get", response.status, String(response.status));
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw mapCloudflareFailure("kv_get", response.status, text.slice(0, 180));
|
||||
}
|
||||
return response.text();
|
||||
},
|
||||
|
||||
async putValue(
|
||||
accountId: string,
|
||||
namespaceId: string,
|
||||
key: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${CF_API_BASE}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${encodeURIComponent(key)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
body: value,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("kv_put", response.status, String(response.status));
|
||||
}
|
||||
await handleCfSuccess(response, "kv_put");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
CF_API_BASE,
|
||||
handleCfResponse,
|
||||
handleCfSuccess,
|
||||
mapCloudflareFailure,
|
||||
} from "./http.js";
|
||||
|
||||
export interface CfAccount {
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface CfWorkersSubdomain {
|
||||
subdomain?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function createWorkersAdapter(token: string) {
|
||||
return {
|
||||
async listAccounts(): Promise<CfAccount[]> {
|
||||
const response = await fetch(`${CF_API_BASE}/accounts?per_page=50`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("list_accounts", response.status, String(response.status));
|
||||
}
|
||||
return handleCfResponse<CfAccount[]>(response, "list_accounts");
|
||||
},
|
||||
|
||||
async putScript(opts: {
|
||||
accountId: string;
|
||||
scriptName: string;
|
||||
source: string;
|
||||
kvNamespaceId: string;
|
||||
filename?: string;
|
||||
}): Promise<void> {
|
||||
const filename = opts.filename ?? "index.mjs";
|
||||
const metadata = {
|
||||
main_module: filename,
|
||||
compatibility_date: "2025-04-01",
|
||||
bindings: [
|
||||
{
|
||||
type: "kv_namespace",
|
||||
name: "HEALTH_KV",
|
||||
namespace_id: opts.kvNamespaceId,
|
||||
},
|
||||
],
|
||||
};
|
||||
const form = new FormData();
|
||||
form.append(
|
||||
"metadata",
|
||||
new Blob([JSON.stringify(metadata)], { type: "application/json" }),
|
||||
);
|
||||
form.append(
|
||||
filename,
|
||||
new Blob([opts.source], { type: "application/javascript+module" }),
|
||||
filename,
|
||||
);
|
||||
const response = await fetch(
|
||||
`${CF_API_BASE}/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: form,
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
},
|
||||
);
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("workers_put_script", response.status, String(response.status));
|
||||
}
|
||||
await handleCfSuccess(response, "workers_put_script");
|
||||
},
|
||||
|
||||
async putSchedules(
|
||||
accountId: string,
|
||||
scriptName: string,
|
||||
crons: string[],
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/schedules`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(crons.map((cron) => ({ cron }))),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("workers_put_schedules", response.status, String(response.status));
|
||||
}
|
||||
await handleCfSuccess(response, "workers_put_schedules");
|
||||
},
|
||||
|
||||
async enableWorkersDev(
|
||||
accountId: string,
|
||||
scriptName: string,
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
if (response.status === 409) return;
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("workers_subdomain", response.status, String(response.status));
|
||||
}
|
||||
if (!response.ok && response.status !== 200 && response.status !== 201) {
|
||||
await handleCfSuccess(response, "workers_subdomain");
|
||||
}
|
||||
},
|
||||
|
||||
async getWorkersSubdomain(accountId: string): Promise<string | null> {
|
||||
const response = await fetch(
|
||||
`${CF_API_BASE}/accounts/${accountId}/workers/subdomain`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
);
|
||||
if (response.status === 404) return null;
|
||||
if (response.status >= 500 || response.status === 429) {
|
||||
throw mapCloudflareFailure("workers_get_subdomain", response.status, String(response.status));
|
||||
}
|
||||
const result = await handleCfResponse<CfWorkersSubdomain>(
|
||||
response,
|
||||
"workers_get_subdomain",
|
||||
);
|
||||
return result.subdomain?.trim() || null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||
import {
|
||||
clampGlobalpingLimit,
|
||||
parseGlobalpingLocations,
|
||||
} from "@cfdm/shared";
|
||||
|
||||
export const GLOBALPING_API_ROOT = "https://api.globalping.io";
|
||||
export const GLOBALPING_MIN_POLL_MS = 500;
|
||||
export const GLOBALPING_UA = "CFDM-health/1.0";
|
||||
|
||||
export interface GlobalpingClientOptions {
|
||||
token?: string | null;
|
||||
locations?: string;
|
||||
limit?: number;
|
||||
pollIntervalMs?: number;
|
||||
maxWaitMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface GlobalpingProbeResult {
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
error: string | null;
|
||||
colo: string | null;
|
||||
}
|
||||
|
||||
interface MeasurementCreateBody {
|
||||
type: "ping" | "http";
|
||||
target: string;
|
||||
inProgressUpdates: false;
|
||||
limit: number;
|
||||
locations: Array<{ magic: string }>;
|
||||
measurementOptions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface MeasurementProbe {
|
||||
continent?: string;
|
||||
country?: string;
|
||||
city?: string;
|
||||
network?: string;
|
||||
}
|
||||
|
||||
interface MeasurementResultRow {
|
||||
probe?: MeasurementProbe;
|
||||
result?: {
|
||||
status?: string;
|
||||
statusCode?: number;
|
||||
timings?: { total?: number };
|
||||
stats?: { avg?: number; loss?: number };
|
||||
};
|
||||
}
|
||||
|
||||
interface MeasurementResponse {
|
||||
id?: string;
|
||||
status?: string;
|
||||
results?: MeasurementResultRow[];
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function locationLabel(probe: MeasurementProbe | undefined): string | null {
|
||||
if (!probe) return null;
|
||||
const city = probe.city?.trim();
|
||||
const country = probe.country?.trim();
|
||||
if (city && country) return `${city}, ${country}`;
|
||||
return city || country || null;
|
||||
}
|
||||
|
||||
export function buildMeasurementBody(
|
||||
target: HealthCheckTarget,
|
||||
options: GlobalpingClientOptions,
|
||||
): MeasurementCreateBody {
|
||||
const limit = clampGlobalpingLimit(options.limit, 3);
|
||||
const locations = parseGlobalpingLocations(options.locations).map((magic) => ({
|
||||
magic,
|
||||
}));
|
||||
const port = target.port ?? (target.type === "http" ? 80 : 80);
|
||||
const ip = String(target.ip || "").trim();
|
||||
const hostname = (target.hostname || ip).trim();
|
||||
|
||||
if (target.type === "http") {
|
||||
const path = target.path?.trim() || "/";
|
||||
const protocol = port === 443 ? "HTTPS" : "HTTP";
|
||||
return {
|
||||
type: "http",
|
||||
target: ip,
|
||||
inProgressUpdates: false,
|
||||
limit,
|
||||
locations,
|
||||
measurementOptions: {
|
||||
protocol,
|
||||
port,
|
||||
request: {
|
||||
method: "GET",
|
||||
host: hostname,
|
||||
path: path.startsWith("/") ? path : `/${path}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "ping",
|
||||
target: ip,
|
||||
inProgressUpdates: false,
|
||||
limit,
|
||||
locations,
|
||||
measurementOptions: {
|
||||
protocol: "TCP",
|
||||
port,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rowOk(target: HealthCheckTarget, row: MeasurementResultRow): boolean {
|
||||
const result = row.result;
|
||||
if (!result) return false;
|
||||
const status = String(result.status ?? "").toLowerCase();
|
||||
if (status && status !== "finished") return false;
|
||||
if (target.type === "http") {
|
||||
const code = result.statusCode;
|
||||
if (code == null) return false;
|
||||
if (target.expected_status != null) return code === target.expected_status;
|
||||
return code >= 200 && code < 400;
|
||||
}
|
||||
const loss = result.stats?.loss;
|
||||
if (loss != null && loss >= 100) return false;
|
||||
return status === "finished" || status === "";
|
||||
}
|
||||
|
||||
function rowLatency(row: MeasurementResultRow): number {
|
||||
const total = row.result?.timings?.total;
|
||||
if (typeof total === "number" && Number.isFinite(total)) return Math.round(total);
|
||||
const avg = row.result?.stats?.avg;
|
||||
if (typeof avg === "number" && Number.isFinite(avg)) return Math.round(avg);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function summarizeMeasurement(
|
||||
target: HealthCheckTarget,
|
||||
doc: MeasurementResponse,
|
||||
): GlobalpingProbeResult {
|
||||
const rows = doc.results ?? [];
|
||||
if (rows.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: "Globalping: пустой результат",
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
const oks = rows.map((row) => rowOk(target, row));
|
||||
const okCount = oks.filter(Boolean).length;
|
||||
const ok = okCount > rows.length / 2;
|
||||
const latencies = rows.map(rowLatency);
|
||||
const latencyMs = Math.round(
|
||||
latencies.reduce((sum, n) => sum + n, 0) / latencies.length,
|
||||
);
|
||||
const colo =
|
||||
locationLabel(rows.find((_, i) => oks[i])?.probe) ??
|
||||
locationLabel(rows[0]?.probe);
|
||||
if (ok) {
|
||||
return { ok: true, latencyMs, error: null, colo };
|
||||
}
|
||||
const expected =
|
||||
target.type === "http" && target.expected_status != null
|
||||
? `ожидали HTTP ${target.expected_status}`
|
||||
: target.type === "http"
|
||||
? "ожидали HTTP 2xx/3xx"
|
||||
: "TCP ping с packet loss < 100%";
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs,
|
||||
error: `Globalping: ${okCount}/${rows.length} проб успешны (${expected})`,
|
||||
colo,
|
||||
};
|
||||
}
|
||||
|
||||
async function parseJson(response: Response): Promise<MeasurementResponse> {
|
||||
try {
|
||||
return (await response.json()) as MeasurementResponse;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function runGlobalpingMeasurement(
|
||||
target: HealthCheckTarget,
|
||||
options: GlobalpingClientOptions = {},
|
||||
): Promise<GlobalpingProbeResult> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const pollMs =
|
||||
options.pollIntervalMs === undefined
|
||||
? GLOBALPING_MIN_POLL_MS
|
||||
: Math.max(0, options.pollIntervalMs);
|
||||
const maxWaitMs = options.maxWaitMs ?? Math.max(target.timeout_ms ?? 3000, 3000) + 15_000;
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": GLOBALPING_UA,
|
||||
};
|
||||
const token = options.token?.trim();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const created = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(buildMeasurementBody(target, options)),
|
||||
});
|
||||
if (created.status === 429) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: "Globalping: 429 rate limit",
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
if (created.status !== 202 && created.status !== 200) {
|
||||
const body = await parseJson(created);
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: `Globalping: HTTP ${created.status}${body.status ? ` (${body.status})` : ""}`,
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
const createdBody = await parseJson(created);
|
||||
const id = createdBody.id?.trim();
|
||||
if (!id) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: "Globalping: нет id измерения",
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < maxWaitMs) {
|
||||
await sleep(pollMs);
|
||||
const polled = await fetchImpl(`${GLOBALPING_API_ROOT}/v1/measurements/${id}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": GLOBALPING_UA,
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
if (polled.status === 429) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: "Globalping: 429 rate limit",
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
if (!polled.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: `Globalping: HTTP ${polled.status} при опросе`,
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
const doc = await parseJson(polled);
|
||||
if (String(doc.status ?? "").toLowerCase() === "in-progress") continue;
|
||||
return summarizeMeasurement(target, doc);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: Date.now() - started,
|
||||
error: "Globalping: timeout ожидания measurement",
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { 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 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) {
|
||||
app.get("/health-status", async (request) => {
|
||||
@@ -16,15 +21,22 @@ export async function healthCheckRoutes(app: FastifyInstance) {
|
||||
|
||||
app.post("/health-check/run", async (request) => {
|
||||
const config = request.server.config;
|
||||
const fallbacks = healthEngineFallbacksFromConfig(config);
|
||||
const settings = getAppSettings(
|
||||
request.server.db,
|
||||
fallbacks,
|
||||
);
|
||||
const thresholds = {
|
||||
degradedFailures: config.healthDegradedFailures,
|
||||
downFailures: config.healthDownFailures,
|
||||
latencyWarnMs: config.healthLatencyWarnMs,
|
||||
successRecoveries: config.healthSuccessRecoveries,
|
||||
degradedFailures: settings.healthDegradedFailures,
|
||||
downFailures: settings.healthDownFailures,
|
||||
latencyWarnMs: settings.healthLatencyWarnMs,
|
||||
successRecoveries: settings.healthSuccessRecoveries,
|
||||
};
|
||||
const checked = await healthCheckService.runAllChecks(request.server.db, {
|
||||
thresholds,
|
||||
probeGapMs: config.healthProbeGapMs,
|
||||
mailbox: mailboxFromSettings(request.server.db, request.server.cf, fallbacks),
|
||||
staleAfterMs: cronStaleAfterMs(settings.healthCheckCron),
|
||||
onStatusChange: async (target, prev, next) => {
|
||||
try {
|
||||
const label =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { changeIpSchema } from "@cfdm/shared";
|
||||
import { certMonitoringSchema, changeIpSchema } from "@cfdm/shared";
|
||||
import * as bindingService from "../services/binding-service.js";
|
||||
import * as changeIp from "../services/change-ip-service.js";
|
||||
import { recordAudit } from "../lib/audit.js";
|
||||
@@ -17,6 +17,7 @@ export async function serviceBindingRoutes(app: FastifyInstance) {
|
||||
service_id: z.number().optional(),
|
||||
hostname: z.string().optional(),
|
||||
target_ip: z.string().optional(),
|
||||
cert_monitoring: certMonitoringSchema.optional(),
|
||||
});
|
||||
|
||||
app.get("/service-bindings", async (request) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { repos } from "@cfdm/db";
|
||||
import * as serviceConfig from "../services/service-config-service.js";
|
||||
import * as 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";
|
||||
|
||||
export async function serviceRoutes(app: FastifyInstance) {
|
||||
@@ -65,6 +66,47 @@ export async function serviceRoutes(app: FastifyInstance) {
|
||||
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));
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
assertValidHealthCron,
|
||||
healthEngineFallbacksFromConfig,
|
||||
} from "../services/health-check-scheduler.js";
|
||||
import { ensureHealthWorker } from "../services/health/health-worker-deploy.js";
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance) {
|
||||
app.get("/settings", async (request) => {
|
||||
@@ -40,11 +41,29 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
"ошибок до down не меньше, чем до degraded",
|
||||
);
|
||||
}
|
||||
const next = updateAppSettings(request.server.db, body, fallbacks);
|
||||
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 next;
|
||||
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) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface UpdateBindingRequest {
|
||||
service_id?: number;
|
||||
hostname?: string;
|
||||
target_ip?: string;
|
||||
cert_monitoring?: string;
|
||||
}
|
||||
|
||||
function normalizeHostname(hostname?: string): string {
|
||||
@@ -92,6 +93,20 @@ export async function update(
|
||||
req: UpdateBindingRequest,
|
||||
): Promise<ServiceBindingView> {
|
||||
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;
|
||||
if (req.service_id) repos.getService(db, req.service_id);
|
||||
const hostname = req.hostname
|
||||
|
||||
@@ -2,7 +2,7 @@ import { connect } from "node:net";
|
||||
import { connect as tlsConnect } from "node:tls";
|
||||
import type { Db } 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 {
|
||||
CERT_ERROR,
|
||||
CERT_MONITOR_AUTO,
|
||||
@@ -11,13 +11,13 @@ import {
|
||||
CERT_UNKNOWN,
|
||||
certStatusFromExpiry,
|
||||
fqdnToDisplay,
|
||||
parseFqdn,
|
||||
shouldMonitorService,
|
||||
} from "@cfdm/shared";
|
||||
|
||||
export interface CertificateTarget {
|
||||
domainId: number;
|
||||
subdomainId: number | null;
|
||||
serviceId: number;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,34 @@ export function getCertificate(db: Db, id: number): Certificate {
|
||||
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(
|
||||
hostname: string,
|
||||
): Promise<{ expiresAt: Date | null; error: string | null }> {
|
||||
@@ -78,6 +106,7 @@ export async function checkAndStore(
|
||||
domainId: number,
|
||||
subdomainId: number | null,
|
||||
hostname: string,
|
||||
serviceId: number | null = null,
|
||||
): Promise<Certificate> {
|
||||
const { expiresAt, error } = await checkHostname(hostname);
|
||||
|
||||
@@ -90,6 +119,7 @@ export async function checkAndStore(
|
||||
null,
|
||||
CERT_ERROR,
|
||||
error,
|
||||
serviceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,6 +135,7 @@ export async function checkAndStore(
|
||||
expiresAt.toISOString(),
|
||||
certStatusFromExpiry(days),
|
||||
null,
|
||||
serviceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -116,23 +147,10 @@ export async function checkAndStore(
|
||||
null,
|
||||
CERT_UNKNOWN,
|
||||
"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(
|
||||
db: Db,
|
||||
domainId: number,
|
||||
@@ -165,10 +183,9 @@ function hasSslHealthGate(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildServiceCertificateFqdns(
|
||||
db: Db,
|
||||
): Map<string, CertificateTarget> {
|
||||
const result = new Map<string, CertificateTarget>();
|
||||
export function resolveCertificateTargets(db: Db): CertificateTarget[] {
|
||||
const targets: CertificateTarget[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const binding of repos.listAllBindings(db)) {
|
||||
const service = repos.getService(db, binding.service_id);
|
||||
@@ -176,98 +193,40 @@ export function buildServiceCertificateFqdns(
|
||||
? repos.getServiceGroup(db, service.service_group_id)
|
||||
: null;
|
||||
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);
|
||||
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);
|
||||
result.set(fqdn, {
|
||||
if (seen.has(fqdn)) continue;
|
||||
seen.add(fqdn);
|
||||
targets.push({
|
||||
domainId: binding.domain_id,
|
||||
subdomainId: subdomain?.id ?? null,
|
||||
serviceId: binding.service_id,
|
||||
hostname: fqdn,
|
||||
});
|
||||
}
|
||||
|
||||
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name);
|
||||
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()];
|
||||
return targets;
|
||||
}
|
||||
|
||||
export async function runAllChecks(db: Db): Promise<number> {
|
||||
@@ -278,6 +237,7 @@ export async function runAllChecks(db: Db): Promise<number> {
|
||||
target.domainId,
|
||||
target.subdomainId,
|
||||
target.hostname,
|
||||
target.serviceId,
|
||||
);
|
||||
}
|
||||
repos.deleteCertificatesNotIn(
|
||||
@@ -287,6 +247,26 @@ export async function runAllChecks(db: Db): Promise<number> {
|
||||
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]> {
|
||||
pruneStaleCertificates(db);
|
||||
return repos.countCertificatesByStatus(db);
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos, type DnsListFilter } from "@cfdm/db";
|
||||
import type { CreateDnsRecordPayload, DnsRecord, PatchDnsRecordPayload } from "@cfdm/shared";
|
||||
import type {
|
||||
CfDnsRecord,
|
||||
CreateDnsRecordPayload,
|
||||
DnsRecord,
|
||||
PatchDnsRecordPayload,
|
||||
} from "@cfdm/shared";
|
||||
import {
|
||||
SYNC_CONFLICT,
|
||||
SYNC_ERROR,
|
||||
SYNC_PENDING_PUSH,
|
||||
SYNC_SYNCED,
|
||||
dnsRecordNamesMatch,
|
||||
normalizeDnsRecordName,
|
||||
} from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||
@@ -64,6 +70,86 @@ function toCfPayload(
|
||||
};
|
||||
}
|
||||
|
||||
function isMissingCfDnsRecord(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /record does not exist|81044/i.test(message);
|
||||
}
|
||||
|
||||
function dnsContentMatches(
|
||||
recordType: string,
|
||||
left: string,
|
||||
right: string,
|
||||
): boolean {
|
||||
if (recordType.toUpperCase() === "CNAME") {
|
||||
return (
|
||||
left.trim().replace(/\.+$/, "").toLowerCase() ===
|
||||
right.trim().replace(/\.+$/, "").toLowerCase()
|
||||
);
|
||||
}
|
||||
return left === right;
|
||||
}
|
||||
|
||||
/** Resolve live Cloudflare record by name + type + content (IP / CNAME target). */
|
||||
function findRemoteByIdentity(
|
||||
remote: readonly CfDnsRecord[],
|
||||
zoneName: string,
|
||||
recordType: string,
|
||||
name: string,
|
||||
content: string,
|
||||
): CfDnsRecord | undefined {
|
||||
const type = recordType.toUpperCase();
|
||||
return remote.find(
|
||||
(record) =>
|
||||
Boolean(record.id) &&
|
||||
(record.type ?? "").toUpperCase() === type &&
|
||||
dnsRecordNamesMatch(record.name, name, zoneName) &&
|
||||
dnsContentMatches(type, record.content, content),
|
||||
);
|
||||
}
|
||||
|
||||
function findRemoteByCfId(
|
||||
remote: readonly CfDnsRecord[],
|
||||
cfRecordId: string | null | undefined,
|
||||
): CfDnsRecord | undefined {
|
||||
if (!cfRecordId) return undefined;
|
||||
return remote.find((record) => record.id === cfRecordId);
|
||||
}
|
||||
|
||||
async function markSynced(
|
||||
db: Db,
|
||||
domainId: number,
|
||||
record: DnsRecord,
|
||||
cfRec: {
|
||||
id?: string | null;
|
||||
type?: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
proxied?: boolean | null;
|
||||
priority?: number | null;
|
||||
},
|
||||
): Promise<DnsRecord> {
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
record.id,
|
||||
cfRec.type ?? record.record_type,
|
||||
cfRec.name,
|
||||
cfRec.content,
|
||||
cfRec.ttl,
|
||||
cfRec.proxied ?? false,
|
||||
cfRec.priority ?? null,
|
||||
SYNC_SYNCED,
|
||||
cfRec.id ?? null,
|
||||
null,
|
||||
);
|
||||
return repos.getDnsRecord(db, domainId, record.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push local desired state to Cloudflare.
|
||||
* Identity is name + type + content; cf_record_id is only a cache hint
|
||||
* (records may be deleted/recreated outside CFDM).
|
||||
*/
|
||||
async function pushRecord(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
@@ -71,6 +157,7 @@ async function pushRecord(
|
||||
cfZoneId: string,
|
||||
record: DnsRecord,
|
||||
): Promise<DnsRecord> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const payload = toCfPayload(
|
||||
record.record_type,
|
||||
record.name,
|
||||
@@ -81,25 +168,38 @@ async function pushRecord(
|
||||
);
|
||||
|
||||
try {
|
||||
const cfRec = record.cf_record_id
|
||||
? await cf.updateDnsRecord(cfZoneId, record.cf_record_id, payload)
|
||||
: await cf.createDnsRecord(cfZoneId, payload);
|
||||
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
record.id,
|
||||
cfRec.type ?? record.record_type,
|
||||
cfRec.name,
|
||||
cfRec.content,
|
||||
cfRec.ttl,
|
||||
cfRec.proxied ?? false,
|
||||
cfRec.priority ?? null,
|
||||
SYNC_SYNCED,
|
||||
cfRec.id ?? null,
|
||||
null,
|
||||
const remote = await cf.listDnsRecords(cfZoneId);
|
||||
const byIdentity = findRemoteByIdentity(
|
||||
remote,
|
||||
domain.zone_name,
|
||||
record.record_type,
|
||||
record.name,
|
||||
record.content,
|
||||
);
|
||||
return repos.getDnsRecord(db, domainId, record.id);
|
||||
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
|
||||
const targetId = byIdentity?.id ?? byCachedId?.id ?? null;
|
||||
|
||||
const cfRec = targetId
|
||||
? await cf.updateDnsRecord(cfZoneId, targetId, payload)
|
||||
: await cf.createDnsRecord(cfZoneId, payload);
|
||||
return markSynced(db, domainId, record, cfRec);
|
||||
} catch (e) {
|
||||
// Race: cached id vanished mid-flight — recreate by identity.
|
||||
if (isMissingCfDnsRecord(e)) {
|
||||
try {
|
||||
const created = await cf.createDnsRecord(cfZoneId, payload);
|
||||
return markSynced(db, domainId, record, created);
|
||||
} catch (createErr) {
|
||||
repos.setDnsSyncStatus(
|
||||
db,
|
||||
record.id,
|
||||
SYNC_ERROR,
|
||||
null,
|
||||
createErr instanceof Error ? createErr.message : String(createErr),
|
||||
);
|
||||
throw createErr;
|
||||
}
|
||||
}
|
||||
repos.setDnsSyncStatus(
|
||||
db,
|
||||
record.id,
|
||||
@@ -188,15 +288,23 @@ export async function patchContent(
|
||||
): 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,
|
||||
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||
const byIdentity = findRemoteByIdentity(
|
||||
remote,
|
||||
domain.zone_name,
|
||||
existing.record_type,
|
||||
existing.name,
|
||||
existing.content,
|
||||
);
|
||||
const byCachedId = findRemoteByCfId(remote, existing.cf_record_id);
|
||||
const targetId = byIdentity?.id ?? byCachedId?.id ?? null;
|
||||
if (!targetId) {
|
||||
throw AppError.dnsUpdateFailed(
|
||||
"DNS-запись не найдена в Cloudflare по имени и содержимому",
|
||||
);
|
||||
}
|
||||
const cfRec = await cf.patchDnsRecord(domain.cf_zone_id, targetId, payload);
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
existing.id,
|
||||
@@ -207,7 +315,7 @@ export async function patchContent(
|
||||
cfRec.proxied ?? existing.proxied,
|
||||
cfRec.priority ?? existing.priority,
|
||||
SYNC_SYNCED,
|
||||
cfRec.id ?? existing.cf_record_id,
|
||||
cfRec.id ?? targetId,
|
||||
null,
|
||||
);
|
||||
return repos.getDnsRecord(db, domainId, existing.id);
|
||||
@@ -235,20 +343,41 @@ export async function deleteRecord(
|
||||
const record = repos.getDnsRecord(db, domainId, recordId);
|
||||
repos.markDnsPendingDelete(db, recordId);
|
||||
|
||||
if (record.cf_record_id) {
|
||||
let targetId: string | null = null;
|
||||
try {
|
||||
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||
const byIdentity = findRemoteByIdentity(
|
||||
remote,
|
||||
domain.zone_name,
|
||||
record.record_type,
|
||||
record.name,
|
||||
record.content,
|
||||
);
|
||||
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
|
||||
targetId = byIdentity?.id ?? byCachedId?.id ?? null;
|
||||
} catch {
|
||||
// Zone list failed — fall back to cached id only.
|
||||
targetId = record.cf_record_id;
|
||||
}
|
||||
|
||||
if (targetId) {
|
||||
try {
|
||||
await cf.deleteDnsRecord(domain.cf_zone_id, record.cf_record_id);
|
||||
await cf.deleteDnsRecord(domain.cf_zone_id, targetId);
|
||||
} catch (e) {
|
||||
repos.setDnsSyncStatus(
|
||||
db,
|
||||
recordId,
|
||||
SYNC_ERROR,
|
||||
record.cf_record_id,
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
throw e;
|
||||
// Already gone in Cloudflare (manual delete) — drop local row.
|
||||
if (!isMissingCfDnsRecord(e)) {
|
||||
repos.setDnsSyncStatus(
|
||||
db,
|
||||
recordId,
|
||||
SYNC_ERROR,
|
||||
targetId,
|
||||
e instanceof Error ? e.message : String(e),
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repos.deleteDnsRecord(db, recordId);
|
||||
}
|
||||
|
||||
@@ -326,24 +455,30 @@ export async function resolveConflict(
|
||||
}
|
||||
|
||||
if (req.source === "cloudflare") {
|
||||
if (record.cf_record_id) {
|
||||
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||
const r = remote.find((x) => x.id === record.cf_record_id);
|
||||
if (r) {
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
recordId,
|
||||
r.type,
|
||||
r.name,
|
||||
r.content,
|
||||
r.ttl,
|
||||
r.proxied ?? false,
|
||||
r.priority ?? null,
|
||||
SYNC_SYNCED,
|
||||
r.id ?? null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||
const byIdentity = findRemoteByIdentity(
|
||||
remote,
|
||||
domain.zone_name,
|
||||
record.record_type,
|
||||
record.name,
|
||||
record.content,
|
||||
);
|
||||
const byCachedId = findRemoteByCfId(remote, record.cf_record_id);
|
||||
const r = byIdentity ?? byCachedId;
|
||||
if (r) {
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
recordId,
|
||||
r.type,
|
||||
r.name,
|
||||
r.content,
|
||||
r.ttl,
|
||||
r.proxied ?? false,
|
||||
r.priority ?? null,
|
||||
SYNC_SYNCED,
|
||||
r.id ?? null,
|
||||
null,
|
||||
);
|
||||
}
|
||||
return repos.getDnsRecord(db, domainId, recordId);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ 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";
|
||||
@@ -9,6 +11,10 @@ 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 {
|
||||
@@ -27,6 +33,8 @@ export function healthEngineFallbacksFromConfig(
|
||||
healthDownFailures: config.healthDownFailures,
|
||||
healthLatencyWarnMs: config.healthLatencyWarnMs,
|
||||
healthSuccessRecoveries: config.healthSuccessRecoveries,
|
||||
healthWorkerUrl: config.healthWorkerUrl,
|
||||
healthWorkerTokenSet: Boolean(config.healthWorkerToken),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,9 +71,18 @@ export function createHealthCheckTask(
|
||||
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 =
|
||||
@@ -98,6 +115,13 @@ export function createHealthCheckTask(
|
||||
}
|
||||
},
|
||||
});
|
||||
if (mailbox) {
|
||||
updateAppSettings(
|
||||
app.db,
|
||||
{ healthWorkerLastIngestAt: new Date().toISOString() },
|
||||
fallbacks,
|
||||
);
|
||||
}
|
||||
const monitors = await healthCheckService.runDomainMonitors(
|
||||
app.db,
|
||||
thresholds,
|
||||
|
||||
@@ -3,9 +3,28 @@ import { resolve4, resolve6 } from "node:dns/promises";
|
||||
import { Agent, buildConnector, fetch as undiciFetch } from "undici";
|
||||
import type { Db } 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 { 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 {
|
||||
degradedFailures: number;
|
||||
@@ -18,6 +37,7 @@ export interface ProbeResult {
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
error: string | null;
|
||||
colo?: string | null;
|
||||
}
|
||||
|
||||
/** Bracket IPv6 for URL authority; leave IPv4/hostname as-is. */
|
||||
@@ -261,11 +281,16 @@ export interface RunAllChecksOptions {
|
||||
thresholds: HealthCheckThresholds;
|
||||
/** Pause between unique physical probes (default 2000). Same IP is only probed once. */
|
||||
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?: (
|
||||
target: HealthCheckTarget,
|
||||
prevState: IpHealthState | null,
|
||||
nextState: IpHealthState,
|
||||
) => void;
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
@@ -273,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
|
||||
* so anti-bot / rate-limit on the origin is not tripped by back-to-back checks.
|
||||
* One network hit per origin+provider. Group+binding on the same IP share a probe.
|
||||
*/
|
||||
export function physicalProbeKey(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}`;
|
||||
export function physicalProbeKey(
|
||||
target: HealthCheckTarget,
|
||||
provider: HealthCheckProvider = target.provider,
|
||||
): string {
|
||||
return `${provider}|${originProbeKey(target)}`;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
async 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 (target.type === "ping") {
|
||||
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||
if (prevState !== state) {
|
||||
await options.onStatusChange?.(target, prevState, state);
|
||||
}
|
||||
if (target.type === "dns") {
|
||||
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||
}
|
||||
return `${target.type}|${ip}|${port}`;
|
||||
}
|
||||
|
||||
function staleWorkerResult(colo: string | null): ProbeResult {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: "Cloudflare Worker: результаты устарели или KV пуст",
|
||||
colo,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runAllChecks(
|
||||
@@ -300,76 +416,107 @@ export async function runAllChecks(
|
||||
): Promise<number> {
|
||||
const targets = repos.listHealthCheckTargets(db);
|
||||
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
|
||||
const local = new LocalHealthCheckProvider();
|
||||
const staleAfterMs = options.staleAfterMs ?? 10 * 60_000;
|
||||
|
||||
const byPhysical = new Map<string, HealthCheckTarget[]>();
|
||||
const byOrigin = new Map<string, HealthCheckTarget[]>();
|
||||
for (const target of targets) {
|
||||
const key = physicalProbeKey(target);
|
||||
const list = byPhysical.get(key);
|
||||
const key = originProbeKey(target);
|
||||
const list = byOrigin.get(key);
|
||||
if (list) list.push(target);
|
||||
else byPhysical.set(key, [target]);
|
||||
else byOrigin.set(key, [target]);
|
||||
}
|
||||
|
||||
let probeIndex = 0;
|
||||
for (const group of byPhysical.values()) {
|
||||
if (probeIndex > 0 && gapMs > 0) {
|
||||
await sleep(gapMs);
|
||||
const needsCloudflare = targets.some((t) =>
|
||||
targetProviders(t).includes("cloudflare"),
|
||||
);
|
||||
let mailboxResults = new Map<string, { ok: boolean; latencyMs: number; error: string | null }>();
|
||||
let mailboxColo: string | null = null;
|
||||
let mailboxStale = true;
|
||||
const mailbox = options.mailbox ?? null;
|
||||
if (needsCloudflare) {
|
||||
const resultsDoc = mailbox ? await mailbox.getResults() : null;
|
||||
mailboxResults = indexResults(resultsDoc);
|
||||
mailboxStale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);
|
||||
mailboxColo = resultsDoc?.colo ?? null;
|
||||
if (mailbox) {
|
||||
try {
|
||||
const next = buildTargetsDoc(targets);
|
||||
const current = await mailbox.getTargets();
|
||||
if (current?.fingerprint !== next.fingerprint) {
|
||||
await mailbox.putTargets(next);
|
||||
}
|
||||
} catch {
|
||||
// ingest still proceeds
|
||||
}
|
||||
}
|
||||
probeIndex += 1;
|
||||
}
|
||||
|
||||
// Prefer binding hostname for SNI when several scopes share one IP.
|
||||
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 result = await probeTarget(representative);
|
||||
|
||||
const needed = new Set<HealthCheckProvider>();
|
||||
for (const target of group) {
|
||||
const prev = repos.getIpHealthStatusRow(
|
||||
db,
|
||||
target.scope,
|
||||
target.ref_id,
|
||||
target.ip,
|
||||
);
|
||||
const { state, failures, successes, node } = deriveState(
|
||||
result.ok,
|
||||
result.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,
|
||||
result.latencyMs,
|
||||
failures,
|
||||
result.error,
|
||||
successes,
|
||||
);
|
||||
const matchedNode = repos.findNodeByIp(db, target.ip);
|
||||
if (matchedNode && matchedNode.enabled) {
|
||||
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: result.error,
|
||||
});
|
||||
}
|
||||
if (prevState !== state) {
|
||||
options.onStatusChange?.(target, prevState, state);
|
||||
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);
|
||||
}
|
||||
await applyAggregatedStatus(db, target, sources, options);
|
||||
}
|
||||
}
|
||||
// Orphan rows (old IPs / hostname keys) still feed MAX latency on group badge.
|
||||
|
||||
repos.pruneStaleIpHealthStatus(db, targets);
|
||||
return targets.length;
|
||||
}
|
||||
@@ -392,6 +539,9 @@ export async function runDomainMonitors(
|
||||
expected_status: monitor.expected_status,
|
||||
timeout_ms: monitor.timeout_ms,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
providers: ["local"],
|
||||
aggregate: "majority",
|
||||
};
|
||||
let result: ProbeResult;
|
||||
if (monitor.type === "http") {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||
import {
|
||||
runGlobalpingMeasurement,
|
||||
type GlobalpingClientOptions,
|
||||
} from "../../lib/globalping-client.js";
|
||||
import type { ProbeResult } from "../health-check-service.js";
|
||||
|
||||
export function globalpingNotConfiguredResult(): ProbeResult {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: "Globalping: токен не задан",
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function probeWithGlobalping(
|
||||
target: HealthCheckTarget,
|
||||
options: GlobalpingClientOptions,
|
||||
): Promise<ProbeResult> {
|
||||
if (!options.token?.trim()) {
|
||||
return globalpingNotConfiguredResult();
|
||||
}
|
||||
try {
|
||||
const result = await runGlobalpingMeasurement(target, options);
|
||||
return {
|
||||
ok: result.ok,
|
||||
latencyMs: result.latencyMs,
|
||||
error: result.error,
|
||||
colo: result.colo,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: err instanceof Error ? err.message : "Globalping: ошибка запроса",
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export function loadHealthProbeWorkerSource(): string {
|
||||
const dir = dirname(fileURLToPath(import.meta.url));
|
||||
const candidates = [
|
||||
join(dir, "health-probe-worker.mjs"),
|
||||
join(process.cwd(), "dist/health-probe-worker.mjs"),
|
||||
join(process.cwd(), "health-probe-worker.mjs"),
|
||||
join(dir, "../../../../../workers/health-probe/src/index.mjs"),
|
||||
join(process.cwd(), "../../workers/health-probe/src/index.mjs"),
|
||||
join(process.cwd(), "workers/health-probe/src/index.mjs"),
|
||||
];
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) {
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
}
|
||||
throw new Error("не найден исходник Worker health-probe");
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { getAppSettings, repos, updateAppSettings, type HealthEngineFallbacks } from "@cfdm/db";
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, targetHasProvider } from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../../lib/cf-client.js";
|
||||
import { AppError } from "../../errors.js";
|
||||
import { loadHealthProbeWorkerSource } from "./health-probe-script.js";
|
||||
import {
|
||||
buildTargetsDoc,
|
||||
createCloudflareKvMailbox,
|
||||
toCloudflareCron,
|
||||
type HealthMailbox,
|
||||
} from "./mailbox.js";
|
||||
|
||||
export const DEFAULT_HEALTH_FALLBACKS: HealthEngineFallbacks = {
|
||||
healthCheckCron: "0 */2 * * * *",
|
||||
healthDegradedFailures: 1,
|
||||
healthDownFailures: 2,
|
||||
healthLatencyWarnMs: 1000,
|
||||
healthSuccessRecoveries: 2,
|
||||
healthWorkerUrl: "",
|
||||
healthWorkerTokenSet: false,
|
||||
};
|
||||
|
||||
export async function resolveAccountId(
|
||||
cf: CloudflareClient,
|
||||
db: Db,
|
||||
cached?: string | null,
|
||||
): Promise<string> {
|
||||
const trimmed = cached?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
const domains = repos.listDomains(db);
|
||||
for (const domain of domains) {
|
||||
if (!domain.cf_zone_id) continue;
|
||||
try {
|
||||
const zone = await cf.getZone(domain.cf_zone_id);
|
||||
const id = zone.account?.id?.trim();
|
||||
if (id) return id;
|
||||
} catch {
|
||||
// try next zone / accounts list
|
||||
}
|
||||
}
|
||||
const accounts = await cf.listAccounts();
|
||||
const id = accounts[0]?.id?.trim();
|
||||
if (!id) {
|
||||
throw AppError.cloudflare(
|
||||
"Не удалось определить Cloudflare account_id. Добавьте зону или расширьте права токена (Account Settings Read).",
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function ensureKvNamespace(
|
||||
cf: CloudflareClient,
|
||||
accountId: string,
|
||||
existingId?: string | null,
|
||||
): Promise<string> {
|
||||
if (existingId?.trim()) return existingId.trim();
|
||||
const listed = await cf.listKvNamespaces(accountId);
|
||||
const found = listed.find((ns) => ns.title === HEALTH_PROBE_KV_TITLE);
|
||||
if (found?.id) return found.id;
|
||||
const created = await cf.createKvNamespace(accountId, HEALTH_PROBE_KV_TITLE);
|
||||
if (!created.id) {
|
||||
throw AppError.cloudflare("Cloudflare не вернул id KV namespace");
|
||||
}
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function ensureHealthWorker(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
fallbacks: HealthEngineFallbacks,
|
||||
): Promise<{ url: string; kvNamespaceId: string; accountId: string }> {
|
||||
const settings = getAppSettings(db, fallbacks);
|
||||
try {
|
||||
const accountId = await resolveAccountId(cf, db, settings.healthWorkerAccountId);
|
||||
const kvNamespaceId = await ensureKvNamespace(
|
||||
cf,
|
||||
accountId,
|
||||
settings.healthWorkerKvNamespaceId,
|
||||
);
|
||||
const source = loadHealthProbeWorkerSource();
|
||||
await cf.putWorkerScript({
|
||||
accountId,
|
||||
scriptName: HEALTH_PROBE_SCRIPT_NAME,
|
||||
source,
|
||||
kvNamespaceId,
|
||||
});
|
||||
await cf.putWorkerSchedules(accountId, HEALTH_PROBE_SCRIPT_NAME, [
|
||||
toCloudflareCron(settings.healthCheckCron),
|
||||
]);
|
||||
try {
|
||||
await cf.enableWorkersDev(accountId, HEALTH_PROBE_SCRIPT_NAME);
|
||||
} catch {
|
||||
// workers.dev may already be on
|
||||
}
|
||||
const subdomain = await cf.getWorkersSubdomain(accountId);
|
||||
const url = subdomain
|
||||
? `https://${HEALTH_PROBE_SCRIPT_NAME}.${subdomain}.workers.dev`
|
||||
: settings.healthWorkerUrl || `https://${HEALTH_PROBE_SCRIPT_NAME}.workers.dev`;
|
||||
|
||||
updateAppSettings(
|
||||
db,
|
||||
{
|
||||
healthWorkerAccountId: accountId,
|
||||
healthWorkerKvNamespaceId: kvNamespaceId,
|
||||
healthWorkerUrl: url,
|
||||
healthWorkerError: null,
|
||||
healthWorkerDeployedAt: new Date().toISOString(),
|
||||
},
|
||||
fallbacks,
|
||||
);
|
||||
|
||||
await syncCloudflareTargetsToKv(db, cf, fallbacks);
|
||||
return { url, kvNamespaceId, accountId };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
updateAppSettings(db, { healthWorkerError: message }, fallbacks);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function maybeEnsureHealthWorker(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
fallbacks: HealthEngineFallbacks,
|
||||
): Promise<void> {
|
||||
const hasCloudflare = repos
|
||||
.listHealthCheckTargets(db)
|
||||
.some((target) => targetHasProvider(target, "cloudflare"));
|
||||
if (!hasCloudflare) return;
|
||||
const settings = getAppSettings(db, fallbacks);
|
||||
if (settings.healthWorkerKvNamespaceId.trim() && !settings.healthWorkerError) {
|
||||
await syncCloudflareTargetsToKv(db, cf, fallbacks);
|
||||
return;
|
||||
}
|
||||
await ensureHealthWorker(db, cf, fallbacks);
|
||||
}
|
||||
|
||||
export function mailboxFromSettings(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
fallbacks: HealthEngineFallbacks,
|
||||
): HealthMailbox | null {
|
||||
const settings = getAppSettings(db, fallbacks);
|
||||
const accountId = settings.healthWorkerAccountId.trim();
|
||||
const ns = settings.healthWorkerKvNamespaceId.trim();
|
||||
if (!accountId || !ns) return null;
|
||||
return createCloudflareKvMailbox(cf, accountId, ns);
|
||||
}
|
||||
|
||||
export async function syncCloudflareTargetsToKv(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
fallbacks: HealthEngineFallbacks,
|
||||
mailbox?: HealthMailbox | null,
|
||||
): Promise<void> {
|
||||
const box = mailbox ?? mailboxFromSettings(db, cf, fallbacks);
|
||||
if (!box) return;
|
||||
const next = buildTargetsDoc(repos.listHealthCheckTargets(db));
|
||||
const current = await box.getTargets();
|
||||
if (current?.fingerprint === next.fingerprint) return;
|
||||
await box.putTargets(next);
|
||||
}
|
||||
|
||||
export function fireEnsureHealthWorker(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
fallbacks: HealthEngineFallbacks,
|
||||
log?: { warn: (obj: unknown, msg: string) => void },
|
||||
): void {
|
||||
if (process.env.VITEST) return;
|
||||
if (!cf.isConfigured) return;
|
||||
const hasCloudflare = repos
|
||||
.listHealthCheckTargets(db)
|
||||
.some((target) => targetHasProvider(target, "cloudflare"));
|
||||
if (!hasCloudflare) {
|
||||
void syncCloudflareTargetsToKv(db, cf, fallbacks).catch((err) => {
|
||||
log?.warn({ err }, "health worker KV sync failed");
|
||||
});
|
||||
return;
|
||||
}
|
||||
void maybeEnsureHealthWorker(db, cf, fallbacks).catch((err) => {
|
||||
log?.warn({ err }, "health worker ensure failed");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type {
|
||||
HealthCheckTarget,
|
||||
HealthProbeResultItem,
|
||||
HealthProbeResultsDoc,
|
||||
HealthProbeTargetItem,
|
||||
HealthProbeTargetsDoc,
|
||||
} from "@cfdm/shared";
|
||||
import { HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, targetHasProvider } from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../../lib/cf-client.js";
|
||||
|
||||
export interface HealthMailbox {
|
||||
getTargets(): Promise<HealthProbeTargetsDoc | null>;
|
||||
putTargets(doc: HealthProbeTargetsDoc): Promise<void>;
|
||||
getResults(): Promise<HealthProbeResultsDoc | null>;
|
||||
}
|
||||
|
||||
export function createCloudflareKvMailbox(
|
||||
cf: CloudflareClient,
|
||||
accountId: string,
|
||||
namespaceId: string,
|
||||
): HealthMailbox {
|
||||
return {
|
||||
async getTargets() {
|
||||
return readJson<HealthProbeTargetsDoc>(cf, accountId, namespaceId, HEALTH_KV_TARGETS_KEY);
|
||||
},
|
||||
async putTargets(doc) {
|
||||
await cf.kvPut(accountId, namespaceId, HEALTH_KV_TARGETS_KEY, JSON.stringify(doc));
|
||||
},
|
||||
async getResults() {
|
||||
return readJson<HealthProbeResultsDoc>(cf, accountId, namespaceId, HEALTH_KV_RESULTS_KEY);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readJson<T>(
|
||||
cf: CloudflareClient,
|
||||
accountId: string,
|
||||
namespaceId: string,
|
||||
key: string,
|
||||
): Promise<T | null> {
|
||||
const raw = await cf.kvGet(accountId, namespaceId, key);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function originProbeKey(target: HealthCheckTarget): string {
|
||||
const port = target.port ?? (target.type === "http" ? 80 : 80);
|
||||
const ip = String(target.ip || "").trim().toLowerCase();
|
||||
if (target.type === "http") {
|
||||
const path = (target.path?.trim() || "/") || "/";
|
||||
const expected = target.expected_status ?? "";
|
||||
return `http|${ip}|${port}|${path}|${expected}`;
|
||||
}
|
||||
if (target.type === "tcp") return `tcp|${ip}|${port}`;
|
||||
if (target.type === "ping") {
|
||||
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||
}
|
||||
if (target.type === "dns") {
|
||||
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||
}
|
||||
return `${target.type}|${ip}|${port}`;
|
||||
}
|
||||
|
||||
export function cloudflareMailboxTargets(
|
||||
targets: HealthCheckTarget[],
|
||||
): HealthProbeTargetItem[] {
|
||||
const unique = new Map<string, HealthProbeTargetItem>();
|
||||
for (const target of targets) {
|
||||
if (!targetHasProvider(target, "cloudflare")) continue;
|
||||
if (target.type !== "tcp" && target.type !== "http") continue;
|
||||
const key = originProbeKey(target);
|
||||
if (unique.has(key)) continue;
|
||||
unique.set(key, {
|
||||
key,
|
||||
ip: target.ip,
|
||||
hostname: target.hostname || target.ip,
|
||||
type: target.type,
|
||||
port: target.port ?? (target.type === "http" ? 80 : 80),
|
||||
path: target.path ?? "/",
|
||||
expectedStatus: target.expected_status,
|
||||
timeoutMs: target.timeout_ms ?? 3000,
|
||||
verifyTls: Boolean(target.verify_tls),
|
||||
});
|
||||
}
|
||||
return [...unique.values()].sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
export function fingerprintTargets(items: HealthProbeTargetItem[]): string {
|
||||
return items
|
||||
.map(
|
||||
(item) =>
|
||||
`${item.key}|${item.hostname}|${item.timeoutMs ?? ""}|${item.verifyTls ? "1" : "0"}`,
|
||||
)
|
||||
.join(";");
|
||||
}
|
||||
|
||||
export function buildTargetsDoc(targets: HealthCheckTarget[]): HealthProbeTargetsDoc {
|
||||
const items = cloudflareMailboxTargets(targets);
|
||||
return {
|
||||
fingerprint: fingerprintTargets(items),
|
||||
updatedAt: new Date().toISOString(),
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function indexResults(
|
||||
doc: HealthProbeResultsDoc | null,
|
||||
): Map<string, HealthProbeResultItem> {
|
||||
const map = new Map<string, HealthProbeResultItem>();
|
||||
if (!doc?.items) return map;
|
||||
for (const item of doc.items) {
|
||||
map.set(item.key, item);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function isResultsStale(doc: HealthProbeResultsDoc | null, staleAfterMs: number): boolean {
|
||||
if (!doc?.probedAt) return true;
|
||||
const ts = Date.parse(doc.probedAt);
|
||||
if (!Number.isFinite(ts)) return true;
|
||||
return Date.now() - ts > staleAfterMs;
|
||||
}
|
||||
|
||||
/** Drop seconds from toad 6-field cron for Cloudflare Workers (5-field). */
|
||||
export function toCloudflareCron(expr: string): string {
|
||||
const parts = expr.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 6) return parts.slice(1).join(" ");
|
||||
if (parts.length === 5) return parts.join(" ");
|
||||
throw new Error("некорректное cron-выражение");
|
||||
}
|
||||
|
||||
export function cronStaleAfterMs(expr: string): number {
|
||||
const cf = toCloudflareCron(expr);
|
||||
const minute = cf.split(/\s+/)[0] ?? "*";
|
||||
if (minute.startsWith("*/")) {
|
||||
const n = Number(minute.slice(2));
|
||||
if (Number.isFinite(n) && n > 0) return Math.max(n * 2, 5) * 60_000;
|
||||
}
|
||||
if (minute === "*") return 10 * 60_000;
|
||||
return 10 * 60_000;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function workerNotConfiguredResult(): {
|
||||
ok: false;
|
||||
latencyMs: number;
|
||||
error: string;
|
||||
colo: null;
|
||||
} {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: 0,
|
||||
error: "Cloudflare Worker не настроен (нет KV mailbox)",
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,10 @@ import type {
|
||||
import { AppError } from "../errors.js";
|
||||
import { isValidIpv4 } from "../lib/validators.js";
|
||||
import { getView } from "./service-config-service.js";
|
||||
import { selectActiveIpsByMode } from "./routing/index.js";
|
||||
import {
|
||||
isSharedPool,
|
||||
resolveDesiredAIps,
|
||||
} from "./routing/index.js";
|
||||
|
||||
function assertAddress(address: string): void {
|
||||
if (!isValidIpv4(address)) {
|
||||
@@ -89,8 +92,12 @@ export async function getOverview(
|
||||
: null;
|
||||
|
||||
const active = new Set<string>();
|
||||
const serviceIps = service.ips.filter(
|
||||
(ip) => service.ip_enabled[ip] !== false,
|
||||
);
|
||||
for (const binding of bindings) {
|
||||
const metas = repos.listBindingIpsWithMeta(db, binding.id);
|
||||
const targetIps = metas.map((entry) => entry.ip);
|
||||
const rows = metas.map((entry) => {
|
||||
const status = repos.getIpHealthStatusRow(db, "binding", binding.id, entry.ip);
|
||||
return {
|
||||
@@ -100,12 +107,15 @@ export async function getOverview(
|
||||
health: status ? status.status : ("unknown" as const),
|
||||
};
|
||||
});
|
||||
for (const ip of selectActiveIpsByMode(
|
||||
for (const ip of resolveDesiredAIps(
|
||||
{
|
||||
lb_mode: binding.lb_mode,
|
||||
health_check_enabled: binding.health_check_enabled,
|
||||
},
|
||||
rows,
|
||||
targetIps,
|
||||
Date.now(),
|
||||
serviceIps,
|
||||
)) {
|
||||
active.add(ip);
|
||||
}
|
||||
@@ -134,10 +144,13 @@ export function opsSummary(db: Db) {
|
||||
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";
|
||||
});
|
||||
return (
|
||||
isSharedPool(binding.target_ips) &&
|
||||
binding.target_ips.some((ip) => {
|
||||
const row = repos.getIpHealthStatusRow(db, "binding", binding.id, ip);
|
||||
return row?.status === "down";
|
||||
})
|
||||
);
|
||||
}).length;
|
||||
return {
|
||||
domains: domains.length,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { LbIpRow } from "./types.js";
|
||||
import { isHealthy } from "./health.js";
|
||||
import { isPoolMember } 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 live = rows.filter((r) => isPoolMember(r.health));
|
||||
const pool = live.length > 0 ? live : 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) {
|
||||
if (live.length > 0) {
|
||||
return primaries.map((r) => r.ip);
|
||||
}
|
||||
return [sorted[0]!.ip];
|
||||
|
||||
@@ -3,3 +3,12 @@ import type { IpHealthState, NodeHealthState } from "@cfdm/shared";
|
||||
export function isHealthy(state: IpHealthState | NodeHealthState | string): boolean {
|
||||
return state === "up" || state === "healthy";
|
||||
}
|
||||
|
||||
export function isDown(state: IpHealthState | NodeHealthState | string): boolean {
|
||||
return state === "down" || state === "unhealthy";
|
||||
}
|
||||
|
||||
/** A-pool membership: only Down is drained. Recovering (unknown/checking) and Slow return immediately. */
|
||||
export function isPoolMember(state: IpHealthState | NodeHealthState | string): boolean {
|
||||
return !isDown(state);
|
||||
}
|
||||
@@ -1,26 +1,55 @@
|
||||
import type { LbMode } from "@cfdm/shared";
|
||||
import { failoverDesired } from "./failover.js";
|
||||
import { canApplyLb, isSharedPool } from "./pool.js";
|
||||
import { roundRobinDesired } from "./round-robin.js";
|
||||
import type { LbIpRow, LbTargetConfig } from "./types.js";
|
||||
import { weightedDesired } from "./weighted.js";
|
||||
|
||||
export type { LbIpRow, LbTargetConfig } from "./types.js";
|
||||
export { isHealthy } from "./health.js";
|
||||
export { isDown, isHealthy, isPoolMember } from "./health.js";
|
||||
export { withBindingLock } from "./binding-lock.js";
|
||||
export {
|
||||
canApplyLb,
|
||||
isSharedPool,
|
||||
shouldRecordFailoverDnsDiff,
|
||||
uniqueIpCount,
|
||||
} from "./pool.js";
|
||||
export { WEIGHTED_DNS_TTL, WEIGHTED_SLOT_MS, weightedDesired } from "./weighted.js";
|
||||
|
||||
export function selectActiveIpsByMode(
|
||||
config: LbTargetConfig,
|
||||
rows: LbIpRow[],
|
||||
nowMs = Date.now(),
|
||||
): string[] {
|
||||
if (rows.length === 0) return [];
|
||||
if (config.lb_mode === "failover") {
|
||||
return failoverDesired(rows);
|
||||
}
|
||||
// weighted = round_robin on DNS (one A per IP)
|
||||
if (config.lb_mode === "weighted") {
|
||||
return weightedDesired(rows, nowMs);
|
||||
}
|
||||
return roundRobinDesired(rows);
|
||||
}
|
||||
|
||||
export function resolveDesiredAIps(
|
||||
config: LbTargetConfig,
|
||||
rows: LbIpRow[],
|
||||
fallbackIps: readonly string[],
|
||||
nowMs = Date.now(),
|
||||
serviceIps: readonly string[] = fallbackIps,
|
||||
): string[] {
|
||||
const fallback = [...fallbackIps];
|
||||
if (!canApplyLb(serviceIps, fallback)) return fallback;
|
||||
if (!isSharedPool(rows.map((row) => row.ip))) return fallback;
|
||||
if (config.lb_mode === "weighted" || config.health_check_enabled) {
|
||||
const activeIps = selectActiveIpsByMode(config, rows, nowMs);
|
||||
if (activeIps.length > 0) return activeIps;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function strategyLabel(mode: LbMode): string {
|
||||
if (mode === "failover") return "Failover";
|
||||
if (mode === "weighted") return "Round Robin (weighted alias)";
|
||||
if (mode === "weighted") return "Weighted";
|
||||
return "Round Robin";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { LbMode } from "@cfdm/shared";
|
||||
|
||||
export function uniqueIpCount(ips: readonly string[]): number {
|
||||
return new Set(ips.filter(Boolean)).size;
|
||||
}
|
||||
|
||||
/** Shared pool — two or more unique IPs. One IP (even duplicated) is not a pool. */
|
||||
export function isSharedPool(ips: readonly string[]): boolean {
|
||||
return uniqueIpCount(ips) >= 2;
|
||||
}
|
||||
|
||||
/** LB / drain only when the service itself has a pool AND this FQDN is shared. */
|
||||
export function canApplyLb(
|
||||
serviceIps: readonly string[],
|
||||
bindingIps: readonly string[],
|
||||
): boolean {
|
||||
return isSharedPool(serviceIps) && isSharedPool(bindingIps);
|
||||
}
|
||||
|
||||
export function shouldRecordFailoverDnsDiff(input: {
|
||||
configuredIps: readonly string[];
|
||||
lbMode: LbMode;
|
||||
added: readonly string[];
|
||||
removed: readonly string[];
|
||||
downIps: ReadonlySet<string>;
|
||||
}): boolean {
|
||||
if (!isSharedPool(input.configuredIps)) return false;
|
||||
if (input.lbMode !== "weighted") return true;
|
||||
return [...input.added, ...input.removed].some((ip) => input.downIps.has(ip));
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { LbIpRow } from "./types.js";
|
||||
import { isHealthy } from "./health.js";
|
||||
import { isPoolMember } from "./health.js";
|
||||
|
||||
export function roundRobinDesired(rows: LbIpRow[]): string[] {
|
||||
const healthy = rows.filter((r) => isHealthy(r.health));
|
||||
const pool = healthy.length > 0 ? healthy : rows;
|
||||
const live = rows.filter((r) => isPoolMember(r.health));
|
||||
const pool = live.length > 0 ? live : rows;
|
||||
return pool.map((r) => r.ip);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LbIpRow } from "./types.js";
|
||||
import { isPoolMember } from "./health.js";
|
||||
|
||||
/** Slot length for time-sliced weighted DNS (one A at a time). */
|
||||
export const WEIGHTED_SLOT_MS = 60_000;
|
||||
|
||||
/** Cloudflare DNS-only minimum TTL; Auto (1) is ~300s and would smear ratios. */
|
||||
export const WEIGHTED_DNS_TTL = 60;
|
||||
|
||||
export function weightedDesired(rows: LbIpRow[], nowMs = Date.now()): string[] {
|
||||
if (rows.length === 0) return [];
|
||||
const live = rows.filter((r) => isPoolMember(r.health));
|
||||
const pool = live.length > 0 ? live : rows;
|
||||
if (pool.length === 1) return [pool[0]!.ip];
|
||||
|
||||
const sorted = [...pool].sort((a, b) => a.ip.localeCompare(b.ip));
|
||||
const cycle: string[] = [];
|
||||
for (const row of sorted) {
|
||||
const weight = Math.max(1, Math.round(row.weight));
|
||||
for (let i = 0; i < weight; i++) cycle.push(row.ip);
|
||||
}
|
||||
const slot = Math.floor(nowMs / WEIGHTED_SLOT_MS) % cycle.length;
|
||||
return [cycle[slot]!];
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type {
|
||||
DnsRecord,
|
||||
HealthCheckAggregate,
|
||||
HealthCheckProvider,
|
||||
HealthCheckScope,
|
||||
HealthCheckType,
|
||||
IpHealthState,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
SYNC_PENDING_PUSH,
|
||||
SYNC_SYNCED,
|
||||
dnsRecordNamesMatch,
|
||||
isIpLiteral,
|
||||
normalizeDnsRecordName,
|
||||
} from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||
@@ -24,15 +27,93 @@ import { isValidIpv4 } from "../lib/validators.js";
|
||||
import * as dnsService from "./dns-service.js";
|
||||
import * as domainService from "./domain-service.js";
|
||||
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
||||
import { fireEnsureHealthWorker, DEFAULT_HEALTH_FALLBACKS } from "./health/health-worker-deploy.js";
|
||||
import {
|
||||
canApplyLb,
|
||||
isPoolMember,
|
||||
isSharedPool,
|
||||
resolveDesiredAIps,
|
||||
selectActiveIpsByMode,
|
||||
shouldRecordFailoverDnsDiff,
|
||||
withBindingLock,
|
||||
WEIGHTED_DNS_TTL,
|
||||
type LbIpRow,
|
||||
type LbTargetConfig,
|
||||
} from "./routing/index.js";
|
||||
|
||||
export type { LbIpRow, LbTargetConfig };
|
||||
export { selectActiveIpsByMode };
|
||||
export {
|
||||
canApplyLb,
|
||||
resolveDesiredAIps,
|
||||
selectActiveIpsByMode,
|
||||
shouldRecordFailoverDnsDiff,
|
||||
};
|
||||
|
||||
const AUTO_DNS_TTL = 1;
|
||||
|
||||
function ttlForBinding(mode: LbMode, ips: readonly string[]): number {
|
||||
return mode === "weighted" && isSharedPool(ips) ? WEIGHTED_DNS_TTL : AUTO_DNS_TTL;
|
||||
}
|
||||
|
||||
function enabledServiceIps(db: Db, serviceId: number): string[] {
|
||||
return repos
|
||||
.listServiceIpRows(db, serviceId)
|
||||
.filter((row) => row.enabled)
|
||||
.map((row) => row.ip);
|
||||
}
|
||||
|
||||
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);
|
||||
const configuredIps = repos.listBindingIps(db, bindingId);
|
||||
const { config, rows } = getBindingLbState(db, bindingId);
|
||||
const downIps = new Set(
|
||||
rows.filter((row) => row.health === "down").map((row) => row.ip),
|
||||
);
|
||||
if (
|
||||
!shouldRecordFailoverDnsDiff({
|
||||
configuredIps,
|
||||
lbMode: config.lb_mode,
|
||||
added,
|
||||
removed,
|
||||
downIps,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
fqdn: string;
|
||||
@@ -50,6 +131,9 @@ export interface ServiceDomainInput {
|
||||
health_check_interval_sec?: number;
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
export interface ToggleRequest {
|
||||
@@ -70,6 +154,9 @@ export interface ServiceGroupBody {
|
||||
health_check_interval_sec?: number;
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
export interface UpdateServiceGroupBody {
|
||||
@@ -86,6 +173,9 @@ export interface UpdateServiceGroupBody {
|
||||
health_check_interval_sec?: number;
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
export interface UpdateServiceConfigRequest {
|
||||
@@ -197,7 +287,7 @@ function getGroupLbState(
|
||||
} else {
|
||||
existing.weight += weight;
|
||||
existing.priority = Math.min(existing.priority, priority);
|
||||
if (isHealthy(existing.health) && status && !isHealthy(status.status as IpHealthState)) {
|
||||
if (isPoolMember(existing.health) && status && !isPoolMember(status.status as IpHealthState)) {
|
||||
existing.health = status.status as IpHealthState;
|
||||
}
|
||||
}
|
||||
@@ -213,16 +303,31 @@ function getGroupLbState(
|
||||
};
|
||||
}
|
||||
|
||||
function computeActiveIps(
|
||||
function desiredAIps(
|
||||
db: Db,
|
||||
scope: HealthCheckScope,
|
||||
refId: number,
|
||||
fallbackIps: string[],
|
||||
): string[] {
|
||||
const state =
|
||||
scope === "binding"
|
||||
? getBindingLbState(db, refId)
|
||||
: getGroupLbState(db, refId);
|
||||
return selectActiveIpsByMode(state.config, state.rows);
|
||||
// Configured binding/group IPs stay intact; DNS publishes only enabled ones.
|
||||
const enabledIps =
|
||||
scope === "binding"
|
||||
? enabledServiceIps(db, repos.getBinding(db, refId).service_id)
|
||||
: fallbackIps;
|
||||
const enabledSet = new Set(enabledIps);
|
||||
const activeFallback = fallbackIps.filter((ip) => enabledSet.has(ip));
|
||||
const activeRows = state.rows.filter((row) => enabledSet.has(row.ip));
|
||||
return resolveDesiredAIps(
|
||||
state.config,
|
||||
activeRows,
|
||||
activeFallback,
|
||||
Date.now(),
|
||||
enabledIps,
|
||||
);
|
||||
}
|
||||
|
||||
async function collectKnownZones(
|
||||
@@ -240,7 +345,38 @@ async function collectKnownZones(
|
||||
return zones;
|
||||
}
|
||||
|
||||
/** Restore common A-bindings whose IPs were shrunk by legacy IP toggles. */
|
||||
function repairPoolSubsetBindings(db: Db, serviceId: number): void {
|
||||
const pool = repos.listServiceIps(db, serviceId);
|
||||
if (pool.length < 2) return;
|
||||
const poolSet = new Set(pool);
|
||||
|
||||
for (const binding of repos.listBindingsByService(db, serviceId)) {
|
||||
if (binding.cname_target?.trim()) continue;
|
||||
const current = repos.listBindingIpsWithMeta(db, binding.id);
|
||||
if (current.length <= 1) continue;
|
||||
if (!current.every((entry) => poolSet.has(entry.ip))) continue;
|
||||
|
||||
const currentSet = new Set(current.map((entry) => entry.ip));
|
||||
if (currentSet.size === pool.length && pool.every((ip) => currentSet.has(ip))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const byIp = new Map(current.map((entry) => [entry.ip, entry]));
|
||||
repos.replaceBindingIpsWithMeta(
|
||||
db,
|
||||
binding.id,
|
||||
pool.map((ip) => ({
|
||||
ip,
|
||||
weight: byIp.get(ip)?.weight ?? 1,
|
||||
priority: byIp.get(ip)?.priority ?? 1,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
repairPoolSubsetBindings(db, serviceId);
|
||||
const service = repos.getService(db, serviceId);
|
||||
const ipRows = repos.listServiceIpRows(db, serviceId);
|
||||
const ips = ipRows.map((row) => row.ip);
|
||||
@@ -271,6 +407,11 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
if (target_ip_priorities[ip] === undefined) target_ip_priorities[ip] = 1;
|
||||
}
|
||||
|
||||
const { config, rows } = getBindingLbState(db, binding.id);
|
||||
const bindingActiveIps = targetCname
|
||||
? []
|
||||
: resolveDesiredAIps(config, rows, targetIps, Date.now(), ips);
|
||||
|
||||
return {
|
||||
binding_id: binding.id,
|
||||
domain_id: binding.domain_id,
|
||||
@@ -291,10 +432,24 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
health_check_interval_sec: binding.health_check_interval_sec,
|
||||
health_check_timeout_ms: binding.health_check_timeout_ms,
|
||||
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),
|
||||
active_ips: bindingActiveIps,
|
||||
};
|
||||
});
|
||||
|
||||
const activeIps = new Set<string>();
|
||||
for (const domain of domainViews) {
|
||||
for (const ip of domain.active_ips) {
|
||||
activeIps.add(ip);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
@@ -313,6 +468,109 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
health_status: "unknown",
|
||||
health_latency_ms: null,
|
||||
ip_health: [],
|
||||
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
|
||||
active_ips: [...activeIps],
|
||||
};
|
||||
}
|
||||
|
||||
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 overlayLiveHealth(
|
||||
stored: IpHealthState | undefined,
|
||||
live: IpHealthState | undefined,
|
||||
): IpHealthState {
|
||||
if (live && live !== "unknown") return live;
|
||||
return stored ?? live ?? "unknown";
|
||||
}
|
||||
|
||||
function bestAliveDisplayStatus(statuses: readonly string[]): IpHealthState {
|
||||
if (statuses.some((status) => status === "up")) return "up";
|
||||
if (statuses.some((status) => status === "degraded")) return "degraded";
|
||||
if (statuses.some((status) => status === "down")) return "down";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** Health badge applies only when the service, IP and HC (binding or group) are active. */
|
||||
function isServiceHealthCheckActive(db: Db, view: ServiceView): boolean {
|
||||
if ((view.domains ?? []).some((domain) => domain.health_check_enabled)) {
|
||||
return true;
|
||||
}
|
||||
if (!view.service_group_id) return false;
|
||||
const group = repos.getServiceGroup(db, view.service_group_id);
|
||||
return Boolean(group.health_check_enabled);
|
||||
}
|
||||
|
||||
function isIpHealthMonitored(db: Db, view: ServiceView, ip: string): boolean {
|
||||
if (!view.enabled) return false;
|
||||
if (view.ip_enabled[ip] === false) return false;
|
||||
return isServiceHealthCheckActive(db, view);
|
||||
}
|
||||
|
||||
function inactiveIpHealthRow(ip: string): ServiceHealthRow {
|
||||
return {
|
||||
ip,
|
||||
status: "unknown",
|
||||
latency_ms: null,
|
||||
last_checked_at: null,
|
||||
last_error: null,
|
||||
provider: "local",
|
||||
colo: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -323,23 +581,60 @@ function attachServiceHealth(
|
||||
const ids = views.map((v) => v.id);
|
||||
const healthByService = repos.aggregateIpHealthByServiceIds(db, ids);
|
||||
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
|
||||
const liveByService = repos.listLatestLiveHealthByServiceIds(db, ids);
|
||||
return views.map((view) => {
|
||||
const health = healthByService.get(view.id);
|
||||
const byIp = new Map(
|
||||
(ipHealthByService.get(view.id) ?? []).map((row) => [row.ip, row]),
|
||||
const rows = ipHealthByService.get(view.id) ?? [];
|
||||
const liveRows = liveByService.get(view.id) ?? [];
|
||||
const byIp = new Map(rows.map((row) => [row.ip, row]));
|
||||
const liveByIp = new Map(liveRows.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);
|
||||
if (!isIpHealthMonitored(db, view, ip)) {
|
||||
return inactiveIpHealthRow(ip);
|
||||
}
|
||||
const row = byIp.get(ip) ?? (aRecordIps.has(ip) ? undefined : cnameFallback);
|
||||
const live = liveByIp.get(ip);
|
||||
const status = overlayLiveHealth(row?.status, live?.status);
|
||||
const extras = live && live.status !== "unknown" ? live : row;
|
||||
return {
|
||||
ip,
|
||||
status: row?.status ?? ("unknown" as const),
|
||||
latency_ms: row?.latency_ms ?? null,
|
||||
status,
|
||||
latency_ms: extras?.latency_ms ?? null,
|
||||
last_checked_at: extras?.last_checked_at ?? null,
|
||||
last_error:
|
||||
live && live.status !== "unknown"
|
||||
? live.last_error
|
||||
: (row?.last_error ?? null),
|
||||
provider: extras?.provider ?? "local",
|
||||
colo: extras?.colo ?? null,
|
||||
};
|
||||
});
|
||||
const monitoredStatuses = ip_health
|
||||
.filter((row) => isIpHealthMonitored(db, view, row.ip))
|
||||
.map((row) => row.status);
|
||||
const displayStatus =
|
||||
monitoredStatuses.length > 0
|
||||
? bestAliveDisplayStatus(monitoredStatuses)
|
||||
: ("unknown" as const);
|
||||
const latencyRow =
|
||||
ip_health.find((row) => row.status === displayStatus && row.latency_ms != null) ??
|
||||
ip_health.find((row) => row.latency_ms != null);
|
||||
return {
|
||||
...view,
|
||||
health_status: health?.health_status ?? "unknown",
|
||||
health_latency_ms: health?.health_latency_ms ?? null,
|
||||
health_status:
|
||||
monitoredStatuses.length > 0
|
||||
? overlayLiveHealth(health?.health_status, displayStatus)
|
||||
: "unknown",
|
||||
health_latency_ms:
|
||||
monitoredStatuses.length > 0 && displayStatus !== "unknown"
|
||||
? (latencyRow?.latency_ms ?? null)
|
||||
: null,
|
||||
ip_health,
|
||||
};
|
||||
});
|
||||
@@ -442,26 +737,11 @@ async function syncBindingDns(
|
||||
desiredIps: string[],
|
||||
cnameTarget: string | null,
|
||||
): Promise<void> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
let effectiveCname = cnameTarget?.trim() || null;
|
||||
|
||||
if (!effectiveCname) {
|
||||
const existingCname = await findOrImportDnsRecord(
|
||||
db,
|
||||
cf,
|
||||
domainId,
|
||||
zoneName,
|
||||
hostname,
|
||||
"CNAME",
|
||||
);
|
||||
if (existingCname) {
|
||||
effectiveCname = existingCname.content;
|
||||
repos.setBindingCnameTarget(db, bindingId, effectiveCname);
|
||||
repos.replaceBindingIps(db, bindingId, []);
|
||||
}
|
||||
}
|
||||
const effectiveCname = cnameTarget?.trim() || null;
|
||||
|
||||
// Desired config wins. Do NOT auto-adopt leftover CNAME from local/CF when the
|
||||
// binding is A-mode — that wiped IPs and blocked extra FQDN publishes.
|
||||
// Docs: https://developers.cloudflare.com/dns/manage-dns-records/troubleshooting/records-with-same-name/
|
||||
if (effectiveCname) {
|
||||
await syncBindingCnameDns(
|
||||
db,
|
||||
@@ -474,7 +754,83 @@ async function syncBindingDns(
|
||||
return;
|
||||
}
|
||||
|
||||
await syncBindingADns(db, cf, bindingId, domainId, hostname, desiredIps);
|
||||
const binding = repos.getBinding(db, bindingId);
|
||||
const configuredIps = repos.listBindingIps(db, bindingId);
|
||||
await syncBindingADns(
|
||||
db,
|
||||
cf,
|
||||
bindingId,
|
||||
domainId,
|
||||
hostname,
|
||||
desiredIps,
|
||||
ttlForBinding(binding.lb_mode, configuredIps),
|
||||
);
|
||||
}
|
||||
|
||||
/** Delete every local+CF record for hostname that must not remain in A mode. */
|
||||
async function reconcileHostnameForA(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
domainId: number,
|
||||
zoneName: string,
|
||||
hostname: string,
|
||||
desiredIps: readonly string[],
|
||||
): Promise<void> {
|
||||
const desired = new Set(desiredIps);
|
||||
|
||||
// CNAME on the same name blocks creating A records in Cloudflare.
|
||||
for (;;) {
|
||||
const cname = await findOrImportDnsRecord(
|
||||
db,
|
||||
cf,
|
||||
domainId,
|
||||
zoneName,
|
||||
hostname,
|
||||
"CNAME",
|
||||
);
|
||||
if (!cname) break;
|
||||
await dnsService.deleteRecord(db, cf, domainId, cname.id);
|
||||
}
|
||||
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const staleLocal = repos
|
||||
.listDnsByDomain(db, domainId)
|
||||
.filter(
|
||||
(record) =>
|
||||
record.record_type.toUpperCase() === "A" &&
|
||||
dnsRecordNamesMatch(record.name, hostname, zoneName) &&
|
||||
!desired.has(record.content),
|
||||
);
|
||||
for (const record of staleLocal) {
|
||||
await dnsService.deleteRecord(db, cf, domainId, record.id);
|
||||
}
|
||||
|
||||
const remote = await cf.listDnsRecords(domain.cf_zone_id);
|
||||
for (const cfRec of remote) {
|
||||
if ((cfRec.type ?? "").toUpperCase() !== "A" || !cfRec.id) continue;
|
||||
if (!dnsRecordNamesMatch(cfRec.name, hostname, zoneName)) continue;
|
||||
if (desired.has(cfRec.content)) continue;
|
||||
|
||||
const existing = repos.findDnsByCfId(db, domainId, cfRec.id);
|
||||
if (existing) {
|
||||
await dnsService.deleteRecord(db, cf, domainId, existing.id);
|
||||
continue;
|
||||
}
|
||||
const imported = repos.insertDnsRecord(
|
||||
db,
|
||||
domainId,
|
||||
cfRec.type,
|
||||
cfRec.name,
|
||||
cfRec.content,
|
||||
cfRec.ttl,
|
||||
cfRec.proxied ?? false,
|
||||
cfRec.priority ?? null,
|
||||
SYNC_SYNCED,
|
||||
"cloudflare",
|
||||
cfRec.id,
|
||||
);
|
||||
await dnsService.deleteRecord(db, cf, domainId, imported.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncBindingCnameDns(
|
||||
@@ -488,6 +844,21 @@ async function syncBindingCnameDns(
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
const normalized = normalizeCnameTarget(cnameTarget, zoneName);
|
||||
|
||||
// A/AAAA on the same name blocks CNAME create in Cloudflare.
|
||||
for (;;) {
|
||||
const conflictingA = await findOrImportDnsRecord(
|
||||
db,
|
||||
cf,
|
||||
domainId,
|
||||
zoneName,
|
||||
hostname,
|
||||
"A",
|
||||
);
|
||||
if (!conflictingA) break;
|
||||
await dnsService.deleteRecord(db, cf, domainId, conflictingA.id);
|
||||
}
|
||||
|
||||
const existingRecords = repos.listRecordsForBinding(db, bindingId);
|
||||
|
||||
for (const record of existingRecords) {
|
||||
@@ -561,9 +932,14 @@ async function syncBindingADns(
|
||||
domainId: number,
|
||||
hostname: string,
|
||||
desiredIps: string[],
|
||||
ttl: number,
|
||||
): Promise<void> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
|
||||
// Align zone/local leftovers with desired A set (CNAME conflicts, stale A IPs).
|
||||
await reconcileHostnameForA(db, cf, domainId, zoneName, hostname, desiredIps);
|
||||
|
||||
const existingRecords = repos.listRecordsForBinding(db, bindingId);
|
||||
|
||||
for (const record of existingRecords) {
|
||||
@@ -580,6 +956,14 @@ async function syncBindingADns(
|
||||
|
||||
if (desiredIps.length === 0) {
|
||||
repos.setBindingDnsRecordId(db, bindingId, null);
|
||||
recordFailoverDnsDiff(
|
||||
db,
|
||||
bindingId,
|
||||
hostname,
|
||||
zoneName,
|
||||
existingRecords,
|
||||
desiredIps,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -588,13 +972,15 @@ async function syncBindingADns(
|
||||
|
||||
for (const ip of desiredIps) {
|
||||
const existing = refreshed.find((r) => r.content === ip);
|
||||
const recordName = dnsNameForBinding(hostname, zoneName);
|
||||
let recordId: number;
|
||||
if (existing) {
|
||||
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
|
||||
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
|
||||
await dnsService.update(db, cf, domainId, existing.id, {
|
||||
record_type: "A",
|
||||
name: dnsNameForBinding(hostname, zoneName),
|
||||
name: recordName,
|
||||
content: ip,
|
||||
ttl,
|
||||
proxied: false,
|
||||
});
|
||||
}
|
||||
@@ -612,12 +998,24 @@ async function syncBindingADns(
|
||||
if (adopted) {
|
||||
repos.linkBindingRecord(db, bindingId, adopted.id);
|
||||
recordId = adopted.id;
|
||||
if (
|
||||
!dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||
|
||||
adopted.ttl !== ttl
|
||||
) {
|
||||
await dnsService.update(db, cf, domainId, adopted.id, {
|
||||
record_type: "A",
|
||||
name: recordName,
|
||||
content: ip,
|
||||
ttl,
|
||||
proxied: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const record = await dnsService.create(db, cf, domainId, {
|
||||
record_type: "A",
|
||||
name: dnsNameForBinding(hostname, zoneName),
|
||||
name: recordName,
|
||||
content: ip,
|
||||
ttl: 1,
|
||||
ttl,
|
||||
proxied: false,
|
||||
});
|
||||
repos.linkBindingRecord(db, bindingId, record.id);
|
||||
@@ -628,6 +1026,14 @@ async function syncBindingADns(
|
||||
}
|
||||
|
||||
repos.setBindingDnsRecordId(db, bindingId, primaryId);
|
||||
recordFailoverDnsDiff(
|
||||
db,
|
||||
bindingId,
|
||||
hostname,
|
||||
zoneName,
|
||||
existingRecords,
|
||||
desiredIps,
|
||||
);
|
||||
}
|
||||
|
||||
async function cleanupBindingDns(
|
||||
@@ -846,12 +1252,7 @@ async function syncServiceBindingsToDns(
|
||||
}
|
||||
validateTargetIpsInPool(targetIps, ips);
|
||||
|
||||
if (binding.health_check_enabled) {
|
||||
const activeIps = computeActiveIps(db, "binding", binding.id);
|
||||
if (activeIps.length > 0) {
|
||||
targetIps = activeIps;
|
||||
}
|
||||
}
|
||||
const desiredIps = desiredAIps(db, "binding", binding.id, targetIps);
|
||||
|
||||
await syncBindingDns(
|
||||
db,
|
||||
@@ -859,7 +1260,7 @@ async function syncServiceBindingsToDns(
|
||||
binding.id,
|
||||
binding.domain_id,
|
||||
binding.hostname,
|
||||
targetIps,
|
||||
desiredIps,
|
||||
null,
|
||||
);
|
||||
}
|
||||
@@ -873,10 +1274,12 @@ async function collectGroupDnsIps(
|
||||
const ips: string[] = [];
|
||||
for (const service of services) {
|
||||
if (!service.enabled) continue;
|
||||
const enabled = new Set(enabledServiceIps(db, service.id));
|
||||
const bindings = repos.listBindingsByService(db, service.id);
|
||||
for (const binding of bindings) {
|
||||
for (const ip of repos.listBindingIps(db, binding.id)) {
|
||||
if (!ips.includes(ip)) ips.push(ip);
|
||||
if (!enabled.has(ip) || ips.includes(ip)) continue;
|
||||
ips.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -891,6 +1294,7 @@ async function syncGroupDomainDnsRecords(
|
||||
domainId: number,
|
||||
hostname: string,
|
||||
desiredIps: string[],
|
||||
ttl: number = AUTO_DNS_TTL,
|
||||
): Promise<void> {
|
||||
const domain = repos.getDomain(db, domainId);
|
||||
const zoneName = domain.zone_name;
|
||||
@@ -906,14 +1310,16 @@ async function syncGroupDomainDnsRecords(
|
||||
if (desiredIps.length === 0) return;
|
||||
|
||||
const refreshed = repos.listGroupDnsRecords(db, groupId);
|
||||
const recordName = dnsNameForBinding(hostname, zoneName);
|
||||
for (const ip of desiredIps) {
|
||||
const existing = refreshed.find((r) => r.content === ip);
|
||||
if (existing) {
|
||||
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName)) {
|
||||
if (!dnsRecordNamesMatch(existing.name, hostname, zoneName) || existing.ttl !== ttl) {
|
||||
await dnsService.update(db, cf, domainId, existing.id, {
|
||||
record_type: "A",
|
||||
name: dnsNameForBinding(hostname, zoneName),
|
||||
name: recordName,
|
||||
content: ip,
|
||||
ttl,
|
||||
proxied: false,
|
||||
});
|
||||
}
|
||||
@@ -929,13 +1335,25 @@ async function syncGroupDomainDnsRecords(
|
||||
);
|
||||
if (adopted) {
|
||||
repos.linkGroupDnsRecord(db, groupId, adopted.id);
|
||||
if (
|
||||
!dnsRecordNamesMatch(adopted.name, hostname, zoneName) ||
|
||||
adopted.ttl !== ttl
|
||||
) {
|
||||
await dnsService.update(db, cf, domainId, adopted.id, {
|
||||
record_type: "A",
|
||||
name: recordName,
|
||||
content: ip,
|
||||
ttl,
|
||||
proxied: false,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const record = await dnsService.create(db, cf, domainId, {
|
||||
record_type: "A",
|
||||
name: dnsNameForBinding(hostname, zoneName),
|
||||
name: recordName,
|
||||
content: ip,
|
||||
ttl: 1,
|
||||
ttl,
|
||||
proxied: false,
|
||||
});
|
||||
repos.linkGroupDnsRecord(db, groupId, record.id);
|
||||
@@ -986,9 +1404,8 @@ async function syncGroupDomainDns(
|
||||
const knownZones = await collectKnownZones(db, cf);
|
||||
const { zoneName, hostname } = parseFqdn(domainValue, knownZones);
|
||||
const domainId = await resolveDomainId(db, cf, zoneName);
|
||||
const desiredIps = group.health_check_enabled
|
||||
? computeActiveIps(db, "group", groupId)
|
||||
: await collectGroupDnsIps(db, groupId);
|
||||
const fallbackIps = await collectGroupDnsIps(db, groupId);
|
||||
const desiredIps = desiredAIps(db, "group", groupId, fallbackIps);
|
||||
await syncGroupDomainDnsRecords(
|
||||
db,
|
||||
cf,
|
||||
@@ -996,6 +1413,7 @@ async function syncGroupDomainDns(
|
||||
domainId,
|
||||
hostname,
|
||||
desiredIps,
|
||||
ttlForBinding(group.lb_mode, fallbackIps),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1154,7 +1572,10 @@ export async function updateConfig(
|
||||
input.health_check_expected_status !== undefined ||
|
||||
input.health_check_interval_sec !== 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, {
|
||||
lb_mode: input.lb_mode,
|
||||
@@ -1166,18 +1587,14 @@ export async function updateConfig(
|
||||
health_check_interval_sec: input.health_check_interval_sec,
|
||||
health_check_timeout_ms: input.health_check_timeout_ms,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
if (pushDns) {
|
||||
let effectiveIps = targetIps;
|
||||
const refreshedBinding = repos.getBinding(db, binding.id);
|
||||
if (refreshedBinding.health_check_enabled) {
|
||||
const activeIps = computeActiveIps(db, "binding", binding.id);
|
||||
if (activeIps.length > 0) {
|
||||
effectiveIps = activeIps;
|
||||
}
|
||||
}
|
||||
const effectiveIps = desiredAIps(db, "binding", binding.id, targetIps);
|
||||
await syncBindingDns(
|
||||
db,
|
||||
cf,
|
||||
@@ -1240,6 +1657,8 @@ export async function updateConfig(
|
||||
|
||||
void syncServiceToVpsTracker(db, id, removedBindingIds);
|
||||
|
||||
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||
|
||||
const [view] = attachServiceHealth(db, [await buildView(db, id)]);
|
||||
return view!;
|
||||
}
|
||||
@@ -1251,7 +1670,7 @@ export async function createGroup(
|
||||
): Promise<ServiceGroup> {
|
||||
const groupType = body.type?.trim() || "custom";
|
||||
const domain = await normalizeGroupDomain(db, cf, body.domain);
|
||||
return repos.createServiceGroup(
|
||||
const group = repos.createServiceGroup(
|
||||
db,
|
||||
body.name,
|
||||
groupType,
|
||||
@@ -1267,8 +1686,13 @@ export async function createGroup(
|
||||
health_check_interval_sec: body.health_check_interval_sec,
|
||||
health_check_timeout_ms: body.health_check_timeout_ms,
|
||||
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(
|
||||
@@ -1303,6 +1727,9 @@ export async function updateGroup(
|
||||
health_check_interval_sec: body.health_check_interval_sec,
|
||||
health_check_timeout_ms: body.health_check_timeout_ms,
|
||||
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) {
|
||||
@@ -1310,6 +1737,7 @@ export async function updateGroup(
|
||||
group = repos.getServiceGroup(db, id);
|
||||
}
|
||||
await syncEnabledServicesInGroup(db, cf, id);
|
||||
fireEnsureHealthWorker(db, cf, DEFAULT_HEALTH_FALLBACKS);
|
||||
return group;
|
||||
}
|
||||
|
||||
@@ -1372,26 +1800,8 @@ export async function toggleServiceIp(
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Keep binding IP membership stable (common FQDN = full pool). DNS sync
|
||||
// filters by enabledServiceIps via desiredAIps — do not reshuffle bindings.
|
||||
|
||||
const service = repos.getService(db, serviceId);
|
||||
if (shouldPushDns(db, service)) {
|
||||
@@ -1472,16 +1882,18 @@ export async function reconcileDnsForTarget(
|
||||
if (scope === "binding") {
|
||||
await withBindingLock(refId, async () => {
|
||||
const binding = repos.getBinding(db, refId);
|
||||
if (!binding.health_check_enabled) return;
|
||||
if (!binding.health_check_enabled && binding.lb_mode !== "weighted") return;
|
||||
const service = repos.getService(db, binding.service_id);
|
||||
if (!shouldPushDns(db, service)) return;
|
||||
const cnameTarget = binding.cname_target?.trim() || null;
|
||||
if (cnameTarget) return;
|
||||
const ips = repos.listServiceIps(db, service.id);
|
||||
const poolIps = enabledServiceIps(db, service.id);
|
||||
const targetIps = repos.listBindingIps(db, binding.id);
|
||||
validateTargetIpsInPool(targetIps, ips);
|
||||
const activeIps = computeActiveIps(db, "binding", refId);
|
||||
const desiredIps = activeIps.length > 0 ? activeIps : targetIps;
|
||||
const desiredIps = canApplyLb(poolIps, targetIps)
|
||||
? desiredAIps(db, "binding", refId, targetIps)
|
||||
: targetIps;
|
||||
await syncBindingDns(
|
||||
db,
|
||||
cf,
|
||||
@@ -1496,8 +1908,61 @@ export async function reconcileDnsForTarget(
|
||||
}
|
||||
|
||||
const group = repos.getServiceGroup(db, refId);
|
||||
if (!group.enabled || !group.domain?.trim() || !group.health_check_enabled) {
|
||||
if (!group.enabled || !group.domain?.trim()) {
|
||||
return;
|
||||
}
|
||||
if (!group.health_check_enabled && group.lb_mode !== "weighted") {
|
||||
return;
|
||||
}
|
||||
await syncGroupDomainDns(db, cf, refId);
|
||||
}
|
||||
|
||||
export async function reconcileWeightedDns(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
): Promise<number> {
|
||||
let n = 0;
|
||||
for (const binding of repos.listAllBindings(db)) {
|
||||
if (binding.lb_mode !== "weighted") continue;
|
||||
if (binding.cname_target?.trim()) continue;
|
||||
if (!isSharedPool(binding.target_ips ?? [])) continue;
|
||||
try {
|
||||
await withBindingLock(binding.id, async () => {
|
||||
const latest = repos.getBinding(db, binding.id);
|
||||
if (latest.lb_mode !== "weighted") return;
|
||||
if (latest.cname_target?.trim()) return;
|
||||
const service = repos.getService(db, latest.service_id);
|
||||
if (!shouldPushDns(db, service)) return;
|
||||
const targetIps = repos.listBindingIps(db, latest.id);
|
||||
const poolIps = enabledServiceIps(db, service.id);
|
||||
if (!canApplyLb(poolIps, targetIps)) return;
|
||||
const ips = repos.listServiceIps(db, service.id);
|
||||
validateTargetIpsInPool(targetIps, ips);
|
||||
const desiredIps = desiredAIps(db, "binding", latest.id, targetIps);
|
||||
await syncBindingDns(
|
||||
db,
|
||||
cf,
|
||||
latest.id,
|
||||
latest.domain_id,
|
||||
latest.hostname,
|
||||
desiredIps,
|
||||
null,
|
||||
);
|
||||
n += 1;
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const group of repos.listServiceGroups(db)) {
|
||||
if (group.lb_mode !== "weighted") continue;
|
||||
if (!group.enabled || !group.domain?.trim()) continue;
|
||||
try {
|
||||
await syncGroupDomainDns(db, cf, group.id);
|
||||
n += 1;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
@@ -1,8 +1,53 @@
|
||||
import { resolve4 } from "node:dns/promises";
|
||||
import type { CfdmBindingSyncItem, ServiceBindingView } from "@cfdm/shared";
|
||||
import type { CfdmBindingSyncItem, LbMode, ServiceBindingView } from "@cfdm/shared";
|
||||
import { isIpLiteral } from "@cfdm/shared";
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { repos, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db";
|
||||
import { isSharedPool } from "./routing/pool.js";
|
||||
|
||||
export function isLbMode(value: unknown): value is LbMode {
|
||||
return value === "round_robin" || value === "failover" || value === "weighted";
|
||||
}
|
||||
|
||||
/** lb_mode binding, иначе service group. */
|
||||
export function resolveLbModeForSync(
|
||||
bindingLbMode: string | undefined | null,
|
||||
groupLbMode?: string | null,
|
||||
): LbMode | undefined {
|
||||
if (isLbMode(bindingLbMode)) return bindingLbMode;
|
||||
if (isLbMode(groupLbMode)) return groupLbMode;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Effective HA only when the service has two or more unique origin IPs. */
|
||||
export function effectiveLbModeForSync(
|
||||
bindingLbMode: string | undefined | null,
|
||||
groupLbMode: string | undefined | null,
|
||||
serviceIps: readonly string[],
|
||||
): LbMode | undefined {
|
||||
if (!isSharedPool(serviceIps)) return undefined;
|
||||
return resolveLbModeForSync(bindingLbMode, groupLbMode);
|
||||
}
|
||||
|
||||
function groupLbModeForService(
|
||||
db: Db,
|
||||
serviceId: number,
|
||||
cache: Map<number, LbMode | undefined>,
|
||||
): LbMode | undefined {
|
||||
if (cache.has(serviceId)) return cache.get(serviceId);
|
||||
let mode: LbMode | undefined;
|
||||
try {
|
||||
const service = repos.getService(db, serviceId);
|
||||
if (service.service_group_id != null) {
|
||||
const group = repos.getServiceGroup(db, service.service_group_id);
|
||||
mode = resolveLbModeForSync(undefined, group.lb_mode);
|
||||
}
|
||||
} catch {
|
||||
mode = undefined;
|
||||
}
|
||||
cache.set(serviceId, mode);
|
||||
return mode;
|
||||
}
|
||||
|
||||
function fqdnToDisplay(hostname: string, zoneName: string): string {
|
||||
if (hostname === "@" || !hostname.trim()) return zoneName;
|
||||
@@ -126,10 +171,13 @@ export async function buildServiceSyncBindingsAsync(
|
||||
const allBindings = repos.listAllBindings(db);
|
||||
const index = buildBindingIndex(allBindings);
|
||||
const bindings = allBindings.filter((row) => row.service_id === serviceId);
|
||||
const groupLbCache = new Map<number, LbMode | undefined>();
|
||||
const groupLb = groupLbModeForService(db, serviceId, groupLbCache);
|
||||
|
||||
const items: CfdmBindingSyncItem[] = [];
|
||||
for (const binding of bindings) {
|
||||
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
|
||||
const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);
|
||||
items.push({
|
||||
bindingId: binding.id,
|
||||
serviceId: service.id,
|
||||
@@ -140,6 +188,7 @@ export async function buildServiceSyncBindingsAsync(
|
||||
hostname: binding.hostname,
|
||||
ips,
|
||||
cnameTarget: cnameTargetForSync(binding),
|
||||
...(lbMode ? { lbMode } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -166,6 +215,7 @@ export async function buildAllSyncBindings(
|
||||
const bindings = repos.listAllBindings(db);
|
||||
const index = buildBindingIndex(bindings);
|
||||
const serviceIpCache = new Map<number, string[]>();
|
||||
const groupLbCache = new Map<number, LbMode | undefined>();
|
||||
|
||||
const items: CfdmBindingSyncItem[] = [];
|
||||
for (const binding of bindings) {
|
||||
@@ -175,6 +225,8 @@ export async function buildAllSyncBindings(
|
||||
serviceIpCache.set(binding.service_id, serviceIps);
|
||||
}
|
||||
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
|
||||
const groupLb = groupLbModeForService(db, binding.service_id, groupLbCache);
|
||||
const lbMode = effectiveLbModeForSync(binding.lb_mode, groupLb, serviceIps);
|
||||
items.push({
|
||||
bindingId: binding.id,
|
||||
serviceId: binding.service_id,
|
||||
@@ -185,6 +237,7 @@ export async function buildAllSyncBindings(
|
||||
hostname: binding.hostname,
|
||||
ips,
|
||||
cnameTarget: cnameTargetForSync(binding),
|
||||
...(lbMode ? { lbMode } : {}),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { AsyncTask, SimpleIntervalJob } from "toad-scheduler";
|
||||
import * as serviceConfigService from "./service-config-service.js";
|
||||
import { WEIGHTED_SLOT_MS } from "./routing/weighted.js";
|
||||
|
||||
export const WEIGHTED_DNS_JOB_ID = "weighted-dns";
|
||||
|
||||
export function createWeightedDnsTask(app: FastifyInstance): AsyncTask {
|
||||
return new AsyncTask(
|
||||
WEIGHTED_DNS_JOB_ID,
|
||||
async () => {
|
||||
const n = await serviceConfigService.reconcileWeightedDns(app.db, app.cf);
|
||||
if (n > 0) {
|
||||
app.log.info({ reconciled: n }, "weighted dns rotated");
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
app.log.warn({ err }, "weighted dns rotate failed");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function scheduleWeightedDnsJob(
|
||||
app: FastifyInstance,
|
||||
task: AsyncTask,
|
||||
): void {
|
||||
const scheduler = app.scheduler;
|
||||
if (!scheduler) return;
|
||||
if (scheduler.existsById(WEIGHTED_DNS_JOB_ID)) {
|
||||
scheduler.removeById(WEIGHTED_DNS_JOB_ID);
|
||||
}
|
||||
scheduler.addSimpleIntervalJob(
|
||||
new SimpleIntervalJob(
|
||||
{ seconds: WEIGHTED_SLOT_MS / 1000, runImmediately: true },
|
||||
task,
|
||||
{ id: WEIGHTED_DNS_JOB_ID, preventOverrun: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -208,7 +208,7 @@ describe("certificates", () => {
|
||||
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({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
@@ -221,10 +221,18 @@ describe("certificates", () => {
|
||||
"required.example.com",
|
||||
"cf-zone-req",
|
||||
);
|
||||
repos.updateDomain(testApp.db, domain.id, {
|
||||
group_id: null,
|
||||
status: "active",
|
||||
const service = repos.createService(testApp.db, "Req", "req");
|
||||
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_REQUIRED,
|
||||
health_check_enabled: false,
|
||||
});
|
||||
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
@@ -247,7 +255,7 @@ describe("certificates", () => {
|
||||
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({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
@@ -260,6 +268,20 @@ describe("certificates", () => {
|
||||
"skipped.example.com",
|
||||
"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(
|
||||
testApp.db,
|
||||
domain.id,
|
||||
@@ -268,12 +290,8 @@ describe("certificates", () => {
|
||||
null,
|
||||
CERT_ERROR,
|
||||
"stale",
|
||||
service.id,
|
||||
);
|
||||
repos.updateDomain(testApp.db, domain.id, {
|
||||
group_id: null,
|
||||
status: "active",
|
||||
cert_monitoring: CERT_MONITOR_SKIPPED,
|
||||
});
|
||||
|
||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||
expiresAt: null,
|
||||
@@ -305,9 +323,16 @@ describe("certificates", () => {
|
||||
"broken.example.com",
|
||||
"cf-zone-broken",
|
||||
);
|
||||
repos.updateDomain(testApp.db, domain.id, {
|
||||
group_id: null,
|
||||
status: "active",
|
||||
const service = repos.createService(testApp.db, "Broken", "broken");
|
||||
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_REQUIRED,
|
||||
});
|
||||
|
||||
@@ -424,7 +449,7 @@ describe("certificates", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
await testApp.close();
|
||||
@@ -443,12 +468,7 @@ describe("certificates", () => {
|
||||
"force.example.com",
|
||||
"cf-zone-force",
|
||||
);
|
||||
repos.updateDomain(testApp.db, domain.id, {
|
||||
group_id: null,
|
||||
status: "active",
|
||||
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||
});
|
||||
repos.createServiceGroup(
|
||||
const group = repos.createServiceGroup(
|
||||
testApp.db,
|
||||
"Proxy",
|
||||
"vpn",
|
||||
@@ -459,6 +479,19 @@ describe("certificates", () => {
|
||||
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({
|
||||
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||
@@ -540,4 +573,137 @@ describe("certificates", () => {
|
||||
|
||||
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,220 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
|
||||
import { SYNC_SYNCED } from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||
import * as dnsService from "../src/services/dns-service.js";
|
||||
import { updateConfig } from "../src/services/service-config-service.js";
|
||||
|
||||
type CfRec = {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
proxied: boolean;
|
||||
};
|
||||
|
||||
function setupDb() {
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("DNS identity by name + content", () => {
|
||||
it("deletes by name+IP when cached cf_record_id is stale/missing in CF", async () => {
|
||||
const db = setupDb();
|
||||
const remote: CfRec[] = [
|
||||
{
|
||||
id: "cf-live",
|
||||
type: "A",
|
||||
name: "nsgt.example.com",
|
||||
content: "130.49.213.176",
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
},
|
||||
];
|
||||
const deleted: string[] = [];
|
||||
|
||||
const cf = {
|
||||
listDnsRecords: async () => [...remote],
|
||||
createDnsRecord: async () => {
|
||||
throw new Error("create should not run");
|
||||
},
|
||||
updateDnsRecord: async () => {
|
||||
throw new Error("update should not run");
|
||||
},
|
||||
deleteDnsRecord: async (_zoneId: string, id: string) => {
|
||||
deleted.push(id);
|
||||
const idx = remote.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) remote.splice(idx, 1);
|
||||
},
|
||||
verifyToken: async () => true,
|
||||
listZones: async () => [],
|
||||
} as unknown as CloudflareClient;
|
||||
|
||||
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||
const local = repos.insertDnsRecord(
|
||||
db,
|
||||
domain.id,
|
||||
"A",
|
||||
"nsgt.example.com",
|
||||
"130.49.213.176",
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
SYNC_SYNCED,
|
||||
"local",
|
||||
"cf-stale-gone", // not present in Cloudflare
|
||||
);
|
||||
|
||||
await dnsService.deleteRecord(db, cf, domain.id, local.id);
|
||||
|
||||
expect(deleted).toEqual(["cf-live"]);
|
||||
expect(repos.listDnsByDomain(db, domain.id)).toEqual([]);
|
||||
expect(remote).toEqual([]);
|
||||
});
|
||||
|
||||
it("delete is no-op success when record already removed outside CFDM", async () => {
|
||||
const db = setupDb();
|
||||
const cf = {
|
||||
listDnsRecords: async () => [],
|
||||
deleteDnsRecord: async () => {
|
||||
throw new Error("Record does not exist");
|
||||
},
|
||||
verifyToken: async () => true,
|
||||
listZones: async () => [],
|
||||
} as unknown as CloudflareClient;
|
||||
|
||||
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||
const local = repos.insertDnsRecord(
|
||||
db,
|
||||
domain.id,
|
||||
"A",
|
||||
"nsgt.example.com",
|
||||
"130.49.213.176",
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
SYNC_SYNCED,
|
||||
"local",
|
||||
"cf-already-gone",
|
||||
);
|
||||
|
||||
await expect(
|
||||
dnsService.deleteRecord(db, cf, domain.id, local.id),
|
||||
).resolves.toBeUndefined();
|
||||
expect(repos.listDnsByDomain(db, domain.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("updateConfig can remove extra FQDN when CF id is stale", async () => {
|
||||
const db = setupDb();
|
||||
const remote: CfRec[] = [
|
||||
{
|
||||
id: "cf-gt",
|
||||
type: "A",
|
||||
name: "gt.example.com",
|
||||
content: "130.49.213.176",
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
},
|
||||
{
|
||||
id: "cf-nsgt-live",
|
||||
type: "A",
|
||||
name: "nsgt.example.com",
|
||||
content: "130.49.213.176",
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
},
|
||||
];
|
||||
const deleted: string[] = [];
|
||||
|
||||
const cf = {
|
||||
listDnsRecords: async () => [...remote],
|
||||
createDnsRecord: async (
|
||||
_zoneId: string,
|
||||
payload: { type: string; name: string; content: string },
|
||||
) => {
|
||||
const rec: CfRec = {
|
||||
id: `cf-new-${remote.length}`,
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
content: payload.content,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
};
|
||||
remote.push(rec);
|
||||
return rec;
|
||||
},
|
||||
updateDnsRecord: async (
|
||||
_zoneId: string,
|
||||
id: string,
|
||||
payload: { type: string; name: string; content: string },
|
||||
) => {
|
||||
const idx = remote.findIndex((r) => r.id === id);
|
||||
if (idx < 0) throw new Error("Record does not exist");
|
||||
const rec: CfRec = {
|
||||
id,
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
content: payload.content,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
};
|
||||
remote[idx] = rec;
|
||||
return rec;
|
||||
},
|
||||
deleteDnsRecord: async (_zoneId: string, id: string) => {
|
||||
const idx = remote.findIndex((r) => r.id === id);
|
||||
if (idx < 0) throw new Error("Record does not exist");
|
||||
deleted.push(id);
|
||||
remote.splice(idx, 1);
|
||||
},
|
||||
verifyToken: async () => true,
|
||||
listZones: async () => [
|
||||
{ id: "zone-1", name: "example.com", status: "active" },
|
||||
],
|
||||
} as unknown as CloudflareClient;
|
||||
|
||||
repos.createDomain(db, null, "example.com", "zone-1");
|
||||
const service = repos.createService(db, "Main TG", "tg-pr");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
|
||||
await updateConfig(db, cf, service.id, {
|
||||
ips: ["130.49.213.176"],
|
||||
domains: [
|
||||
{ fqdn: "gt.example.com", target_ips: ["130.49.213.176"] },
|
||||
{ fqdn: "nsgt.example.com", target_ips: ["130.49.213.176"] },
|
||||
],
|
||||
});
|
||||
|
||||
// Poison cached id on extra binding's DNS row.
|
||||
const nsgtBinding = repos
|
||||
.listBindingsByService(db, service.id)
|
||||
.find((b) => b.hostname === "nsgt")!;
|
||||
const nsgtRecords = repos.listRecordsForBinding(db, nsgtBinding.id);
|
||||
for (const row of nsgtRecords) {
|
||||
repos.updateDnsFields(
|
||||
db,
|
||||
row.id,
|
||||
row.record_type,
|
||||
row.name,
|
||||
row.content,
|
||||
row.ttl,
|
||||
row.proxied,
|
||||
row.priority,
|
||||
SYNC_SYNCED,
|
||||
"cf-stale-nsgt",
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
const view = await updateConfig(db, cf, service.id, {
|
||||
ips: ["130.49.213.176"],
|
||||
domains: [{ fqdn: "gt.example.com", target_ips: ["130.49.213.176"] }],
|
||||
});
|
||||
|
||||
expect(view.domains.map((d) => d.fqdn)).toEqual(["gt.example.com"]);
|
||||
expect(deleted).toContain("cf-nsgt-live");
|
||||
expect(remote.some((r) => r.name.includes("nsgt"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
|
||||
import { SYNC_SYNCED } from "@cfdm/shared";
|
||||
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||
import { updateConfig } from "../src/services/service-config-service.js";
|
||||
|
||||
type CfRec = {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
proxied: boolean;
|
||||
};
|
||||
|
||||
function setupDb() {
|
||||
const { db, sqlite } = createMemoryDb();
|
||||
runMigrations(sqlite);
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("DNS reconcile for extra FQDN", () => {
|
||||
it("publishes extra A even when local/CF already have CNAME on same name", async () => {
|
||||
const db = setupDb();
|
||||
const remote: CfRec[] = [
|
||||
{
|
||||
id: "cf-cname-nsgt",
|
||||
type: "CNAME",
|
||||
name: "nsgt.example.com",
|
||||
content: "legacy.example.com",
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
},
|
||||
];
|
||||
const created: Array<{ type: string; name: string; content: string }> = [];
|
||||
const deleted: string[] = [];
|
||||
|
||||
const cf = {
|
||||
listDnsRecords: async () => remote,
|
||||
createDnsRecord: async (
|
||||
_zoneId: string,
|
||||
payload: { type: string; name: string; content: string },
|
||||
) => {
|
||||
created.push(payload);
|
||||
const rec: CfRec = {
|
||||
id: `cf-new-${created.length}`,
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
content: payload.content,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
};
|
||||
remote.push(rec);
|
||||
return rec;
|
||||
},
|
||||
updateDnsRecord: async (
|
||||
_zoneId: string,
|
||||
id: string,
|
||||
payload: { type: string; name: string; content: string },
|
||||
) => {
|
||||
const idx = remote.findIndex((r) => r.id === id);
|
||||
const rec: CfRec = {
|
||||
id,
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
content: payload.content,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
};
|
||||
if (idx >= 0) remote[idx] = rec;
|
||||
else remote.push(rec);
|
||||
return rec;
|
||||
},
|
||||
deleteDnsRecord: async (_zoneId: string, id: string) => {
|
||||
deleted.push(id);
|
||||
const idx = remote.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) remote.splice(idx, 1);
|
||||
},
|
||||
verifyToken: async () => true,
|
||||
listZones: async () => [
|
||||
{ id: "zone-1", name: "example.com", status: "active" },
|
||||
],
|
||||
} as unknown as CloudflareClient;
|
||||
|
||||
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||
// Leftover local CNAME (previous service / manual import).
|
||||
repos.insertDnsRecord(
|
||||
db,
|
||||
domain.id,
|
||||
"CNAME",
|
||||
"nsgt.example.com",
|
||||
"legacy.example.com",
|
||||
1,
|
||||
false,
|
||||
null,
|
||||
SYNC_SYNCED,
|
||||
"cloudflare",
|
||||
"cf-cname-nsgt",
|
||||
);
|
||||
|
||||
const service = repos.createService(db, "MSK Hip", "tg-msk-hip");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
|
||||
const view = await updateConfig(db, cf, service.id, {
|
||||
ips: ["130.49.213.176"],
|
||||
domains: [
|
||||
{
|
||||
fqdn: "gt.example.com",
|
||||
target_ips: ["130.49.213.176"],
|
||||
},
|
||||
{
|
||||
fqdn: "nsgt.example.com",
|
||||
target_ips: ["130.49.213.176"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const nsgt = view.domains.find((d) => d.fqdn === "nsgt.example.com");
|
||||
expect(nsgt?.record_type).toBe("A");
|
||||
expect(nsgt?.target_ips).toEqual(["130.49.213.176"]);
|
||||
expect(nsgt?.target_cname).toBeFalsy();
|
||||
|
||||
const binding = repos
|
||||
.listBindingsByService(db, service.id)
|
||||
.find((b) => b.hostname === "nsgt")!;
|
||||
expect(repos.listBindingIps(db, binding.id)).toEqual(["130.49.213.176"]);
|
||||
expect(deleted).toContain("cf-cname-nsgt");
|
||||
expect(
|
||||
created.some(
|
||||
(r) =>
|
||||
r.type === "A" &&
|
||||
(r.name === "nsgt" || r.name === "nsgt.example.com") &&
|
||||
r.content === "130.49.213.176",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(remote.some((r) => r.type === "CNAME" && r.name.includes("nsgt"))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
remote.some(
|
||||
(r) =>
|
||||
r.type === "A" &&
|
||||
r.name.includes("nsgt") &&
|
||||
r.content === "130.49.213.176",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces stale A content for extra FQDN on the same hostname", async () => {
|
||||
const db = setupDb();
|
||||
const remote: CfRec[] = [
|
||||
{
|
||||
id: "cf-stale-a",
|
||||
type: "A",
|
||||
name: "nsgt.example.com",
|
||||
content: "1.1.1.1",
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
},
|
||||
];
|
||||
const deleted: string[] = [];
|
||||
const created: Array<{ type: string; name: string; content: string }> = [];
|
||||
|
||||
const cf = {
|
||||
listDnsRecords: async () => [...remote],
|
||||
createDnsRecord: async (
|
||||
_zoneId: string,
|
||||
payload: { type: string; name: string; content: string },
|
||||
) => {
|
||||
created.push(payload);
|
||||
const rec: CfRec = {
|
||||
id: `cf-new-${created.length}`,
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
content: payload.content,
|
||||
ttl: 1,
|
||||
proxied: false,
|
||||
};
|
||||
remote.push(rec);
|
||||
return rec;
|
||||
},
|
||||
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,
|
||||
}),
|
||||
deleteDnsRecord: async (_zoneId: string, id: string) => {
|
||||
deleted.push(id);
|
||||
const idx = remote.findIndex((r) => r.id === id);
|
||||
if (idx >= 0) remote.splice(idx, 1);
|
||||
},
|
||||
verifyToken: async () => true,
|
||||
listZones: async () => [
|
||||
{ id: "zone-1", name: "example.com", status: "active" },
|
||||
],
|
||||
} as unknown as CloudflareClient;
|
||||
|
||||
repos.createDomain(db, null, "example.com", "zone-1");
|
||||
const service = repos.createService(db, "MSK", "msk");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
|
||||
await updateConfig(db, cf, service.id, {
|
||||
ips: ["130.49.213.176"],
|
||||
domains: [
|
||||
{ fqdn: "nsgt.example.com", target_ips: ["130.49.213.176"] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(deleted).toContain("cf-stale-a");
|
||||
expect(
|
||||
created.some(
|
||||
(r) => r.type === "A" && r.content === "130.49.213.176",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(remote.map((r) => r.content)).toEqual(["130.49.213.176"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
timeout_ms: 1000,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
};
|
||||
const result = await healthCheckService.probeTarget(target);
|
||||
expect(result.ok).toBe(true);
|
||||
@@ -75,6 +76,7 @@ describe("health-check probeTarget", () => {
|
||||
expected_status: null,
|
||||
timeout_ms: 500,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
};
|
||||
const result = await healthCheckService.probeTarget(target);
|
||||
expect(result.ok).toBe(false);
|
||||
@@ -107,6 +109,7 @@ describe("health-check probeTarget", () => {
|
||||
expected_status: 200,
|
||||
timeout_ms: 1000,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
};
|
||||
const bindingTarget: HealthCheckTarget = {
|
||||
...groupTarget,
|
||||
@@ -142,6 +145,7 @@ describe("health-check probeTarget", () => {
|
||||
expected_status: null,
|
||||
timeout_ms: 3000,
|
||||
verify_tls: false,
|
||||
provider: "local",
|
||||
};
|
||||
const binding: HealthCheckTarget = {
|
||||
...group,
|
||||
@@ -231,4 +235,320 @@ describe("health-check state derivation via runAllChecks", () => {
|
||||
expect(targets).toHaveLength(1);
|
||||
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.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||
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.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||
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");
|
||||
});
|
||||
|
||||
it("getView shows live OK over hysteresis unknown", 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 Panel", "rw-panel");
|
||||
repos.replaceServiceIps(db, service.id, ["2.59.161.102"]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "c", null);
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
{ ip: "2.59.161.102", weight: 1, priority: 1 },
|
||||
]);
|
||||
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"2.59.161.102",
|
||||
"unknown",
|
||||
63,
|
||||
0,
|
||||
null,
|
||||
1,
|
||||
);
|
||||
repos.insertHealthProbeLog(db, {
|
||||
scope: "binding",
|
||||
refId: binding.id,
|
||||
ip: "2.59.161.102",
|
||||
provider: "local",
|
||||
status: "up",
|
||||
ok: true,
|
||||
latencyMs: 63,
|
||||
colo: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const view = await getView(db, service.id);
|
||||
expect(view.health_status).toBe("up");
|
||||
expect(view.ip_health[0]?.status).toBe("up");
|
||||
expect(view.ip_health[0]?.latency_ms).toBe(63);
|
||||
});
|
||||
|
||||
it("getView masks stale down when health-check is disabled", 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, "Main TG", "main-tg");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
repos.replaceServiceIps(db, service.id, ["130.49.213.176"]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
{ ip: "130.49.213.176", weight: 1, priority: 1 },
|
||||
]);
|
||||
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: false });
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"130.49.213.176",
|
||||
"down",
|
||||
null,
|
||||
5,
|
||||
"timeout",
|
||||
);
|
||||
|
||||
const view = await getView(db, service.id);
|
||||
expect(view.health_status).toBe("unknown");
|
||||
expect(view.ip_health).toEqual([
|
||||
expect.objectContaining({
|
||||
ip: "130.49.213.176",
|
||||
status: "unknown",
|
||||
latency_ms: null,
|
||||
last_error: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("getView masks stale down when IP is disabled in pool", 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, "Main TG", "main-tg");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
repos.replaceServiceIps(db, service.id, ["130.49.213.176"]);
|
||||
repos.setServiceIpEnabled(db, service.id, "130.49.213.176", false);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
{ ip: "130.49.213.176", weight: 1, priority: 1 },
|
||||
]);
|
||||
repos.updateBindingLbConfig(db, binding.id, { health_check_enabled: true });
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"130.49.213.176",
|
||||
"down",
|
||||
null,
|
||||
5,
|
||||
"timeout",
|
||||
);
|
||||
|
||||
const view = await getView(db, service.id);
|
||||
expect(view.health_status).toBe("unknown");
|
||||
expect(view.ip_health[0]?.status).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { repos, type Db } from "@cfdm/db";
|
||||
import * as healthCheckService from "../src/services/health-check-service.js";
|
||||
import {
|
||||
buildMeasurementBody,
|
||||
summarizeMeasurement,
|
||||
} from "../src/lib/globalping-client.js";
|
||||
import { aggregateHealthOk } from "@cfdm/shared";
|
||||
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||
|
||||
const thresholds = {
|
||||
degradedFailures: 1,
|
||||
downFailures: 2,
|
||||
latencyWarnMs: 1000,
|
||||
successRecoveries: 2,
|
||||
};
|
||||
|
||||
function tcpTarget(overrides?: Partial<HealthCheckTarget>): HealthCheckTarget {
|
||||
return {
|
||||
scope: "binding",
|
||||
ref_id: 1,
|
||||
ip: "203.0.113.10",
|
||||
hostname: "panel.example.com",
|
||||
type: "tcp",
|
||||
port: 443,
|
||||
path: null,
|
||||
expected_status: null,
|
||||
timeout_ms: 400,
|
||||
verify_tls: false,
|
||||
provider: "globalping",
|
||||
providers: ["globalping"],
|
||||
aggregate: "majority",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function seedBinding(
|
||||
db: Db,
|
||||
opts: {
|
||||
ip: string;
|
||||
providers: Array<"local" | "cloudflare" | "globalping">;
|
||||
aggregate?: "any" | "all" | "majority";
|
||||
port?: number;
|
||||
},
|
||||
) {
|
||||
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||
const service = repos.createService(db, "Panel", "panel");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
repos.replaceServiceIps(db, service.id, [opts.ip]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "panel", null);
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
{ ip: opts.ip, weight: 1, priority: 1 },
|
||||
]);
|
||||
repos.updateBindingLbConfig(db, binding.id, {
|
||||
health_check_enabled: true,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: opts.port ?? 1,
|
||||
health_check_timeout_ms: 400,
|
||||
health_check_providers: opts.providers,
|
||||
health_check_aggregate: opts.aggregate ?? "majority",
|
||||
});
|
||||
return { service, binding, domain };
|
||||
}
|
||||
|
||||
function mockFetch(handler: (url: string, init?: RequestInit) => Response): typeof fetch {
|
||||
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
return handler(url, init);
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
describe("aggregateHealthOk", () => {
|
||||
it("any / all / majority", () => {
|
||||
expect(aggregateHealthOk([true, false], "any")).toBe(false);
|
||||
expect(aggregateHealthOk([true, false], "all")).toBe(true);
|
||||
expect(aggregateHealthOk([true, false], "majority")).toBe(true);
|
||||
expect(aggregateHealthOk([false, false], "majority")).toBe(false);
|
||||
expect(aggregateHealthOk([true, false, false], "majority")).toBe(false);
|
||||
expect(aggregateHealthOk([true, true, false], "majority")).toBe(true);
|
||||
expect(aggregateHealthOk([true], "majority")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("globalping mapping", () => {
|
||||
it("maps CFDM TCP to ping+TCP and HTTP to http+host", () => {
|
||||
const tcp = buildMeasurementBody(tcpTarget(), { limit: 3, locations: "World" });
|
||||
expect(tcp.type).toBe("ping");
|
||||
expect(tcp.measurementOptions.protocol).toBe("TCP");
|
||||
expect(tcp.measurementOptions.port).toBe(443);
|
||||
expect(tcp.inProgressUpdates).toBe(false);
|
||||
|
||||
const http = buildMeasurementBody(
|
||||
tcpTarget({ type: "http", port: 443, path: "/health", expected_status: 200 }),
|
||||
{ limit: 2, locations: "EU,US" },
|
||||
);
|
||||
expect(http.type).toBe("http");
|
||||
expect(http.locations).toEqual([{ magic: "EU" }, { magic: "US" }]);
|
||||
expect(http.measurementOptions.request).toMatchObject({
|
||||
host: "panel.example.com",
|
||||
path: "/health",
|
||||
method: "GET",
|
||||
});
|
||||
});
|
||||
|
||||
it("summarizes HTTP majority and TCP packet loss", () => {
|
||||
const httpOk = summarizeMeasurement(
|
||||
tcpTarget({ type: "http", expected_status: 200 }),
|
||||
{
|
||||
status: "finished",
|
||||
results: [
|
||||
{ probe: { city: "Frankfurt", country: "DE" }, result: { status: "finished", statusCode: 200, timings: { total: 40 } } },
|
||||
{ probe: { city: "London", country: "GB" }, result: { status: "finished", statusCode: 200, timings: { total: 50 } } },
|
||||
{ probe: { city: "Paris", country: "FR" }, result: { status: "finished", statusCode: 500, timings: { total: 20 } } },
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(httpOk.ok).toBe(true);
|
||||
expect(httpOk.colo).toBe("Frankfurt, DE");
|
||||
|
||||
const tcpFail = summarizeMeasurement(tcpTarget(), {
|
||||
status: "finished",
|
||||
results: [
|
||||
{ result: { status: "finished", stats: { avg: 12, loss: 100 } } },
|
||||
{ result: { status: "finished", stats: { avg: 11, loss: 100 } } },
|
||||
],
|
||||
});
|
||||
expect(tcpFail.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("globalping engine", () => {
|
||||
it("POST 202 + GET finished writes colo from probe city", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const { binding } = await seedBinding(app.db, {
|
||||
ip: "203.0.113.40",
|
||||
providers: ["globalping"],
|
||||
port: 443,
|
||||
});
|
||||
const fetchImpl = mockFetch((url) => {
|
||||
if (url.endsWith("/v1/measurements")) {
|
||||
return new Response(JSON.stringify({ id: "meas-1" }), { status: 202 });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "meas-1",
|
||||
status: "finished",
|
||||
results: [
|
||||
{
|
||||
probe: { city: "Amsterdam", country: "NL" },
|
||||
result: { status: "finished", stats: { avg: 18, loss: 0 } },
|
||||
},
|
||||
{
|
||||
probe: { city: "Frankfurt", country: "DE" },
|
||||
result: { status: "finished", stats: { avg: 22, loss: 0 } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: 0,
|
||||
globalping: {
|
||||
token: "gp_test",
|
||||
locations: "World",
|
||||
limit: 2,
|
||||
pollIntervalMs: 0,
|
||||
fetchImpl,
|
||||
},
|
||||
});
|
||||
const row = repos.getIpHealthStatusRow(
|
||||
app.db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"203.0.113.40",
|
||||
);
|
||||
expect(row?.status).toBe("up");
|
||||
expect(row?.provider).toBe("globalping");
|
||||
expect(row?.colo).toMatch(/Amsterdam/);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("429 fails the source and does not fall back to local", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const { binding } = await seedBinding(app.db, {
|
||||
ip: "127.0.0.1",
|
||||
providers: ["globalping"],
|
||||
port: 1,
|
||||
});
|
||||
const fetchImpl = mockFetch(() => new Response("rate limited", { status: 429 }));
|
||||
await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: 0,
|
||||
globalping: {
|
||||
token: "gp_test",
|
||||
locations: "World",
|
||||
limit: 1,
|
||||
pollIntervalMs: 0,
|
||||
fetchImpl,
|
||||
},
|
||||
});
|
||||
const row = repos.getIpHealthStatusRow(
|
||||
app.db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"127.0.0.1",
|
||||
);
|
||||
expect(row?.last_error).toMatch(/429/i);
|
||||
expect(row?.provider).toBe("globalping");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("local+globalping all keeps IP up if Globalping is ok", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const { binding, service } = await seedBinding(app.db, {
|
||||
ip: "127.0.0.1",
|
||||
providers: ["local", "globalping"],
|
||||
aggregate: "all",
|
||||
port: 1,
|
||||
});
|
||||
const fetchImpl = mockFetch((url) => {
|
||||
if (url.endsWith("/v1/measurements")) {
|
||||
return new Response(JSON.stringify({ id: "meas-2" }), { status: 202 });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
status: "finished",
|
||||
results: [
|
||||
{ probe: { city: "Vienna", country: "AT" }, result: { status: "finished", stats: { avg: 9, loss: 0 } } },
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: 0,
|
||||
globalping: {
|
||||
token: "gp_test",
|
||||
locations: "World",
|
||||
limit: 1,
|
||||
pollIntervalMs: 0,
|
||||
fetchImpl,
|
||||
},
|
||||
});
|
||||
const row = repos.getIpHealthStatusRow(
|
||||
app.db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"127.0.0.1",
|
||||
);
|
||||
expect(row?.status).toBe("up");
|
||||
expect(row?.provider).toBe("aggregate");
|
||||
const logs = repos.listHealthProbeLogForService(app.db, service.id);
|
||||
expect(logs.map((row) => row.provider).sort()).toEqual([
|
||||
"globalping",
|
||||
"local",
|
||||
]);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { toCloudflareCron } from "../src/services/health/mailbox.js";
|
||||
import { HEALTH_PROBE_SCRIPT_NAME } from "@cfdm/shared";
|
||||
|
||||
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/auth/login",
|
||||
payload: { username: "admin", password: "admin" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { token } = res.json() as { token: string };
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
function jsonOk(result: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify({ success: true, result }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("health worker deploy", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("maps 6-field toad cron to 5-field Cloudflare cron", () => {
|
||||
expect(toCloudflareCron("0 */2 * * * *")).toBe("*/2 * * * *");
|
||||
expect(toCloudflareCron("*/5 * * * *")).toBe("*/5 * * * *");
|
||||
});
|
||||
|
||||
it("POST ensure creates KV+script; 403 is not local fallback", async () => {
|
||||
const calls: string[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url =
|
||||
typeof input === "string" || input instanceof URL
|
||||
? String(input)
|
||||
: input.url;
|
||||
const method = (
|
||||
init?.method ??
|
||||
(typeof Request !== "undefined" && input instanceof Request
|
||||
? input.method
|
||||
: "GET")
|
||||
).toUpperCase();
|
||||
calls.push(`${method} ${url}`);
|
||||
if (
|
||||
(url.includes("/accounts?") || /\/accounts$/.test(url.split("?")[0] ?? "")) &&
|
||||
!url.includes("/storage/") &&
|
||||
!url.includes("/workers/")
|
||||
) {
|
||||
return jsonOk([{ id: "acc-1", name: "Test" }]);
|
||||
}
|
||||
if (url.includes("/storage/kv/namespaces") && method === "GET" && !url.includes("/values/")) {
|
||||
return jsonOk([]);
|
||||
}
|
||||
if (url.includes("/storage/kv/namespaces") && method === "POST") {
|
||||
return jsonOk({ id: "kv-1", title: "cfdm-health-probe" });
|
||||
}
|
||||
if (
|
||||
url.includes(`/workers/scripts/${HEALTH_PROBE_SCRIPT_NAME}`) &&
|
||||
method === "PUT" &&
|
||||
!url.includes("/schedules")
|
||||
) {
|
||||
return jsonOk({ id: "script-1" });
|
||||
}
|
||||
if (url.includes("/schedules") && method === "PUT") {
|
||||
return jsonOk([{ cron: "*/2 * * * *" }]);
|
||||
}
|
||||
if (url.includes("/subdomain") && method === "POST") {
|
||||
return jsonOk({ enabled: true });
|
||||
}
|
||||
if (url.includes("/workers/subdomain") && method === "GET") {
|
||||
return jsonOk({ subdomain: "example" });
|
||||
}
|
||||
if (url.includes("/values/") && method === "GET") {
|
||||
return new Response("null", { status: 404 });
|
||||
}
|
||||
if (url.includes("/values/") && method === "PUT") {
|
||||
return jsonOk(null);
|
||||
}
|
||||
return jsonOk({});
|
||||
},
|
||||
);
|
||||
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null, cloudflareApiToken: "cf-token" },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/health/worker/ensure",
|
||||
headers,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as {
|
||||
healthWorkerStatus: string;
|
||||
healthWorkerUrl: string;
|
||||
healthWorkerKvNamespaceId: string;
|
||||
healthWorkerError: string | null;
|
||||
};
|
||||
expect(body.healthWorkerStatus).toBe("ready");
|
||||
expect(body.healthWorkerKvNamespaceId).toBe("kv-1");
|
||||
expect(body.healthWorkerUrl).toContain("cfdm-health-probe.example.workers.dev");
|
||||
expect(body.healthWorkerError).toBeNull();
|
||||
expect(calls.some((c) => c.includes("/workers/scripts/"))).toBe(true);
|
||||
await app.close();
|
||||
}, 20_000);
|
||||
|
||||
it("POST ensure 403 stores error, does not probe as local", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/storage/kv/namespaces")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
errors: [{ code: 10000, message: "Authentication error" }],
|
||||
}),
|
||||
{ status: 403, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url.includes("/accounts")) {
|
||||
return jsonOk([{ id: "acc-1" }]);
|
||||
}
|
||||
return jsonOk({});
|
||||
},
|
||||
);
|
||||
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null, cloudflareApiToken: "zone-only" },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/health/worker/ensure",
|
||||
headers,
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
const again = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers,
|
||||
});
|
||||
const body = again.json() as {
|
||||
healthWorkerStatus: string;
|
||||
healthWorkerError: string | null;
|
||||
};
|
||||
expect(body.healthWorkerStatus).toBe("error");
|
||||
expect(body.healthWorkerError).toMatch(/Workers Scripts Write|токен/i);
|
||||
await app.close();
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { repos, type Db } from "@cfdm/db";
|
||||
import * as healthCheckService from "../src/services/health-check-service.js";
|
||||
import type { HealthMailbox } from "../src/services/health/mailbox.js";
|
||||
import { originProbeKey } from "../src/services/health/mailbox.js";
|
||||
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||
|
||||
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/auth/login",
|
||||
payload: { username: "admin", password: "admin" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const { token } = res.json() as { token: string };
|
||||
return { authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
async function seedBinding(
|
||||
db: Db,
|
||||
opts: { provider: "local" | "cloudflare"; ip: string },
|
||||
) {
|
||||
const domain = repos.createDomain(db, null, "example.com", "zone-1");
|
||||
const service = repos.createService(db, "Panel", "panel");
|
||||
repos.setServiceEnabled(db, service.id, true);
|
||||
repos.replaceServiceIps(db, service.id, [opts.ip]);
|
||||
const binding = repos.insertBinding(db, domain.id, service.id, "panel", null);
|
||||
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||
{ ip: opts.ip, weight: 1, priority: 1 },
|
||||
]);
|
||||
repos.updateBindingLbConfig(db, binding.id, {
|
||||
health_check_enabled: true,
|
||||
health_check_type: "tcp",
|
||||
health_check_port: 1,
|
||||
health_check_timeout_ms: 400,
|
||||
health_check_provider: opts.provider,
|
||||
});
|
||||
return { service, binding, domain };
|
||||
}
|
||||
|
||||
const thresholds = {
|
||||
degradedFailures: 1,
|
||||
downFailures: 2,
|
||||
latencyWarnMs: 1000,
|
||||
successRecoveries: 2,
|
||||
};
|
||||
|
||||
function memoryMailbox(opts?: {
|
||||
resultsOk?: boolean;
|
||||
colo?: string;
|
||||
probedAt?: string;
|
||||
}): HealthMailbox {
|
||||
let targets: unknown = null;
|
||||
return {
|
||||
async getTargets() {
|
||||
return targets as never;
|
||||
},
|
||||
async putTargets(doc) {
|
||||
targets = doc;
|
||||
},
|
||||
async getResults() {
|
||||
if (!opts) return null;
|
||||
const dummy: HealthCheckTarget = {
|
||||
scope: "binding",
|
||||
ref_id: 1,
|
||||
ip: "203.0.113.10",
|
||||
hostname: "panel.example.com",
|
||||
type: "tcp",
|
||||
port: 1,
|
||||
path: null,
|
||||
expected_status: null,
|
||||
timeout_ms: 400,
|
||||
verify_tls: false,
|
||||
provider: "cloudflare",
|
||||
};
|
||||
return {
|
||||
probedAt: opts.probedAt ?? new Date().toISOString(),
|
||||
colo: opts.colo ?? "AMS",
|
||||
items: [
|
||||
{
|
||||
key: originProbeKey(dummy),
|
||||
ok: opts.resultsOk !== false,
|
||||
latencyMs: 42,
|
||||
error: opts.resultsOk === false ? "down" : null,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("health-check XOR worker mailbox", () => {
|
||||
it("lists only local providers when no cloudflare bindings", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
await seedBinding(app.db, {
|
||||
provider: "local",
|
||||
ip: "10.0.0.1",
|
||||
});
|
||||
const targets = repos.listHealthCheckTargets(app.db);
|
||||
expect(targets.length).toBeGreaterThan(0);
|
||||
expect(targets.every((t) => t.provider === "local")).toBe(true);
|
||||
expect(targets.some((t) => t.provider === "cloudflare")).toBe(false);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("cloudflare without mailbox does not fall back to local", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const { binding } = await seedBinding(app.db, {
|
||||
provider: "cloudflare",
|
||||
ip: "127.0.0.1",
|
||||
});
|
||||
await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: 0,
|
||||
mailbox: null,
|
||||
});
|
||||
const row = repos.getIpHealthStatusRow(
|
||||
app.db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"127.0.0.1",
|
||||
);
|
||||
expect(row?.last_error).toMatch(/Worker не настроен/i);
|
||||
expect(row?.provider).toBe("cloudflare");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("KV results write colo and last_checked_at without HTTP /probe", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const headers = await authHeaders(app);
|
||||
const { service, binding } = await seedBinding(app.db, {
|
||||
provider: "cloudflare",
|
||||
ip: "203.0.113.10",
|
||||
});
|
||||
const targets = repos.listHealthCheckTargets(app.db);
|
||||
const cfTarget = targets.find((t) => t.ip === "203.0.113.10")!;
|
||||
const mailbox: HealthMailbox = {
|
||||
async getTargets() {
|
||||
return null;
|
||||
},
|
||||
async putTargets() {
|
||||
/* fingerprint sync */
|
||||
},
|
||||
async getResults() {
|
||||
return {
|
||||
probedAt: new Date().toISOString(),
|
||||
colo: "AMS",
|
||||
items: [
|
||||
{
|
||||
key: originProbeKey(cfTarget),
|
||||
ok: true,
|
||||
latencyMs: 42,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: 0,
|
||||
mailbox,
|
||||
});
|
||||
const row = repos.getIpHealthStatusRow(
|
||||
app.db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"203.0.113.10",
|
||||
);
|
||||
expect(row?.status).toBe("up");
|
||||
expect(row?.colo).toBe("AMS");
|
||||
expect(row?.last_checked_at).toBeTruthy();
|
||||
expect(row?.provider).toBe("cloudflare");
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/services/${service.id}`,
|
||||
headers,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json() as {
|
||||
ip_health: Array<{
|
||||
ip: string;
|
||||
colo: string | null;
|
||||
last_checked_at: string | null;
|
||||
provider: string;
|
||||
}>;
|
||||
};
|
||||
const ipRow = body.ip_health.find((item) => item.ip === "203.0.113.10");
|
||||
expect(ipRow?.colo).toBe("AMS");
|
||||
expect(ipRow?.provider).toBe("cloudflare");
|
||||
|
||||
const logRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/services/${service.id}/health-log`,
|
||||
headers,
|
||||
});
|
||||
expect(logRes.statusCode).toBe(200);
|
||||
const logBody = logRes.json() as { items: Array<{ colo: string | null }> };
|
||||
expect(logBody.items[0]?.colo).toBe("AMS");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("stale KV results are recorded, not local probe", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
});
|
||||
const { binding } = await seedBinding(app.db, {
|
||||
provider: "cloudflare",
|
||||
ip: "203.0.113.20",
|
||||
});
|
||||
await healthCheckService.runAllChecks(app.db, {
|
||||
thresholds,
|
||||
probeGapMs: 0,
|
||||
mailbox: memoryMailbox({
|
||||
resultsOk: true,
|
||||
colo: "SIN",
|
||||
probedAt: new Date(Date.now() - 60 * 60_000).toISOString(),
|
||||
}),
|
||||
staleAfterMs: 60_000,
|
||||
});
|
||||
const row = repos.getIpHealthStatusRow(
|
||||
app.db,
|
||||
"binding",
|
||||
binding.id,
|
||||
"203.0.113.20",
|
||||
);
|
||||
expect(row?.last_error).toMatch(/устарели|KV/i);
|
||||
expect(row?.provider).toBe("cloudflare");
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveDesiredAIps,
|
||||
selectActiveIpsByMode,
|
||||
shouldRecordFailoverDnsDiff,
|
||||
type LbIpRow,
|
||||
type LbTargetConfig,
|
||||
} from "../src/services/service-config-service.js";
|
||||
import { WEIGHTED_SLOT_MS } from "../src/services/routing/weighted.js";
|
||||
|
||||
function row(
|
||||
ip: string,
|
||||
@@ -17,6 +20,11 @@ function row(
|
||||
};
|
||||
}
|
||||
|
||||
const weightedConfig: LbTargetConfig = {
|
||||
lb_mode: "weighted",
|
||||
health_check_enabled: true,
|
||||
};
|
||||
|
||||
describe("selectActiveIpsByMode", () => {
|
||||
it("round_robin returns all healthy ips, falls back to all if none healthy", () => {
|
||||
const config: LbTargetConfig = {
|
||||
@@ -62,6 +70,18 @@ describe("selectActiveIpsByMode", () => {
|
||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("failover returns recovering unknown primary immediately", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "failover",
|
||||
health_check_enabled: true,
|
||||
};
|
||||
const rows = [
|
||||
row("1.1.1.1", { priority: 1, health: "unknown" }),
|
||||
row("2.2.2.2", { priority: 2, health: "up" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("failover falls back to min-priority ip among all when none healthy", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "failover",
|
||||
@@ -75,23 +95,61 @@ describe("selectActiveIpsByMode", () => {
|
||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["2.2.2.2"]);
|
||||
});
|
||||
|
||||
it("weighted returns all healthy ips (one A per ip; weights stored for display)", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "weighted",
|
||||
health_check_enabled: true,
|
||||
};
|
||||
it("weighted 1:3 picks the lighter ip on slot 0 and the heavier on slot 1", () => {
|
||||
const rows = [
|
||||
row("1.1.1.1", { weight: 3, health: "up" }),
|
||||
row("2.2.2.2", { weight: 1, health: "up" }),
|
||||
row("3.3.3.3", { weight: 2, health: "down" }),
|
||||
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||
row("2.2.2.2", { weight: 3, health: "up" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
||||
"1.1.1.1",
|
||||
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
|
||||
expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([
|
||||
"2.2.2.2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("round_robin excludes unknown when another ip is up", () => {
|
||||
it("weighted excludes down ips from the cycle", () => {
|
||||
const rows = [
|
||||
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||
row("2.2.2.2", { weight: 3, health: "down" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
|
||||
expect(
|
||||
selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS),
|
||||
).toEqual(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("weighted includes recovering unknown in the cycle", () => {
|
||||
const rows = [
|
||||
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||
row("2.2.2.2", { weight: 3, health: "unknown" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
|
||||
expect(
|
||||
selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS),
|
||||
).toEqual(["2.2.2.2"]);
|
||||
});
|
||||
|
||||
it("weighted with one ip always returns that ip", () => {
|
||||
expect(
|
||||
selectActiveIpsByMode(weightedConfig, [row("1.1.1.1", { weight: 5 })], 0),
|
||||
).toEqual(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("weighted with all unknown rotates across every ip", () => {
|
||||
const rows = [
|
||||
row("1.1.1.1", { weight: 1, health: "unknown" }),
|
||||
row("2.2.2.2", { weight: 3, health: "unknown" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(weightedConfig, rows, 0)).toEqual(["1.1.1.1"]);
|
||||
expect(selectActiveIpsByMode(weightedConfig, rows, WEIGHTED_SLOT_MS)).toEqual([
|
||||
"2.2.2.2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("weighted returns empty array for no rows", () => {
|
||||
expect(selectActiveIpsByMode(weightedConfig, [], 0)).toEqual([]);
|
||||
});
|
||||
|
||||
it("round_robin puts recovering unknown back with live ips", () => {
|
||||
const config: LbTargetConfig = {
|
||||
lb_mode: "round_robin",
|
||||
health_check_enabled: true,
|
||||
@@ -100,7 +158,25 @@ describe("selectActiveIpsByMode", () => {
|
||||
row("1.1.1.1", { health: "up" }),
|
||||
row("2.2.2.2", { health: "unknown" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(config, rows)).toEqual(["1.1.1.1"]);
|
||||
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
||||
"1.1.1.1",
|
||||
"2.2.2.2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("round_robin keeps degraded in the pool with live ips", () => {
|
||||
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: "degraded" }),
|
||||
];
|
||||
expect(selectActiveIpsByMode(config, rows).sort()).toEqual([
|
||||
"1.1.1.1",
|
||||
"2.2.2.2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array for no rows", () => {
|
||||
@@ -111,3 +187,115 @@ describe("selectActiveIpsByMode", () => {
|
||||
expect(selectActiveIpsByMode(config, [])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDesiredAIps", () => {
|
||||
it("keeps a dedicated single IP even when down", () => {
|
||||
expect(
|
||||
resolveDesiredAIps(
|
||||
weightedConfig,
|
||||
[row("1.1.1.1", { health: "down" })],
|
||||
["1.1.1.1"],
|
||||
),
|
||||
).toEqual(["1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("does not drain when the service has only one unique IP", () => {
|
||||
expect(
|
||||
resolveDesiredAIps(
|
||||
weightedConfig,
|
||||
[row("1.1.1.1", { health: "down" })],
|
||||
["1.1.1.1", "1.1.1.1"],
|
||||
0,
|
||||
["1.1.1.1"],
|
||||
),
|
||||
).toEqual(["1.1.1.1", "1.1.1.1"]);
|
||||
});
|
||||
|
||||
it("does not apply overlay when service pool is a single IP", () => {
|
||||
const rows = [
|
||||
row("1.1.1.1", { health: "up" }),
|
||||
row("2.2.2.2", { health: "down" }),
|
||||
];
|
||||
expect(
|
||||
resolveDesiredAIps(
|
||||
weightedConfig,
|
||||
rows,
|
||||
["1.1.1.1", "2.2.2.2"],
|
||||
0,
|
||||
["1.1.1.1"],
|
||||
),
|
||||
).toEqual(["1.1.1.1", "2.2.2.2"]);
|
||||
});
|
||||
|
||||
it("applies weighted overlay on a shared pool", () => {
|
||||
const rows = [
|
||||
row("1.1.1.1", { weight: 1, health: "up" }),
|
||||
row("2.2.2.2", { weight: 3, health: "up" }),
|
||||
];
|
||||
expect(
|
||||
resolveDesiredAIps(weightedConfig, rows, ["1.1.1.1", "2.2.2.2"], 0),
|
||||
).toEqual(selectActiveIpsByMode(weightedConfig, rows, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRecordFailoverDnsDiff", () => {
|
||||
it("skips duplicate listings of the same IP", () => {
|
||||
expect(
|
||||
shouldRecordFailoverDnsDiff({
|
||||
configuredIps: ["1.1.1.1", "1.1.1.1"],
|
||||
lbMode: "failover",
|
||||
added: [],
|
||||
removed: ["1.1.1.1"],
|
||||
downIps: new Set(["1.1.1.1"]),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("skips dedicated extra-FQDN", () => {
|
||||
expect(
|
||||
shouldRecordFailoverDnsDiff({
|
||||
configuredIps: ["1.1.1.1"],
|
||||
lbMode: "failover",
|
||||
added: [],
|
||||
removed: ["1.1.1.1"],
|
||||
downIps: new Set(["1.1.1.1"]),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("skips weighted live-to-live slot swap", () => {
|
||||
expect(
|
||||
shouldRecordFailoverDnsDiff({
|
||||
configuredIps: ["1.1.1.1", "2.2.2.2"],
|
||||
lbMode: "weighted",
|
||||
added: ["2.2.2.2"],
|
||||
removed: ["1.1.1.1"],
|
||||
downIps: new Set(),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("logs weighted swap when a down ip leaves the pool", () => {
|
||||
expect(
|
||||
shouldRecordFailoverDnsDiff({
|
||||
configuredIps: ["1.1.1.1", "2.2.2.2"],
|
||||
lbMode: "weighted",
|
||||
added: ["2.2.2.2"],
|
||||
removed: ["1.1.1.1"],
|
||||
downIps: new Set(["1.1.1.1"]),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("logs failover diffs on a shared pool", () => {
|
||||
expect(
|
||||
shouldRecordFailoverDnsDiff({
|
||||
configuredIps: ["1.1.1.1", "2.2.2.2"],
|
||||
lbMode: "failover",
|
||||
added: ["2.2.2.2"],
|
||||
removed: ["1.1.1.1"],
|
||||
downIps: new Set(["1.1.1.1"]),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -78,4 +78,53 @@ describe("service bindings prune", () => {
|
||||
expect(bindings).toHaveLength(2);
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -99,7 +99,15 @@ describe("service groups health enrichment", () => {
|
||||
expect(groupView!.services[0]?.health_status).toBe("degraded");
|
||||
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 },
|
||||
{
|
||||
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)
|
||||
expect(groupView!.health_status).toBe("degraded");
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { serviceGroupsResponseSchema } from "@cfdm/shared";
|
||||
import { serviceGroupsResponseSchema, updateServiceConfigSchema } from "@cfdm/shared";
|
||||
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 {
|
||||
listGroupViews,
|
||||
toggleServiceIp,
|
||||
updateConfig,
|
||||
} from "../src/services/service-config-service.js";
|
||||
|
||||
@@ -51,6 +52,26 @@ async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
@@ -123,11 +144,75 @@ describe("create service then list groups", () => {
|
||||
expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
httpParsed.data!.groups
|
||||
.find((g) => g.id === group.id)
|
||||
?.services.some((s) => s.id === created.id),
|
||||
).toBe(true);
|
||||
const listed = httpParsed.data!.groups
|
||||
.find((g) => g.id === group.id)
|
||||
?.services.find((s) => s.id === created.id);
|
||||
expect(listed).toBeDefined();
|
||||
expect(listed?.lb_mode).toBe("round_robin");
|
||||
expect(listed?.active_ips).toEqual(["1.2.3.4"]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -166,7 +251,7 @@ describe("create service then list groups", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("PATCH /services/:id/ips/toggle keeps IP in pool and removes it from A-binding", async () => {
|
||||
it("PATCH /services/:id/ips/toggle keeps IP in pool and in A-binding", async () => {
|
||||
const app = await buildApp({
|
||||
config: { ...loadConfig(), staticDir: null },
|
||||
memory: true,
|
||||
@@ -196,6 +281,18 @@ describe("create service then list groups", () => {
|
||||
expect(createRes.statusCode).toBe(200);
|
||||
const created = createRes.json() as { id: number };
|
||||
|
||||
const domainPayload = {
|
||||
lb_mode: "round_robin" as const,
|
||||
health_check_enabled: false,
|
||||
health_check_type: "tcp" as const,
|
||||
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,
|
||||
};
|
||||
|
||||
await updateConfig(app.db, cf, created.id, {
|
||||
ips: ["1.2.3.4", "5.6.7.8"],
|
||||
service_group_id: group.id,
|
||||
@@ -205,23 +302,81 @@ describe("create service then list groups", () => {
|
||||
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,
|
||||
...domainPayload,
|
||||
},
|
||||
{
|
||||
fqdn: "extra.example.com",
|
||||
target_ips: ["1.2.3.4"],
|
||||
target_ip_weights: { "1.2.3.4": 1 },
|
||||
target_ip_priorities: { "1.2.3.4": 1 },
|
||||
...domainPayload,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// 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 commonBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "panel")!;
|
||||
const extraBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "extra")!;
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, commonBinding.id)
|
||||
.map((r) => r.content)
|
||||
.sort(),
|
||||
).toEqual(["1.2.3.4", "5.6.7.8"]);
|
||||
|
||||
// Direct service call with mock CF — keep HTTP path free of real Cloudflare.
|
||||
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", false);
|
||||
|
||||
expect(repos.listServiceIps(app.db, created.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(
|
||||
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
|
||||
?.enabled,
|
||||
).toBe(false);
|
||||
// Common + per-IP bindings keep configured IPs (UI hydrate stays stable).
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
|
||||
// DNS for common FQDN drops the disabled IP only.
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, commonBinding.id)
|
||||
.map((r) => r.content)
|
||||
.sort(),
|
||||
).toEqual(["5.6.7.8"]);
|
||||
// Per-IP extra FQDN has no enabled targets → A records removed.
|
||||
expect(repos.listRecordsForBinding(app.db, extraBinding.id)).toEqual([]);
|
||||
|
||||
await toggleServiceIp(app.db, cf, created.id, "1.2.3.4", true);
|
||||
expect(
|
||||
repos.listServiceIpRows(app.db, created.id).find((r) => r.ip === "1.2.3.4")
|
||||
?.enabled,
|
||||
).toBe(true);
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, commonBinding.id)
|
||||
.map((r) => r.content)
|
||||
.sort(),
|
||||
).toEqual(["1.2.3.4", "5.6.7.8"]);
|
||||
expect(
|
||||
repos
|
||||
.listRecordsForBinding(app.db, extraBinding.id)
|
||||
.map((r) => r.content),
|
||||
).toEqual(["1.2.3.4"]);
|
||||
|
||||
// HTTP toggle still updates ip_enabled without mutating bindings.
|
||||
repos.setServiceEnabled(app.db, created.id, false);
|
||||
const offRes = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/api/v1/services/${created.id}/ips/toggle`,
|
||||
@@ -235,24 +390,117 @@ describe("create service then list groups", () => {
|
||||
};
|
||||
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(repos.listBindingIps(app.db, commonBinding.id)).toEqual(
|
||||
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("GET /services/:id repairs multi-IP bindings shrunk below the pool", 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-repair",
|
||||
null,
|
||||
"vpn.example.com",
|
||||
);
|
||||
|
||||
const createRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/services",
|
||||
headers,
|
||||
payload: {
|
||||
name: "Repair",
|
||||
slug: "panel-ip-repair",
|
||||
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", "9.9.9.9"],
|
||||
service_group_id: group.id,
|
||||
domains: [
|
||||
{
|
||||
fqdn: "gw.example.com",
|
||||
target_ips: ["1.2.3.4", "5.6.7.8", "9.9.9.9"],
|
||||
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 1 },
|
||||
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1, "9.9.9.9": 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,
|
||||
},
|
||||
{
|
||||
fqdn: "extra.example.com",
|
||||
target_ips: ["1.2.3.4"],
|
||||
target_ip_weights: { "1.2.3.4": 1 },
|
||||
target_ip_priorities: { "1.2.3.4": 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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const commonBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "gw")!;
|
||||
const extraBinding = repos
|
||||
.listBindingsByService(app.db, created.id)
|
||||
.find((b) => b.hostname === "extra")!;
|
||||
|
||||
// Simulate legacy toggle damage: shrink common binding, leave extra alone.
|
||||
repos.replaceBindingIpsWithMeta(app.db, commonBinding.id, [
|
||||
{ ip: "1.2.3.4", weight: 1, priority: 1 },
|
||||
{ ip: "5.6.7.8", weight: 1, priority: 1 },
|
||||
]);
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id)).toEqual([
|
||||
"1.2.3.4",
|
||||
"5.6.7.8",
|
||||
]);
|
||||
|
||||
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<{ fqdn: string; target_ips: string[] }>;
|
||||
};
|
||||
const gw = view.domains.find((d) => d.fqdn === "gw.example.com");
|
||||
const extra = view.domains.find((d) => d.fqdn === "extra.example.com");
|
||||
expect(gw?.target_ips.sort()).toEqual(["1.2.3.4", "5.6.7.8", "9.9.9.9"]);
|
||||
expect(extra?.target_ips).toEqual(["1.2.3.4"]);
|
||||
expect(repos.listBindingIps(app.db, commonBinding.id).sort()).toEqual([
|
||||
"1.2.3.4",
|
||||
"5.6.7.8",
|
||||
"9.9.9.9",
|
||||
]);
|
||||
expect(repos.listBindingIps(app.db, extraBinding.id)).toEqual(["1.2.3.4"]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -40,12 +40,14 @@ describe("settings health engine", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -133,4 +135,80 @@ describe("settings health engine", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ServiceBindingView } from "@cfdm/shared";
|
||||
import { resolveBindingIpsForSync } from "../src/services/vps-tracker-sync.js";
|
||||
import {
|
||||
cfdmBindingSyncItemSchema,
|
||||
type ServiceBindingView,
|
||||
} from "@cfdm/shared";
|
||||
import {
|
||||
resolveBindingIpsForSync,
|
||||
resolveLbModeForSync,
|
||||
effectiveLbModeForSync,
|
||||
} from "../src/services/vps-tracker-sync.js";
|
||||
|
||||
function binding(
|
||||
partial: Partial<ServiceBindingView> &
|
||||
@@ -160,3 +167,71 @@ describe("resolveBindingIpsForSync", () => {
|
||||
expect(ips).toEqual(["203.0.113.55"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLbModeForSync", () => {
|
||||
it("prefers binding lb_mode", () => {
|
||||
expect(resolveLbModeForSync("failover", "round_robin")).toBe("failover");
|
||||
});
|
||||
|
||||
it("falls back to service group lb_mode", () => {
|
||||
expect(resolveLbModeForSync("off", "weighted")).toBe("weighted");
|
||||
expect(resolveLbModeForSync(undefined, "round_robin")).toBe("round_robin");
|
||||
});
|
||||
|
||||
it("returns undefined when neither is a known mode", () => {
|
||||
expect(resolveLbModeForSync("off", "off")).toBeUndefined();
|
||||
expect(resolveLbModeForSync(null, undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveLbModeForSync", () => {
|
||||
it("omits mode when unique origin IPs are below two", () => {
|
||||
expect(
|
||||
effectiveLbModeForSync("round_robin", "failover", ["203.0.113.10"]),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
effectiveLbModeForSync("round_robin", "failover", [
|
||||
"203.0.113.10",
|
||||
"203.0.113.10",
|
||||
]),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits configured mode when the service has a pool", () => {
|
||||
expect(
|
||||
effectiveLbModeForSync("failover", "round_robin", [
|
||||
"203.0.113.10",
|
||||
"203.0.113.20",
|
||||
]),
|
||||
).toBe("failover");
|
||||
expect(
|
||||
effectiveLbModeForSync("off", "weighted", [
|
||||
"203.0.113.10",
|
||||
"198.51.100.1",
|
||||
]),
|
||||
).toBe("weighted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cfdmBindingSyncItemSchema lbMode", () => {
|
||||
const base = {
|
||||
bindingId: 1,
|
||||
serviceId: 10,
|
||||
serviceName: "VPN",
|
||||
serviceSlug: "vpn",
|
||||
fqdn: "vpn.example.com",
|
||||
zoneName: "example.com",
|
||||
hostname: "vpn",
|
||||
ips: ["203.0.113.10"],
|
||||
};
|
||||
|
||||
it("accepts optional lbMode on sync payload", () => {
|
||||
const parsed = cfdmBindingSyncItemSchema.parse({ ...base, lbMode: "failover" });
|
||||
expect(parsed.lbMode).toBe("failover");
|
||||
});
|
||||
|
||||
it("accepts payload without lbMode (legacy)", () => {
|
||||
const parsed = cfdmBindingSyncItemSchema.parse(base);
|
||||
expect(parsed.lbMode).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
environment: "node",
|
||||
include: ["test/**/*.test.ts"],
|
||||
testTimeout: 20_000,
|
||||
typecheck: {
|
||||
tsconfig: "./tsconfig.test.json",
|
||||
},
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import {
|
||||
LayoutDashboardIcon,
|
||||
GlobeIcon,
|
||||
ChevronRightIcon,
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
LayoutDashboardIcon,
|
||||
PinIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { NavUser } from '@/components/nav-user'
|
||||
import { useSystemAttention } from '@/hooks/use-system-attention'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@cfdm/ui/components/collapsible'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -18,25 +28,114 @@ import {
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@cfdm/ui/components/sidebar'
|
||||
|
||||
const infrastructureNav = [
|
||||
const PINNED_KEY = 'cfdm.sidebar.pinned'
|
||||
const COLLAPSED_KEY = 'cfdm.sidebar.collapsed'
|
||||
|
||||
type NavAttention = 'services-down' | 'certs-expiring'
|
||||
|
||||
type AppNavTo =
|
||||
| '/'
|
||||
| '/domains'
|
||||
| '/services'
|
||||
| '/groups'
|
||||
| '/certificates'
|
||||
| '/settings/appearance'
|
||||
|
||||
type NavItem = {
|
||||
to: AppNavTo
|
||||
label: string
|
||||
icon: typeof GlobeIcon
|
||||
exact: boolean
|
||||
matchPrefix?: string
|
||||
attention?: NavAttention
|
||||
}
|
||||
|
||||
const infrastructureNav: NavItem[] = [
|
||||
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
|
||||
{ to: '/domains', label: 'Домены', icon: GlobeIcon, exact: false },
|
||||
] as const
|
||||
]
|
||||
|
||||
const operationsNav = [
|
||||
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
|
||||
{ to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
|
||||
] as const
|
||||
const operationsNav: NavItem[] = [
|
||||
{
|
||||
to: '/services',
|
||||
label: 'Сервисы',
|
||||
icon: ServerIcon,
|
||||
exact: false,
|
||||
attention: 'services-down',
|
||||
},
|
||||
{
|
||||
to: '/settings/appearance',
|
||||
label: 'Настройки',
|
||||
icon: SettingsIcon,
|
||||
exact: false,
|
||||
matchPrefix: '/settings',
|
||||
},
|
||||
]
|
||||
|
||||
const secondaryNav = [
|
||||
const secondaryNav: NavItem[] = [
|
||||
{ to: '/groups', label: 'Группы доменов', icon: FolderTreeIcon, exact: false },
|
||||
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
||||
{
|
||||
to: '/certificates',
|
||||
label: 'Сертификаты',
|
||||
icon: ShieldCheckIcon,
|
||||
exact: false,
|
||||
attention: 'certs-expiring',
|
||||
},
|
||||
]
|
||||
|
||||
const NAV_GROUPS = [
|
||||
{ id: 'infrastructure', label: 'Инфраструктура', items: infrastructureNav },
|
||||
{ id: 'operations', label: 'Операции', items: operationsNav },
|
||||
{ id: 'secondary', label: 'Прочее', items: secondaryNav },
|
||||
] as const
|
||||
|
||||
const NAV_ITEMS = NAV_GROUPS.flatMap((group) => group.items)
|
||||
|
||||
function readStringList(key: string) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter((item): item is string => typeof item === 'string')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeStringList(key: string, values: string[]) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(values))
|
||||
} catch {
|
||||
// хранилище недоступно — состояние остаётся в памяти
|
||||
}
|
||||
}
|
||||
|
||||
function useStringList(key: string) {
|
||||
const [values, setValues] = useState<string[]>(() => readStringList(key))
|
||||
|
||||
const toggle = useCallback(
|
||||
(id: string) => {
|
||||
setValues((current) => {
|
||||
const next = current.includes(id)
|
||||
? current.filter((item) => item !== id)
|
||||
: [...current, id]
|
||||
writeStringList(key, next)
|
||||
return next
|
||||
})
|
||||
},
|
||||
[key],
|
||||
)
|
||||
|
||||
return { values, toggle }
|
||||
}
|
||||
|
||||
function isNavActive(
|
||||
pathname: string,
|
||||
to: string,
|
||||
@@ -50,56 +149,148 @@ function isNavActive(
|
||||
return pathname === to || pathname.startsWith(`${to}/`)
|
||||
}
|
||||
|
||||
function NavItems({
|
||||
items,
|
||||
pathname,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
servicesDown,
|
||||
certsExpiring,
|
||||
}: {
|
||||
items: readonly NavItem[]
|
||||
pathname: string
|
||||
pinned: readonly string[]
|
||||
onTogglePin: (to: string) => void
|
||||
servicesDown: number
|
||||
certsExpiring: number
|
||||
}) {
|
||||
return (
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const isPinned = pinned.includes(item.to)
|
||||
const showServicesDot = item.attention === 'services-down' && servicesDown > 0
|
||||
const showCertsDot = item.attention === 'certs-expiring' && certsExpiring > 0
|
||||
const showDot = showServicesDot || showCertsDot
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
isActive={isNavActive(pathname, item.to, item.exact, item.matchPrefix)}
|
||||
render={<Link to={item.to} activeOptions={{ exact: item.exact }} />}
|
||||
>
|
||||
<item.icon />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{showDot ? (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto size-1.5 shrink-0 rounded-full group-data-[collapsible=icon]:hidden',
|
||||
showServicesDot ? 'bg-destructive' : 'bg-warning',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{showDot ? (
|
||||
<span className="sr-only">
|
||||
{showServicesDot ? ', есть недоступные' : ', истекают в ближайшие 14 дней'}
|
||||
</span>
|
||||
) : null}
|
||||
</SidebarMenuButton>
|
||||
{showDot ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-1 right-1 hidden size-1.5 rounded-full group-data-[collapsible=icon]:block',
|
||||
showServicesDot ? 'bg-destructive' : 'bg-warning',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<SidebarMenuAction
|
||||
showOnHover={!isPinned}
|
||||
aria-label={isPinned ? `Открепить «${item.label}»` : `Закрепить «${item.label}»`}
|
||||
onClick={() => onTogglePin(item.to)}
|
||||
>
|
||||
<PinIcon />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function NavSection({
|
||||
id,
|
||||
label,
|
||||
items,
|
||||
pathname,
|
||||
collapsed,
|
||||
forceOpen,
|
||||
onToggleCollapsed,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
servicesDown,
|
||||
certsExpiring,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
items: readonly {
|
||||
to: string
|
||||
label: string
|
||||
icon: typeof GlobeIcon
|
||||
exact: boolean
|
||||
matchPrefix?: string
|
||||
}[]
|
||||
items: readonly NavItem[]
|
||||
pathname: string
|
||||
collapsed: boolean
|
||||
forceOpen: boolean
|
||||
onToggleCollapsed: (id: string) => void
|
||||
pinned: readonly string[]
|
||||
onTogglePin: (to: string) => void
|
||||
servicesDown: number
|
||||
certsExpiring: number
|
||||
}) {
|
||||
const open = forceOpen || !collapsed
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
isActive={isNavActive(
|
||||
pathname,
|
||||
item.to,
|
||||
item.exact,
|
||||
item.matchPrefix,
|
||||
)}
|
||||
render={
|
||||
<Link
|
||||
to={item.to}
|
||||
activeOptions={{ exact: item.exact }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<item.icon className="size-4" />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (forceOpen) return
|
||||
if (nextOpen === open) return
|
||||
onToggleCollapsed(id)
|
||||
}}
|
||||
className="group/nav-section"
|
||||
>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel className="w-full" render={<CollapsibleTrigger />}>
|
||||
<span>{label}</span>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
'ml-auto transition-transform duration-200 motion-reduce:transition-none',
|
||||
open && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<NavItems
|
||||
items={items}
|
||||
pathname={pathname}
|
||||
pinned={pinned}
|
||||
onTogglePin={onTogglePin}
|
||||
servicesDown={servicesDown}
|
||||
certsExpiring={certsExpiring}
|
||||
/>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</SidebarGroup>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const { state, isMobile } = useSidebar()
|
||||
const attention = useSystemAttention()
|
||||
const pins = useStringList(PINNED_KEY)
|
||||
const collapsed = useStringList(COLLAPSED_KEY)
|
||||
const forceOpen = state === 'collapsed' && !isMobile
|
||||
const pinnedItems = pins.values
|
||||
.map((to) => NAV_ITEMS.find((item) => item.to === to))
|
||||
.filter((item): item is NavItem => item != null)
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -107,9 +298,37 @@ export function AppSidebar() {
|
||||
<AppSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavSection label="Инфраструктура" items={infrastructureNav} pathname={pathname} />
|
||||
<NavSection label="Операции" items={operationsNav} pathname={pathname} />
|
||||
<NavSection label="Прочее" items={secondaryNav} pathname={pathname} />
|
||||
{pinnedItems.length > 0 ? (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Закреплённые</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<NavItems
|
||||
items={pinnedItems}
|
||||
pathname={pathname}
|
||||
pinned={pins.values}
|
||||
onTogglePin={pins.toggle}
|
||||
servicesDown={attention.servicesDown}
|
||||
certsExpiring={attention.certsExpiring}
|
||||
/>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
) : null}
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<NavSection
|
||||
key={group.id}
|
||||
id={group.id}
|
||||
label={group.label}
|
||||
items={group.items}
|
||||
pathname={pathname}
|
||||
collapsed={collapsed.values.includes(group.id)}
|
||||
forceOpen={forceOpen}
|
||||
onToggleCollapsed={collapsed.toggle}
|
||||
pinned={pins.values}
|
||||
onTogglePin={pins.toggle}
|
||||
servicesDown={attention.servicesDown}
|
||||
certsExpiring={attention.certsExpiring}
|
||||
/>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Frame } from "@/components/reui/frame"
|
||||
|
||||
import { CardItem } from "./card-item"
|
||||
import { CARDS } from "./data"
|
||||
|
||||
export function CardGrid() {
|
||||
return (
|
||||
<Frame className="w-full">
|
||||
<div className="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{CARDS.map((card) => (
|
||||
<CardItem key={card.title} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { FramePanel } from "@/components/reui/frame"
|
||||
|
||||
import { ICard } from "./data"
|
||||
|
||||
export function CardItem({ card }: { card: ICard }) {
|
||||
return (
|
||||
<a
|
||||
href={card.href}
|
||||
className="group/card focus-visible:ring-ring/50 hover:[&_svg]:text-primary block h-full w-full min-w-0 cursor-pointer rounded-(--frame-radius) outline-none focus-visible:ring-[3px] [&_svg]:size-5 [&_svg]:opacity-60 hover:[&_svg]:opacity-100"
|
||||
>
|
||||
<FramePanel className="space-y-7.5 p-3.5">
|
||||
{card.icon}
|
||||
<h3 className="group-hover/card:text-primary text-sm leading-relaxed transition-colors">
|
||||
{card.title}
|
||||
</h3>
|
||||
</FramePanel>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type ReactNode } from "react"
|
||||
import { ActivityIcon, BookOpenIcon, ShieldIcon, CircleDollarSignIcon, BarChart3Icon, TrendingUp } from "lucide-react"
|
||||
|
||||
export interface ICard {
|
||||
title: string
|
||||
href: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export const CARDS: ICard[] = [
|
||||
{
|
||||
title: "Valuation",
|
||||
href: "#valuation",
|
||||
icon: (
|
||||
<ActivityIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Ledger",
|
||||
href: "#ledger",
|
||||
icon: (
|
||||
<BookOpenIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Funding",
|
||||
href: "#funding",
|
||||
icon: (
|
||||
<ShieldIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Cash Flow",
|
||||
href: "#cash-flow",
|
||||
icon: (
|
||||
<CircleDollarSignIcon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Metrics",
|
||||
href: "#metrics",
|
||||
icon: (
|
||||
<BarChart3Icon aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Earnings",
|
||||
href: "#earnings",
|
||||
icon: (
|
||||
<TrendingUp aria-hidden="true" />
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
import { CardGrid } from "./components/card-grid"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full max-w-lg items-center justify-center p-6">
|
||||
<CardGrid />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export type ToolbarOption<T extends string> = {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
|
||||
export const EXPORT_AUDIENCE_OPTIONS = [
|
||||
{ value: "everyone", label: "Everyone" },
|
||||
{ value: "ops-leads", label: "Ops leads" },
|
||||
{ value: "finance-reviewers", label: "Finance reviewers" },
|
||||
{ value: "client-owners", label: "Client owners" },
|
||||
] as const satisfies readonly ToolbarOption<string>[]
|
||||
|
||||
export const EXPORT_SCOPE_OPTIONS = [
|
||||
{ value: "all-workspaces", label: "All workspaces" },
|
||||
{ value: "harbor-field", label: "Harbor field" },
|
||||
{ value: "market-lab", label: "Market lab" },
|
||||
{ value: "support-desk", label: "Support desk" },
|
||||
] as const satisfies readonly ToolbarOption<string>[]
|
||||
|
||||
export const EXPORT_RANGE_OPTIONS = [
|
||||
{ value: "last-30-days", label: "Last 30 days" },
|
||||
{ value: "this-quarter", label: "This quarter" },
|
||||
{ value: "previous-cycle", label: "Previous cycle" },
|
||||
{ value: "custom-window", label: "Custom window" },
|
||||
] as const satisfies readonly ToolbarOption<string>[]
|
||||
|
||||
export const EXPORT_FORMAT_OPTIONS = [
|
||||
{
|
||||
value: "csv",
|
||||
label: "CSV bundle",
|
||||
description: "Spreadsheet-ready activity rows",
|
||||
},
|
||||
{
|
||||
value: "pdf",
|
||||
label: "PDF brief",
|
||||
description: "A concise review packet for stakeholders",
|
||||
},
|
||||
{
|
||||
value: "schedule",
|
||||
label: "Schedule delivery",
|
||||
description: "Send this export every Friday morning",
|
||||
},
|
||||
] as const
|
||||
|
||||
export type ExportAudience = (typeof EXPORT_AUDIENCE_OPTIONS)[number]["value"]
|
||||
export type ExportScope = (typeof EXPORT_SCOPE_OPTIONS)[number]["value"]
|
||||
export type ExportRange = (typeof EXPORT_RANGE_OPTIONS)[number]["value"]
|
||||
export type ExportFormat = (typeof EXPORT_FORMAT_OPTIONS)[number]["value"]
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { IconStack } from "@/components/reui/icon-stack"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
} from "@cfdm/ui/components/button-group"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@cfdm/ui/components/dropdown-menu"
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@cfdm/ui/components/empty"
|
||||
import { Separator } from "@cfdm/ui/components/separator"
|
||||
import {
|
||||
EXPORT_AUDIENCE_OPTIONS,
|
||||
EXPORT_FORMAT_OPTIONS,
|
||||
EXPORT_RANGE_OPTIONS,
|
||||
EXPORT_SCOPE_OPTIONS,
|
||||
type ExportAudience,
|
||||
type ExportFormat,
|
||||
type ExportRange,
|
||||
type ExportScope,
|
||||
type ToolbarOption,
|
||||
} from "./data"
|
||||
import { ChevronDownIcon, PlayIcon, FileDownIcon, BookOpenIcon, CalendarClockIcon, ArchiveIcon } from "lucide-react"
|
||||
|
||||
type ToolbarFilterProps<T extends string> = {
|
||||
label: string
|
||||
value: T
|
||||
options: readonly ToolbarOption<T>[]
|
||||
onValueChange: (value: T) => void
|
||||
}
|
||||
|
||||
function getOptionLabel<T extends string>(
|
||||
options: readonly ToolbarOption<T>[],
|
||||
value: T
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
function ToolbarFilter<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onValueChange,
|
||||
}: ToolbarFilterProps<T>) {
|
||||
const selectedLabel = getOptionLabel(options, value)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label={`${label}: ${selectedLabel}`}
|
||||
>
|
||||
<span className="truncate">{selectedLabel}</span>
|
||||
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
<DropdownMenuRadioGroup
|
||||
value={value}
|
||||
onValueChange={(nextValue) => {
|
||||
if (nextValue !== null) {
|
||||
onValueChange(nextValue as T)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
closeOnClick
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState() {
|
||||
const [audience, setAudience] = useState<ExportAudience>(
|
||||
EXPORT_AUDIENCE_OPTIONS[0].value
|
||||
)
|
||||
const [scope, setScope] = useState<ExportScope>(EXPORT_SCOPE_OPTIONS[0].value)
|
||||
const [range, setRange] = useState<ExportRange>(EXPORT_RANGE_OPTIONS[0].value)
|
||||
|
||||
const audienceLabel = getOptionLabel(EXPORT_AUDIENCE_OPTIONS, audience)
|
||||
const scopeLabel = getOptionLabel(EXPORT_SCOPE_OPTIONS, scope)
|
||||
const rangeLabel = getOptionLabel(EXPORT_RANGE_OPTIONS, range)
|
||||
|
||||
const showExportToast = (format: ExportFormat = "csv") => {
|
||||
const formatOption =
|
||||
EXPORT_FORMAT_OPTIONS.find((option) => option.value === format) ??
|
||||
EXPORT_FORMAT_OPTIONS[0]
|
||||
|
||||
toast.message(`${formatOption.label} is ready to wire`, {
|
||||
description: `${formatOption.description}. ${audienceLabel} · ${scopeLabel} · ${rangeLabel}. Connect this action to your export job when activity records exist.`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex min-h-[430px] w-full max-w-4xl flex-col"
|
||||
aria-labelledby="export-ledger-heading"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 pb-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
{/* Heading */}
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
{/* Title and Description */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* Title */}
|
||||
<h2
|
||||
id="export-ledger-heading"
|
||||
className="text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
Activity Exports
|
||||
</h2>
|
||||
{/* Description */}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Download scoped activity packets for billing review, staffing
|
||||
audits, and client handoffs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ToolbarFilter
|
||||
label="Audience"
|
||||
value={audience}
|
||||
options={EXPORT_AUDIENCE_OPTIONS}
|
||||
onValueChange={setAudience}
|
||||
/>
|
||||
<ToolbarFilter
|
||||
label="Workspace"
|
||||
value={scope}
|
||||
options={EXPORT_SCOPE_OPTIONS}
|
||||
onValueChange={setScope}
|
||||
/>
|
||||
<ToolbarFilter
|
||||
label="Date range"
|
||||
value={range}
|
||||
options={EXPORT_RANGE_OPTIONS}
|
||||
onValueChange={setRange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Download Action */}
|
||||
<ButtonGroup className="w-full **:data-[slot=button]:border-r-0 sm:w-fit">
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1 sm:flex-none"
|
||||
onClick={() => showExportToast()}
|
||||
>
|
||||
<PlayIcon className="fill-current" data-icon="inline-start" aria-hidden="true" />
|
||||
<span>Execute</span>
|
||||
</Button>
|
||||
|
||||
<ButtonGroupSeparator className="bg-primary/72" />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
className="border-primary-foreground/20 rounded-l-none border-l"
|
||||
aria-label="Open download options"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ChevronDownIcon aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent sideOffset={8} align="end" className="w-52">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => showExportToast("csv")}>
|
||||
<FileDownIcon aria-hidden="true" />
|
||||
CSV bundle
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => showExportToast("pdf")}>
|
||||
<BookOpenIcon aria-hidden="true" />
|
||||
PDF brief
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => showExportToast("schedule")}>
|
||||
<CalendarClockIcon aria-hidden="true" />
|
||||
Schedule delivery
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Empty State */}
|
||||
<div className="flex flex-1 items-center justify-center py-14 sm:py-16">
|
||||
<Empty className="max-w-md flex-none bg-transparent p-0">
|
||||
<EmptyHeader className="gap-5 text-center">
|
||||
<EmptyMedia className="mb-0">
|
||||
<IconStack aria-hidden="true">
|
||||
<ArchiveIcon strokeWidth="1.9" aria-hidden="true" />
|
||||
</IconStack>
|
||||
</EmptyMedia>
|
||||
|
||||
{/* Empty State Content */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<EmptyTitle className="text-base font-semibold tracking-tight">
|
||||
No exportable activity yet
|
||||
</EmptyTitle>
|
||||
<EmptyDescription className="max-w-sm text-sm/relaxed">
|
||||
Capture approved activity and this view will assemble your next
|
||||
review packet.
|
||||
</EmptyDescription>
|
||||
</div>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { EmptyState } from "./components/empty-state"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-svh w-full items-center justify-center p-4 sm:p-8 md:p-10"
|
||||
aria-labelledby="page-heading"
|
||||
>
|
||||
<h1 id="page-heading" className="sr-only">
|
||||
Activity exports empty state
|
||||
</h1>
|
||||
<EmptyState />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from "react"
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
import { FieldLegend, FieldSet } from "@cfdm/ui/components/field"
|
||||
import { ACCENT_COLORS } from "./data"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
// ── Color Picker ──
|
||||
|
||||
export function ColorPicker() {
|
||||
const [selected, setSelected] = useState(ACCENT_COLORS[0].value)
|
||||
|
||||
return (
|
||||
<FieldSet className="gap-0">
|
||||
<FieldLegend className="sr-only">Accent color</FieldLegend>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3">
|
||||
{ACCENT_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
aria-label={color.name}
|
||||
onClick={() => setSelected(color.value)}
|
||||
className={cn(
|
||||
"flex size-7 items-center justify-center rounded-full transition-shadow",
|
||||
selected === color.value &&
|
||||
"ring-ring ring-offset-background ring-2 ring-offset-2"
|
||||
)}
|
||||
style={{ backgroundColor: color.value }}
|
||||
>
|
||||
{selected === color.value ? (
|
||||
<CheckIcon className="size-3 text-white" aria-hidden="true" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type SelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type Preference = {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
defaultChecked: boolean
|
||||
}
|
||||
|
||||
export type AccentColorOption = {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type SettingBadgeVariant =
|
||||
| "primary-light"
|
||||
| "destructive-light"
|
||||
| "info-light"
|
||||
|
||||
export type SettingFieldProps = {
|
||||
title: string
|
||||
description: string
|
||||
badge?: { label: string; variant: SettingBadgeVariant }
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
}
|
||||
|
||||
export type DateFormatOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const DAYS: SelectOption[] = [
|
||||
{ value: "monday", label: "Monday" },
|
||||
{ value: "tuesday", label: "Tuesday" },
|
||||
{ value: "wednesday", label: "Wednesday" },
|
||||
{ value: "thursday", label: "Thursday" },
|
||||
{ value: "friday", label: "Friday" },
|
||||
{ value: "saturday", label: "Saturday" },
|
||||
{ value: "sunday", label: "Sunday" },
|
||||
]
|
||||
|
||||
export const CURRENCIES: SelectOption[] = [
|
||||
{ value: "usd", label: "USD · US Dollar" },
|
||||
{ value: "eur", label: "EUR · Euro" },
|
||||
{ value: "gbp", label: "GBP · British Pound" },
|
||||
{ value: "jpy", label: "JPY · Japanese Yen" },
|
||||
]
|
||||
|
||||
export const REGIONS: SelectOption[] = [
|
||||
{ value: "us", label: "United States" },
|
||||
{ value: "eu", label: "European Union" },
|
||||
{ value: "uk", label: "United Kingdom" },
|
||||
{ value: "jp", label: "Japan" },
|
||||
{ value: "au", label: "Australia" },
|
||||
]
|
||||
|
||||
export const ACCENT_COLORS: AccentColorOption[] = [
|
||||
{ name: "Slate", value: "#64748b" },
|
||||
{ name: "Rose", value: "#f43f5e" },
|
||||
{ name: "Orange", value: "#f97316" },
|
||||
{ name: "Violet", value: "#8b5cf6" },
|
||||
{ name: "Emerald", value: "#10b981" },
|
||||
{ name: "Sky", value: "#0ea5e9" },
|
||||
]
|
||||
|
||||
export const PREFERENCES: Preference[] = [
|
||||
{
|
||||
id: "keyboard-shortcuts",
|
||||
label: "Enable keyboard shortcuts.",
|
||||
description: "Use shortcuts to speed up your workflow.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "spellcheck",
|
||||
label: "Auto spell-check.",
|
||||
description: "Highlight spelling errors in text fields.",
|
||||
defaultChecked: true,
|
||||
},
|
||||
{
|
||||
id: "compact-mode",
|
||||
label: "Compact Mode.",
|
||||
description: "Reduce spacing for a denser interface layout.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
{
|
||||
id: "animations",
|
||||
label: "Reduce animations.",
|
||||
description: "Minimize motion effects throughout the UI.",
|
||||
defaultChecked: false,
|
||||
},
|
||||
]
|
||||
|
||||
export const DATE_FORMAT_OPTIONS: DateFormatOption[] = [
|
||||
{ value: "mdy", label: "MM/DD/YYYY" },
|
||||
{ value: "dmy", label: "DD/MM/YYYY" },
|
||||
{ value: "ymd", label: "YYYY/MM/DD" },
|
||||
]
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@cfdm/ui/components/field"
|
||||
import { Switch } from "@cfdm/ui/components/switch"
|
||||
|
||||
import { PREFERENCES } from "./data"
|
||||
|
||||
// ── Editor Preferences ──
|
||||
|
||||
export function EditorPreferences() {
|
||||
const [values, setValues] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(PREFERENCES.map((p) => [p.id, p.defaultChecked]))
|
||||
)
|
||||
|
||||
return (
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Editor preferences</FieldLegend>
|
||||
{/* Description */}
|
||||
<FieldDescription className="sr-only">
|
||||
Configure editing behavior and interface density.
|
||||
</FieldDescription>
|
||||
|
||||
{/* List */}
|
||||
<FieldGroup className="gap-3">
|
||||
{PREFERENCES.map((pref) => (
|
||||
<Field key={pref.id} orientation="horizontal" className="gap-3">
|
||||
<Switch
|
||||
id={`settings-3-${pref.id}`}
|
||||
checked={values[pref.id]}
|
||||
onCheckedChange={(value) =>
|
||||
setValues((prev) => ({ ...prev, [pref.id]: value }))
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
|
||||
<FieldContent className="gap-0.5">
|
||||
<FieldLabel htmlFor={`settings-3-${pref.id}`}>
|
||||
{pref.label}
|
||||
</FieldLabel>
|
||||
<FieldDescription className="text-xs">
|
||||
{pref.description}
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
</Field>
|
||||
))}
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
|
||||
import { Button } from "@cfdm/ui/components/button"
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSet,
|
||||
} from "@cfdm/ui/components/field"
|
||||
import { Input } from "@cfdm/ui/components/input"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@cfdm/ui/components/input-group"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@cfdm/ui/components/select"
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from "@cfdm/ui/components/toggle-group"
|
||||
import { ColorPicker } from "./color-picker"
|
||||
import { CURRENCIES, DAYS, REGIONS } from "./data"
|
||||
import { EditorPreferences } from "./editor-preferences"
|
||||
import { SettingField } from "./setting-field"
|
||||
import { PlusIcon, UploadIcon, GlobeIcon, CircleDollarSignIcon } from "lucide-react"
|
||||
|
||||
export function GeneralSettings() {
|
||||
const [dateFormat, setDateFormat] = useState(["mdy"])
|
||||
const [startOfWeek, setStartOfWeek] = useState("monday")
|
||||
const [region, setRegion] = useState("us")
|
||||
const [currency, setCurrency] = useState("usd")
|
||||
const handleStartOfWeekChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setStartOfWeek(value)
|
||||
}
|
||||
}
|
||||
const handleRegionChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setRegion(value)
|
||||
}
|
||||
}
|
||||
const handleCurrencyChange = (value: string | null) => {
|
||||
if (value !== null) {
|
||||
setCurrency(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame className="w-full max-w-3xl">
|
||||
<FrameHeader className="px-2! py-2.5!">
|
||||
<FrameTitle>General Settings</FrameTitle>
|
||||
<FrameDescription>Core app preferences.</FrameDescription>
|
||||
</FrameHeader>
|
||||
|
||||
<FramePanel className="p-0">
|
||||
<FieldGroup className="gap-0">
|
||||
{/* ── Project name ── */}
|
||||
<SettingField
|
||||
title="Project name"
|
||||
description="The display name for your project across the platform."
|
||||
labelFor="settings-3-project-name"
|
||||
>
|
||||
<Input id="settings-3-project-name" defaultValue="Acme Dashboard" />
|
||||
</SettingField>
|
||||
|
||||
{/* ── API endpoint ── */}
|
||||
<SettingField
|
||||
title="API endpoint"
|
||||
description="Set the base URL for your project API."
|
||||
badge={{ label: "Required", variant: "destructive-light" }}
|
||||
labelFor="settings-3-api-endpoint"
|
||||
>
|
||||
<InputGroup className="w-full">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<InputGroupText>https://</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="settings-3-api-endpoint"
|
||||
defaultValue="api.acme.io"
|
||||
/>
|
||||
</InputGroup>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Start of week ── */}
|
||||
<SettingField
|
||||
title="Start of week"
|
||||
description="Choose which day marks the start of your week."
|
||||
labelFor="settings-3-start-of-week"
|
||||
>
|
||||
<Select value={startOfWeek} onValueChange={handleStartOfWeekChange}>
|
||||
<SelectTrigger id="settings-3-start-of-week" className="w-full">
|
||||
<SelectValue>{getOptionLabel(DAYS, startOfWeek)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{DAYS.map((day) => (
|
||||
<SelectItem key={day.value} value={day.value}>
|
||||
{day.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Allowed origins ── */}
|
||||
<SettingField
|
||||
title="Allowed origins"
|
||||
description="Define the trusted domains for CORS requests."
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Add origin
|
||||
</Button>
|
||||
|
||||
<Button variant="outline">
|
||||
<UploadIcon aria-hidden="true" />
|
||||
Import from file
|
||||
</Button>
|
||||
</div>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Accent color ── */}
|
||||
<SettingField
|
||||
title="Accent color"
|
||||
description="Select a color to represent your brand."
|
||||
badge={{ label: "Upgrade to Pro", variant: "primary-light" }}
|
||||
>
|
||||
<ColorPicker />
|
||||
</SettingField>
|
||||
|
||||
{/* ── Region & currency ── */}
|
||||
<SettingField
|
||||
title="Region & currency"
|
||||
description="Adjust your regional preferences and currency."
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<FieldSet className="w-full gap-3">
|
||||
<FieldLegend className="sr-only">Region and currency</FieldLegend>
|
||||
<FieldDescription className="sr-only">
|
||||
Regional and financial formatting preferences.
|
||||
</FieldDescription>
|
||||
|
||||
<FieldGroup className="gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-3-region">Region</FieldLabel>
|
||||
<Select value={region} onValueChange={handleRegionChange}>
|
||||
<SelectTrigger id="settings-3-region" className="w-full">
|
||||
<GlobeIcon aria-hidden="true" />
|
||||
<SelectValue>
|
||||
{getOptionLabel(REGIONS, region)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{REGIONS.map((region) => (
|
||||
<SelectItem key={region.value} value={region.value}>
|
||||
{region.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="settings-3-currency">
|
||||
Currency
|
||||
</FieldLabel>
|
||||
<Select value={currency} onValueChange={handleCurrencyChange}>
|
||||
<SelectTrigger id="settings-3-currency" className="w-full">
|
||||
<CircleDollarSignIcon aria-hidden="true" />
|
||||
<SelectValue>
|
||||
{getOptionLabel(CURRENCIES, currency)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<SelectItem
|
||||
key={currency.value}
|
||||
value={currency.value}
|
||||
>
|
||||
{currency.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</FieldSet>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Date display format ── */}
|
||||
<SettingField
|
||||
title="Date display format"
|
||||
description="Choose your preferred format for dates."
|
||||
>
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={dateFormat}
|
||||
onValueChange={(value) => {
|
||||
if (value.length > 0) setDateFormat(value)
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Date display format"
|
||||
>
|
||||
<ToggleGroupItem value="mdy">MM/DD/YYYY</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dmy">DD/MM/YYYY</ToggleGroupItem>
|
||||
<ToggleGroupItem value="ymd">YYYY/MM/DD</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</SettingField>
|
||||
|
||||
{/* ── Editor preferences ── */}
|
||||
<SettingField
|
||||
title="Editor preferences"
|
||||
description="Adjust your editing environment and display options."
|
||||
last
|
||||
contentClassName="@md/field-group:w-[22rem]"
|
||||
>
|
||||
<EditorPreferences />
|
||||
</SettingField>
|
||||
</FieldGroup>
|
||||
</FramePanel>
|
||||
|
||||
<FrameFooter className="flex flex-row justify-end gap-3">
|
||||
<Button variant="outline">Reset</Button>
|
||||
<Button>Save changes</Button>
|
||||
</FrameFooter>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helper functions ──
|
||||
|
||||
function getOptionLabel(
|
||||
options: Array<{ value: string; label: string }>,
|
||||
value: string
|
||||
) {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { type ComponentProps, type ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldLabel,
|
||||
FieldSeparator,
|
||||
FieldTitle,
|
||||
} from "@cfdm/ui/components/field"
|
||||
|
||||
interface SettingFieldProps {
|
||||
title: string
|
||||
description: string
|
||||
badge?: {
|
||||
label: string
|
||||
variant: ComponentProps<typeof Badge>["variant"]
|
||||
}
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
labelFor?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
// ── Setting Field ──
|
||||
|
||||
export function SettingField({
|
||||
title,
|
||||
description,
|
||||
badge,
|
||||
children,
|
||||
last,
|
||||
labelFor,
|
||||
contentClassName,
|
||||
}: SettingFieldProps) {
|
||||
return (
|
||||
<>
|
||||
<Field orientation="responsive" className="gap-4 px-4 py-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{labelFor ? (
|
||||
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||
) : (
|
||||
<FieldTitle>{title}</FieldTitle>
|
||||
)}
|
||||
|
||||
{badge ? (
|
||||
<Badge variant={badge.variant} size="sm">
|
||||
{badge.label}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<FieldDescription className="text-xs">{description}</FieldDescription>
|
||||
</div>
|
||||
|
||||
<FieldContent
|
||||
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
|
||||
>
|
||||
{children}
|
||||
</FieldContent>
|
||||
</Field>
|
||||
|
||||
{!last ? <FieldSeparator /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { GeneralSettings } from "./components/general-settings"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex min-h-svh w-full items-start justify-center p-8 md:p-16">
|
||||
<GeneralSettings />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import { ReactNode } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Headphones, CircleCheckIcon, SmileIcon } from "lucide-react"
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface CardData {
|
||||
icon: ReactNode
|
||||
iconBg: string
|
||||
value: string | number
|
||||
label: string
|
||||
info: ReactNode
|
||||
}
|
||||
|
||||
// ── Data ──
|
||||
|
||||
export const cards: CardData[] = [
|
||||
{
|
||||
icon: (
|
||||
<Headphones aria-hidden="true" />
|
||||
),
|
||||
iconBg: "text-blue-600 dark:text-blue-400",
|
||||
value: 320,
|
||||
label: "Support Tickets",
|
||||
info: <Badge variant="info-light">12 Open, 308 Closed</Badge>,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<CircleCheckIcon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "text-emerald-600 dark:text-emerald-400",
|
||||
value: "98%",
|
||||
label: "Resolved",
|
||||
info: <Badge variant="success-light">+2.1% this month</Badge>,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<SmileIcon aria-hidden="true" />
|
||||
),
|
||||
iconBg: "text-amber-600 dark:text-amber-400",
|
||||
value: "4.8",
|
||||
label: "Satisfaction Rate",
|
||||
info: <Badge variant="warning-light">Avg. (out of 5)</Badge>,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
import { Item, ItemMedia } from "@cfdm/ui/components/item"
|
||||
|
||||
import { cards } from "./data"
|
||||
|
||||
export function Stats() {
|
||||
return (
|
||||
<div className="@container w-full grow">
|
||||
{/* Grid */}
|
||||
<div className="mx-auto grid max-w-5xl grow grid-cols-1 gap-5 @3xl:grid-cols-3">
|
||||
{cards.map((card, i) => (
|
||||
<Frame key={i}>
|
||||
<FramePanel className="flex flex-col items-start gap-6">
|
||||
<Item
|
||||
className={cn(
|
||||
"border-background bg-muted flex size-10.5 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5",
|
||||
card.iconBg
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-foreground text-2xl leading-none font-bold">
|
||||
{card.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm font-medium">
|
||||
{card.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{card.info}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Stats } from "./components/stats"
|
||||
|
||||
export function Page() {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center p-10 md:p-20">
|
||||
<Stats />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
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 { 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 type { Certificate } from '@/lib/schemas'
|
||||
import { formatDate, formatRelative } from '@/lib/format'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
export const CERT_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
@@ -41,6 +43,7 @@ export function certTabFilter(item: Certificate, tabId: string) {
|
||||
export function createDefaultCertFilters() {
|
||||
return [
|
||||
createFilter('hostname', 'contains', ['']),
|
||||
createFilter('service', 'contains', ['']),
|
||||
createFilter('status', 'is', ['']),
|
||||
]
|
||||
}
|
||||
@@ -56,6 +59,14 @@ export function useCertFilterFields() {
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по хосту…',
|
||||
},
|
||||
{
|
||||
key: 'service',
|
||||
label: 'Сервис',
|
||||
icon: <ServerIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по сервису…',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Статус',
|
||||
@@ -75,6 +86,8 @@ export function certFilterFieldValue(item: Certificate, field: string) {
|
||||
switch (field) {
|
||||
case 'hostname':
|
||||
return `${item.hostname} ${item.status}`.toLowerCase()
|
||||
case 'service':
|
||||
return (item.service_name ?? '').toLowerCase()
|
||||
case 'status':
|
||||
return item.status
|
||||
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)
|
||||
if (!expiresAt) {
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
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',
|
||||
header: 'Статус',
|
||||
|
||||
@@ -19,6 +19,8 @@ interface ConfirmDialogProps {
|
||||
description: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
/** Кнопка подтверждения. Удаление остаётся destructive. */
|
||||
confirmVariant?: 'default' | 'destructive'
|
||||
onConfirm: () => void
|
||||
disabled?: boolean
|
||||
}
|
||||
@@ -31,6 +33,7 @@ export function ConfirmDialog({
|
||||
description,
|
||||
confirmLabel = 'Удалить',
|
||||
cancelLabel = 'Отмена',
|
||||
confirmVariant = 'destructive',
|
||||
onConfirm,
|
||||
disabled,
|
||||
}: ConfirmDialogProps) {
|
||||
@@ -46,7 +49,11 @@ export function ConfirmDialog({
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
||||
<AlertDialogAction
|
||||
variant={confirmVariant}
|
||||
onClick={onConfirm}
|
||||
disabled={disabled}
|
||||
>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { FolderTreeIcon, GlobeIcon, Link2Icon, ServerIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { SettingRow } from '@/components/setting-row'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
SubdomainEditSheet,
|
||||
type SubdomainEditValues,
|
||||
} from '@/components/subdomain-edit-sheet'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { useDomainPage } from '@/hooks/use-domain-page'
|
||||
import {
|
||||
type DomainDetailTab,
|
||||
} from '@/components/domains/domain-detail-tabs'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import type { ServiceBinding } from '@/lib/schemas'
|
||||
import {
|
||||
dnsListQueryOptions,
|
||||
groupsQueryOptions,
|
||||
groupKeys,
|
||||
domainKeys,
|
||||
updateDomain,
|
||||
} from '@/queries'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
|
||||
const groupFormSchema = z.object({
|
||||
group_id: z.string(),
|
||||
})
|
||||
|
||||
type GroupFormValues = z.infer<typeof groupFormSchema>
|
||||
|
||||
interface DomainDetailActions {
|
||||
openCreateSubdomain: () => void
|
||||
}
|
||||
|
||||
function DomainChromeSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<TableSkeleton rows={1} cols={2} />
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
<TableSkeleton rows={2} cols={1} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Общая шапка, KPI и полоса вкладок карточки домена.
|
||||
* Preview: https://reui.io/preview/base/stats-12
|
||||
* Preview: https://reui.io/preview/base/form-7
|
||||
* Preview: https://reui.io/preview/base/settings-16
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export function DomainDetailChrome({
|
||||
domainId,
|
||||
activeTab,
|
||||
children,
|
||||
}: {
|
||||
domainId: string
|
||||
activeTab: DomainDetailTab
|
||||
children: (actions: DomainDetailActions) => ReactNode
|
||||
}) {
|
||||
const id = Number(domainId)
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const page = useDomainPage(id)
|
||||
const groupsQuery = useQuery(groupsQueryOptions())
|
||||
const dnsQuery = useQuery(dnsListQueryOptions(id))
|
||||
|
||||
const [groupOpen, setGroupOpen] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [changeIp, setChangeIp] = useState<ServiceBinding | null>(null)
|
||||
|
||||
const domain = page.domain
|
||||
const groupForm = useForm<GroupFormValues>({
|
||||
resolver: zodResolver(groupFormSchema),
|
||||
defaultValues: { group_id: 'none' },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupOpen || !domain) return
|
||||
groupForm.reset({
|
||||
group_id: domain.group_id != null ? String(domain.group_id) : 'none',
|
||||
})
|
||||
}, [groupOpen, domain, groupForm])
|
||||
|
||||
const groupName = useMemo(() => {
|
||||
if (!domain?.group_id) return null
|
||||
return groupsQuery.data?.find((group) => group.id === domain.group_id)?.name ?? null
|
||||
}, [domain, groupsQuery.data])
|
||||
|
||||
const groupItems = useMemo(
|
||||
() => [
|
||||
{ label: 'Без группы', value: 'none' },
|
||||
...(groupsQuery.data ?? []).map((group) => ({
|
||||
label: group.name,
|
||||
value: String(group.id),
|
||||
})),
|
||||
],
|
||||
[groupsQuery.data],
|
||||
)
|
||||
|
||||
const updateGroupMutation = useMutation({
|
||||
mutationFn: (groupId: number | null) => updateDomain(id, { group_id: groupId }),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.detail(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: groupKeys.all }),
|
||||
])
|
||||
setGroupOpen(false)
|
||||
toast.success('Группа домена обновлена')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось сменить группу')
|
||||
},
|
||||
})
|
||||
|
||||
function openCreateSubdomain() {
|
||||
setCreateOpen(true)
|
||||
}
|
||||
|
||||
async function handleCreateSubdomain(values: SubdomainEditValues) {
|
||||
await page.createSubdomainMutation.mutateAsync(values.name)
|
||||
setCreateOpen(false)
|
||||
}
|
||||
|
||||
function handleTabChange(next: string) {
|
||||
if (next === 'dns') {
|
||||
void navigate({
|
||||
to: '/domains/$domainId/dns',
|
||||
params: { domainId },
|
||||
search: { host: undefined },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (
|
||||
next === 'overview' ||
|
||||
next === 'availability' ||
|
||||
next === 'subdomains' ||
|
||||
next === 'bindings'
|
||||
) {
|
||||
void navigate({
|
||||
to: '/domains/$domainId',
|
||||
params: { domainId },
|
||||
search: { tab: next },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const kpiCards = domain
|
||||
? [
|
||||
{
|
||||
id: 'status',
|
||||
icon: <GlobeIcon />,
|
||||
label: 'Статус',
|
||||
value: domain.status,
|
||||
hint: <StatusBadge status={domain.status} />,
|
||||
},
|
||||
{
|
||||
id: 'group',
|
||||
icon: <FolderTreeIcon />,
|
||||
label: 'Группа',
|
||||
value: groupName ?? '—',
|
||||
hint: groupName ? 'В группе' : 'Без группы',
|
||||
},
|
||||
{
|
||||
id: 'bindings',
|
||||
icon: <Link2Icon />,
|
||||
label: 'Привязки',
|
||||
value: page.bindings.length,
|
||||
hint: 'Сервисы в зоне',
|
||||
},
|
||||
{
|
||||
id: 'sync',
|
||||
icon: <ServerIcon />,
|
||||
label: 'Синхронизация',
|
||||
value: formatDate(domain.last_synced_at),
|
||||
hint: 'Последняя синхронизация',
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const bindings = page.bindings
|
||||
|
||||
return (
|
||||
<QueryState
|
||||
isLoading={page.isLoading}
|
||||
isError={page.isError}
|
||||
error={page.error}
|
||||
onRetry={page.refetch}
|
||||
skeleton={<DomainChromeSkeleton />}
|
||||
>
|
||||
{domain ? (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={domain.zone_name}
|
||||
description="Карточка домена"
|
||||
actions={
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={() => setGroupOpen(true)}>
|
||||
Сменить группу
|
||||
</Button>
|
||||
{bindings.length === 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setChangeIp(bindings[0] ?? null)}
|
||||
>
|
||||
Сменить IP
|
||||
</Button>
|
||||
) : bindings.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button type="button" variant="outline">
|
||||
Сменить IP
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
{bindings.map((binding) => (
|
||||
<DropdownMenuItem
|
||||
key={binding.id}
|
||||
onClick={() => setChangeIp(binding)}
|
||||
>
|
||||
{binding.hostname}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button type="button" onClick={openCreateSubdomain}>
|
||||
Добавить поддомен
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStatGrid cards={kpiCards} />
|
||||
|
||||
<CountedLineTabs
|
||||
tabs={[
|
||||
{ id: 'overview', label: 'Обзор' },
|
||||
{ id: 'availability', label: 'Доступность' },
|
||||
{
|
||||
id: 'subdomains',
|
||||
label: 'Поддомены',
|
||||
count: page.subdomainRows.length,
|
||||
},
|
||||
{ id: 'bindings', label: 'Привязки', count: bindings.length },
|
||||
{
|
||||
id: 'dns',
|
||||
label: 'DNS',
|
||||
count: dnsQuery.data ? dnsQuery.data.length : undefined,
|
||||
},
|
||||
]}
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex w-full flex-col gap-4"
|
||||
>
|
||||
{activeTab === 'overview' ? <DomainOverviewFacts domainId={id} /> : null}
|
||||
{children({ openCreateSubdomain })}
|
||||
</CountedLineTabs>
|
||||
|
||||
<FormSheet
|
||||
open={groupOpen}
|
||||
onOpenChange={setGroupOpen}
|
||||
title="Сменить группу"
|
||||
description={`Назначить группу для ${domain.zone_name}`}
|
||||
form={groupForm}
|
||||
onSubmit={(values) => {
|
||||
const next = values.group_id === 'none' ? null : Number(values.group_id)
|
||||
updateGroupMutation.mutate(next)
|
||||
}}
|
||||
footer={
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={updateGroupMutation.isPending}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FieldGroup>
|
||||
<FormFieldSimple label="Группа" htmlFor="domain-group-id">
|
||||
<Controller
|
||||
control={groupForm.control}
|
||||
name="group_id"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={groupItems}
|
||||
value={field.value}
|
||||
onValueChange={(value) => field.onChange(value ?? 'none')}
|
||||
>
|
||||
<SelectTrigger id="domain-group-id" className="w-full">
|
||||
<SelectValue placeholder="Без группы" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</FieldGroup>
|
||||
</FormSheet>
|
||||
|
||||
<SubdomainEditSheet
|
||||
mode="create"
|
||||
subdomain={null}
|
||||
zoneName={domain.zone_name}
|
||||
services={page.services}
|
||||
serviceGroupById={page.serviceGroupById}
|
||||
currentServiceId="none"
|
||||
open={createOpen}
|
||||
isSaving={page.createSubdomainMutation.isPending}
|
||||
onOpenChange={setCreateOpen}
|
||||
onSubmit={handleCreateSubdomain}
|
||||
/>
|
||||
|
||||
<ChangeIpSheet
|
||||
open={changeIp != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setChangeIp(null)
|
||||
}}
|
||||
bindingId={changeIp?.id ?? null}
|
||||
serviceId={changeIp?.service_id ?? null}
|
||||
currentIp={changeIp?.target_ip ?? changeIp?.target_ips?.[0]}
|
||||
/>
|
||||
</DetailPanel>
|
||||
) : null}
|
||||
</QueryState>
|
||||
)
|
||||
}
|
||||
|
||||
function DomainOverviewFacts({ domainId }: { domainId: number }) {
|
||||
const page = useDomainPage(domainId)
|
||||
const groupsQuery = useQuery(groupsQueryOptions())
|
||||
const domain = page.domain
|
||||
if (!domain) return null
|
||||
|
||||
const groupName = domain.group_id
|
||||
? groupsQuery.data?.find((group) => group.id === domain.group_id)?.name
|
||||
: null
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Зона</FrameTitle>
|
||||
<FrameDescription>Параметры зоны Cloudflare</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<SettingRow title="Зона">
|
||||
<span className="text-sm font-medium">{domain.zone_name}</span>
|
||||
</SettingRow>
|
||||
<SettingRow title="Группа">
|
||||
{domain.group_id ? (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/groups/$groupId" params={{ groupId: String(domain.group_id) }} />
|
||||
}
|
||||
>
|
||||
{groupName ?? 'Открыть группу'}
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant="outline">Без группы</Badge>
|
||||
)}
|
||||
</SettingRow>
|
||||
<SettingRow title="Zone ID">
|
||||
<span className="font-mono text-sm break-all">{domain.cf_zone_id}</span>
|
||||
</SettingRow>
|
||||
<SettingRow title="Последняя синхронизация" last>
|
||||
<span className="text-sm tabular-nums">{formatDate(domain.last_synced_at)}</span>
|
||||
</SettingRow>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export const DOMAIN_SECTION_TABS = [
|
||||
'overview',
|
||||
'availability',
|
||||
'subdomains',
|
||||
'bindings',
|
||||
] as const
|
||||
|
||||
export type DomainSectionTab = (typeof DOMAIN_SECTION_TABS)[number]
|
||||
export type DomainDetailTab = DomainSectionTab | 'dns'
|
||||
@@ -9,15 +9,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
|
||||
interface DomainsBulkToolbarProps {
|
||||
count: number
|
||||
isPending?: boolean
|
||||
groupItems: { label: string; value: string }[]
|
||||
onAssignGroup: (groupId: number | null) => void
|
||||
onSetEnvironment: (env: 'prod' | 'staging' | 'dev' | null) => void
|
||||
onAddTag: (tag: string) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
@@ -26,23 +22,12 @@ export function DomainsBulkToolbar({
|
||||
isPending = false,
|
||||
groupItems,
|
||||
onAssignGroup,
|
||||
onSetEnvironment,
|
||||
onAddTag,
|
||||
onClear,
|
||||
}: DomainsBulkToolbarProps) {
|
||||
const [groupValue, setGroupValue] = useState('none')
|
||||
const [envValue, setEnvValue] = useState('none')
|
||||
const [tag, setTag] = useState('')
|
||||
|
||||
if (count === 0) return null
|
||||
|
||||
const envItems = [
|
||||
{ label: 'Без env', value: 'none' },
|
||||
{ label: 'prod', value: 'prod' },
|
||||
{ label: 'staging', value: 'staging' },
|
||||
{ label: 'dev', value: 'dev' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className="mb-3 w-full">
|
||||
<FramePanel className="flex flex-wrap items-center gap-2 bg-muted/30">
|
||||
@@ -74,56 +59,16 @@ export function DomainsBulkToolbar({
|
||||
onAssignGroup(groupValue === 'none' ? null : Number(groupValue))
|
||||
}
|
||||
>
|
||||
В группу
|
||||
Назначить группу
|
||||
</Button>
|
||||
<Select
|
||||
items={envItems}
|
||||
value={envValue}
|
||||
onValueChange={(v) => setEnvValue(v ?? 'none')}
|
||||
>
|
||||
<SelectTrigger className="w-32" size="sm">
|
||||
<SelectValue placeholder="Env" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{envItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
onClick={() =>
|
||||
onSetEnvironment(
|
||||
envValue === 'none'
|
||||
? null
|
||||
: (envValue as 'prod' | 'staging' | 'dev'),
|
||||
)
|
||||
}
|
||||
onClick={() => onAssignGroup(null)}
|
||||
>
|
||||
Env
|
||||
</Button>
|
||||
<Input
|
||||
className="h-8 w-32"
|
||||
placeholder="тег"
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isPending || !tag.trim()}
|
||||
onClick={() => {
|
||||
onAddTag(tag.trim())
|
||||
setTag('')
|
||||
}}
|
||||
>
|
||||
+ тег
|
||||
Снять группу
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -1,40 +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'
|
||||
|
||||
export interface FailoverEvent {
|
||||
id: string
|
||||
title: string
|
||||
detail: string
|
||||
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} ошибок подряд`
|
||||
}
|
||||
|
||||
export function FailoverTimeline({ events }: { events: FailoverEvent[] }) {
|
||||
if (events.length === 0) {
|
||||
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 (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Событий failover пока нет.
|
||||
</p>
|
||||
<EmptyState
|
||||
icon={ShieldCheckIcon}
|
||||
title="Нет инцидентов"
|
||||
description="Нет Down и нет выходов из общего пула"
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Timeline defaultValue={events.length} className="w-full">
|
||||
{events.map((event, index) => (
|
||||
<TimelineItem key={event.id} step={index + 1}>
|
||||
<TimelineSeparator />
|
||||
<TimelineIndicator />
|
||||
<TimelineHeader>
|
||||
<TimelineTitle>{event.title}</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineContent>{event.detail}</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -16,8 +16,8 @@ type HealthStatus =
|
||||
|
||||
function normalizeHealth(status: HealthStatus): IpHealthStatus['status'] {
|
||||
if (status === 'healthy') return 'up'
|
||||
if (status === 'unhealthy' || status === 'disabled') return 'down'
|
||||
if (status === 'checking') return 'unknown'
|
||||
if (status === 'unhealthy') return 'down'
|
||||
if (status === 'disabled' || status === 'checking') return 'unknown'
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ interface HealthCheckBadgeProps {
|
||||
latencyMs?: number | null
|
||||
lastCheckedAt?: string | null
|
||||
lastError?: string | null
|
||||
colo?: string | null
|
||||
provider?: 'local' | 'cloudflare' | string | null
|
||||
title?: string
|
||||
showLatency?: boolean
|
||||
size?: 'xs' | 'sm'
|
||||
@@ -65,6 +67,8 @@ export function HealthCheckBadge({
|
||||
latencyMs,
|
||||
lastCheckedAt,
|
||||
lastError,
|
||||
colo,
|
||||
provider,
|
||||
title,
|
||||
showLatency = false,
|
||||
size = 'sm',
|
||||
@@ -79,6 +83,9 @@ export function HealthCheckBadge({
|
||||
tooltipParts.push(`Статус: ${label}`)
|
||||
if (latencyMs != null) tooltipParts.push(`Задержка: ${latencyMs} мс`)
|
||||
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}`)
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { AppInput } from '@/components/app-input'
|
||||
import { SettingRow } from '@/components/setting-row'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
NumberField,
|
||||
@@ -9,24 +10,23 @@ import {
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/components/reui/number-field'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { Field, FieldGroup, FieldLabel } from '@cfdm/ui/components/field'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { ButtonGroup } from '@cfdm/ui/components/button-group'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { CableIcon, GlobeIcon } from 'lucide-react'
|
||||
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 HealthCheckType = 'tcp' | 'http'
|
||||
export type HealthProvider = 'local' | 'cloudflare'
|
||||
export type { HealthProvider, HealthAggregate }
|
||||
|
||||
export interface HealthCheckConfig {
|
||||
enabled: boolean
|
||||
@@ -38,6 +38,8 @@ export interface HealthCheckConfig {
|
||||
timeout_ms: number
|
||||
verify_tls: boolean
|
||||
provider: HealthProvider
|
||||
providers: HealthProvider[]
|
||||
aggregate: HealthAggregate
|
||||
method?: string | null
|
||||
retries?: number
|
||||
consecutive_fails?: number
|
||||
@@ -51,63 +53,12 @@ export interface LbAndHealthConfig extends HealthCheckConfig {
|
||||
const defaultLbModeOptions = [
|
||||
{ value: 'round_robin', label: 'Round Robin' },
|
||||
{ value: 'failover', label: 'Failover (приоритет)' },
|
||||
{ value: 'weighted', label: 'Weighted (веса)' },
|
||||
{ value: 'weighted', label: 'Веса (подмена IP)' },
|
||||
]
|
||||
|
||||
const healthCheckTypes = [
|
||||
{ value: 'tcp', label: 'TCP connect' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
] as const
|
||||
|
||||
const cloudflareTypes = [
|
||||
{ value: 'tcp', label: 'TCP' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
] as const
|
||||
|
||||
export function HealthProviderToggle({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
}: {
|
||||
value: HealthProvider
|
||||
onChange: (next: HealthProvider) => void
|
||||
id?: string
|
||||
}) {
|
||||
const provider = value || 'local'
|
||||
return (
|
||||
<ButtonGroup className="w-full" id={id}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
variant={provider === 'local' ? 'secondary' : 'outline'}
|
||||
aria-pressed={provider === 'local'}
|
||||
onClick={() => onChange('local')}
|
||||
>
|
||||
Local
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
variant={provider === 'cloudflare' ? 'secondary' : 'outline'}
|
||||
aria-pressed={provider === 'cloudflare'}
|
||||
onClick={() => onChange('cloudflare')}
|
||||
>
|
||||
Cloudflare
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
|
||||
interface HealthCheckConfigFieldsProps {
|
||||
value: LbAndHealthConfig
|
||||
onChange: (next: LbAndHealthConfig) => void
|
||||
lbModeLabel?: string
|
||||
lbModeOptions?: { value: string; label: string }[]
|
||||
idPrefix?: string
|
||||
showLbMode?: boolean
|
||||
className?: string
|
||||
export interface LbPoolMetaChange {
|
||||
weight?: number
|
||||
priority?: number
|
||||
}
|
||||
|
||||
function CompactNumberField({
|
||||
@@ -143,6 +94,89 @@ function CompactNumberField({
|
||||
)
|
||||
}
|
||||
|
||||
function PoolLbMetaFields({
|
||||
idPrefix,
|
||||
mode,
|
||||
ips,
|
||||
weights,
|
||||
priorities,
|
||||
onMetaChange,
|
||||
}: {
|
||||
idPrefix: string
|
||||
mode: Exclude<LbMode, 'round_robin'>
|
||||
ips: readonly string[]
|
||||
weights: Record<string, number>
|
||||
priorities: Record<string, number>
|
||||
onMetaChange?: (ip: string, meta: LbPoolMetaChange) => void
|
||||
}) {
|
||||
const isWeighted = mode === 'weighted'
|
||||
const minPriority =
|
||||
ips.length === 0
|
||||
? 1
|
||||
: Math.min(...ips.map((ip) => priorities[ip] ?? 1))
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
title={isWeighted ? 'Вес IP' : 'Приоритет IP'}
|
||||
description={
|
||||
isWeighted
|
||||
? 'Доля времени на общем FQDN: 1 и 3 = ¼ и ¾ цикла (слот 60 с)'
|
||||
: '1 — основной, больше — запасной'
|
||||
}
|
||||
compact
|
||||
stacked
|
||||
className="gap-3 px-0 py-3"
|
||||
contentClassName="min-w-0"
|
||||
>
|
||||
{ips.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Сначала добавьте IP выше</p>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{ips.map((ip) => {
|
||||
const isPrimary = (priorities[ip] ?? 1) === minPriority
|
||||
const fieldId = isWeighted
|
||||
? `${idPrefix}-weight-${ip}`
|
||||
: `${idPrefix}-priority-${ip}`
|
||||
return (
|
||||
<div key={ip} className="flex items-center gap-3">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-sm">{ip}</span>
|
||||
{!isWeighted ? (
|
||||
<Badge
|
||||
variant={isPrimary ? 'success-light' : 'outline'}
|
||||
size="xs"
|
||||
radius="full"
|
||||
>
|
||||
{isPrimary ? 'Основной' : 'Запасной'}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Field className="w-28 gap-0">
|
||||
<FieldLabel htmlFor={fieldId} className="sr-only">
|
||||
{isWeighted ? `Вес ${ip}` : `Приоритет ${ip}`}
|
||||
</FieldLabel>
|
||||
<CompactNumberField
|
||||
id={fieldId}
|
||||
value={isWeighted ? (weights[ip] ?? 1) : (priorities[ip] ?? 1)}
|
||||
min={1}
|
||||
max={100}
|
||||
onValueChange={(next) =>
|
||||
onMetaChange?.(
|
||||
ip,
|
||||
isWeighted
|
||||
? { weight: next ?? 1 }
|
||||
: { priority: next ?? 1 },
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingRow>
|
||||
)
|
||||
}
|
||||
|
||||
export function HealthCheckConfigFields({
|
||||
value,
|
||||
onChange,
|
||||
@@ -150,12 +184,33 @@ export function HealthCheckConfigFields({
|
||||
lbModeOptions = defaultLbModeOptions,
|
||||
idPrefix = 'health',
|
||||
showLbMode = true,
|
||||
ips = [],
|
||||
weights = {},
|
||||
priorities = {},
|
||||
onMetaChange,
|
||||
className,
|
||||
}: HealthCheckConfigFieldsProps) {
|
||||
}: {
|
||||
value: LbAndHealthConfig
|
||||
onChange: (next: LbAndHealthConfig) => void
|
||||
lbModeLabel?: string
|
||||
lbModeOptions?: { value: string; label: string }[]
|
||||
idPrefix?: string
|
||||
showLbMode?: boolean
|
||||
ips?: readonly string[]
|
||||
weights?: Record<string, number>
|
||||
priorities?: Record<string, number>
|
||||
onMetaChange?: (ip: string, meta: LbPoolMetaChange) => void
|
||||
className?: string
|
||||
}) {
|
||||
function patch(next: Partial<LbAndHealthConfig>) {
|
||||
onChange({ ...value, ...next })
|
||||
}
|
||||
|
||||
const providers = parseHealthProviders(
|
||||
value.providers,
|
||||
value.provider ?? 'local',
|
||||
)
|
||||
const aggregate = value.aggregate ?? 'majority'
|
||||
const isHttp = value.type === 'http'
|
||||
const rowClass = 'gap-3 px-0 py-3'
|
||||
|
||||
@@ -169,63 +224,64 @@ export function HealthCheckConfigFields({
|
||||
compact
|
||||
className={rowClass}
|
||||
>
|
||||
<Select
|
||||
<SelectField
|
||||
modal={false}
|
||||
value={value.lb_mode}
|
||||
onValueChange={(v) => patch({ lb_mode: (v ?? 'round_robin') as LbMode })}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-lb-mode`} className="w-full">
|
||||
<SelectValue placeholder="Выберите режим" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{lbModeOptions.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
triggerId={`${idPrefix}-lb-mode`}
|
||||
placeholder="Выберите режим"
|
||||
options={lbModeOptions}
|
||||
/>
|
||||
</SettingRow>
|
||||
) : null}
|
||||
|
||||
{showLbMode && value.lb_mode !== 'round_robin' ? (
|
||||
<PoolLbMetaFields
|
||||
idPrefix={idPrefix}
|
||||
mode={value.lb_mode}
|
||||
ips={ips}
|
||||
weights={weights}
|
||||
priorities={priorities}
|
||||
onMetaChange={onMetaChange}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SettingRow
|
||||
title="Провайдер health-check"
|
||||
description="Local TCP/HTTP или Cloudflare Health Checks API"
|
||||
description="Кто пробирует цель. Можно выбрать несколько источников."
|
||||
labelFor={`${idPrefix}-provider`}
|
||||
compact
|
||||
stacked
|
||||
className={rowClass}
|
||||
contentClassName="min-w-0"
|
||||
>
|
||||
<HealthProviderToggle
|
||||
id={`${idPrefix}-provider`}
|
||||
value={value.provider ?? 'local'}
|
||||
onChange={(provider) =>
|
||||
<HealthSourceTiles
|
||||
value={providers}
|
||||
onChange={(next) =>
|
||||
patch({
|
||||
provider,
|
||||
enabled: provider === 'cloudflare' ? true : value.enabled,
|
||||
providers: next,
|
||||
provider: next[0] ?? 'local',
|
||||
enabled: next.includes('cloudflare') ? true : value.enabled,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{value.provider === 'cloudflare' ? (
|
||||
<Alert>
|
||||
<AlertTitle>Cloudflare Health Checks</AlertTitle>
|
||||
<AlertDescription>
|
||||
Поля соответствуют официальному API зоны. Если план не позволяет Health
|
||||
Checks, API вернёт ошибку — останется Local. Workers не используются.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertTitle>Local health-check</AlertTitle>
|
||||
<AlertDescription>
|
||||
Интервал и таймаут пробы — ниже. Cron и пороги Slow/Down задаются в{' '}
|
||||
<Link to="/settings/health" className="text-foreground underline">
|
||||
Настройках → Health-check
|
||||
</Link>
|
||||
, как параметры Cloudflare Health Checks в этой форме.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{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
|
||||
title="Health-check"
|
||||
@@ -254,25 +310,30 @@ export function HealthCheckConfigFields({
|
||||
<div className="flex flex-col gap-3 pt-1 pb-1">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
|
||||
<Select
|
||||
modal={false}
|
||||
value={value.type}
|
||||
onValueChange={(v) => patch({ type: (v ?? 'tcp') as HealthCheckType })}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-type`} className="w-full">
|
||||
<SelectValue placeholder="Тип" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(value.provider === 'cloudflare'
|
||||
? cloudflareTypes
|
||||
: healthCheckTypes
|
||||
).map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<ButtonGroup id={`${idPrefix}-type`} className="w-full min-w-0">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={value.type === 'tcp' ? 'secondary' : 'outline'}
|
||||
className="flex-1"
|
||||
aria-pressed={value.type === 'tcp'}
|
||||
onClick={() => patch({ type: 'tcp' })}
|
||||
>
|
||||
<CableIcon data-icon="inline-start" />
|
||||
TCP
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={value.type === 'http' ? 'secondary' : 'outline'}
|
||||
className="flex-1"
|
||||
aria-pressed={value.type === 'http'}
|
||||
onClick={() => patch({ type: 'http' })}
|
||||
>
|
||||
<GlobeIcon data-icon="inline-start" />
|
||||
HTTP
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</FormFieldSimple>
|
||||
|
||||
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
|
||||
@@ -334,83 +395,18 @@ export function HealthCheckConfigFields({
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormFieldSimple label="Интервал, сек" htmlFor={`${idPrefix}-interval`}>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-interval`}
|
||||
value={value.interval_sec}
|
||||
min={5}
|
||||
max={3600}
|
||||
placeholder="30"
|
||||
onValueChange={(next) =>
|
||||
patch({ interval_sec: next ?? 30 })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Таймаут, мс" htmlFor={`${idPrefix}-timeout`}>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-timeout`}
|
||||
value={value.timeout_ms}
|
||||
min={100}
|
||||
max={30000}
|
||||
placeholder="3000"
|
||||
onValueChange={(next) =>
|
||||
patch({ timeout_ms: next ?? 3000 })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</div>
|
||||
{value.provider === 'cloudflare' ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormFieldSimple
|
||||
label="Retries"
|
||||
htmlFor={`${idPrefix}-retries`}
|
||||
hint="Cloudflare retries"
|
||||
>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-retries`}
|
||||
value={value.retries ?? 2}
|
||||
min={0}
|
||||
max={10}
|
||||
placeholder="2"
|
||||
onValueChange={(retries) => patch({ retries: retries ?? 2 })}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Successes"
|
||||
htmlFor={`${idPrefix}-successes`}
|
||||
hint="consecutive_successes"
|
||||
>
|
||||
<CompactNumberField
|
||||
id={`${idPrefix}-successes`}
|
||||
value={value.consecutive_successes ?? 2}
|
||||
min={1}
|
||||
max={20}
|
||||
placeholder="2"
|
||||
onValueChange={(consecutive_successes) =>
|
||||
patch({ consecutive_successes: consecutive_successes ?? 2 })
|
||||
}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</div>
|
||||
) : null}
|
||||
{value.provider === 'cloudflare' && isHttp ? (
|
||||
<FormFieldSimple label="HTTP method" htmlFor={`${idPrefix}-method`}>
|
||||
<Select
|
||||
modal={false}
|
||||
value={value.method ?? 'GET'}
|
||||
onValueChange={(v) => patch({ method: v ?? 'GET' })}
|
||||
>
|
||||
<SelectTrigger id={`${idPrefix}-method`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="HEAD">HEAD</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormFieldSimple>
|
||||
) : null}
|
||||
<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>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
@@ -23,19 +23,27 @@ export interface HealthTimelineEvent {
|
||||
latency_ms?: number | null
|
||||
error?: string | null
|
||||
checked_at: string
|
||||
colo?: string | null
|
||||
provider?: string | null
|
||||
}
|
||||
|
||||
interface HealthTimelineProps {
|
||||
events: HealthTimelineEvent[]
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
}
|
||||
|
||||
export function HealthTimeline({ events }: HealthTimelineProps) {
|
||||
export function HealthTimeline({
|
||||
events,
|
||||
emptyTitle = 'Нет событий',
|
||||
emptyDescription = 'Результаты проверок появятся после первого прогона',
|
||||
}: HealthTimelineProps) {
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Link2Icon}
|
||||
title="Нет событий"
|
||||
description="Результаты проверок появятся после первого прогона"
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
centered={false}
|
||||
/>
|
||||
)
|
||||
@@ -63,6 +71,8 @@ export function HealthTimeline({ events }: HealthTimelineProps) {
|
||||
<HealthCheckBadge
|
||||
status={event.status}
|
||||
latencyMs={event.latency_ms}
|
||||
colo={event.colo}
|
||||
provider={event.provider}
|
||||
size="xs"
|
||||
showLatency
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { CircleAlertIcon } from 'lucide-react'
|
||||
import {
|
||||
Alert,
|
||||
AlertAction,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
interface IncidentAlertProps {
|
||||
title: string
|
||||
description?: string
|
||||
onShow: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Баннер самого тяжёлого инцидента.
|
||||
* Preview: https://reui.io/docs/components/base/alert
|
||||
*/
|
||||
export function IncidentAlert({ title, description, onShow }: IncidentAlertProps) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon aria-hidden />
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
{description ? <AlertDescription>{description}</AlertDescription> : null}
|
||||
<AlertAction>
|
||||
<Button type="button" variant="outline" size="xs" onClick={onShow}>
|
||||
Показать
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import { TriangleAlertIcon } from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
@@ -57,11 +59,23 @@ export function VpsTrackerIntegrationCard({
|
||||
|
||||
const syncBadgeVariant = settings?.vpsTrackerSyncEnabled ? 'success' : 'secondary'
|
||||
|
||||
const hasUnsaved = form.formState.isDirty
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}
|
||||
>
|
||||
{hasUnsaved ? (
|
||||
<Alert variant="warning" role="status">
|
||||
<TriangleAlertIcon aria-hidden="true" />
|
||||
<AlertTitle>Есть несохранённые изменения</AlertTitle>
|
||||
<AlertDescription>
|
||||
URL, токен или синхронизация отличаются от последних сохранённых
|
||||
значений.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={syncBadgeVariant}>
|
||||
{settings?.vpsTrackerSyncEnabled ? 'Синхронизация включена' : 'Синхронизация выключена'}
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { useEffect, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { AppSidebar } from '@/components/app-sidebar'
|
||||
import { CommandPalette } from '@/components/layout/command-palette'
|
||||
import { SiteHeader } from '@/components/layout/site-header'
|
||||
import { SearchMenu } from '@/components/layout/search-menu'
|
||||
import { SystemAttentionProvider } from '@/components/layout/system-attention-provider'
|
||||
import { bindLiveRefresh } from '@/lib/live-refresh'
|
||||
import { TooltipProvider } from '@cfdm/ui/components/tooltip'
|
||||
import { SidebarInset, SidebarProvider } from '@cfdm/ui/components/sidebar'
|
||||
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||
|
||||
/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => bindLiveRefresh(queryClient), [queryClient])
|
||||
|
||||
return (
|
||||
<TooltipProvider delay={0}>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '240px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset id="main-content">
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<SearchMenu hotkeyOnly />
|
||||
</SidebarProvider>
|
||||
<SystemAttentionProvider>
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '240px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset id="main-content">
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<CommandPalette />
|
||||
</SidebarProvider>
|
||||
</SystemAttentionProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTheme } from 'next-themes'
|
||||
import {
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
LayoutDashboardIcon,
|
||||
PaletteIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@cfdm/ui/components/command'
|
||||
import {
|
||||
domainsListQueryOptions,
|
||||
groupsQueryOptions,
|
||||
serviceGroupsQueryOptions,
|
||||
} from '@/queries'
|
||||
|
||||
export const OPEN_COMMAND_PALETTE_EVENT = 'cfdm-open-command-palette'
|
||||
|
||||
const SECTION_ITEMS = [
|
||||
{
|
||||
to: '/',
|
||||
label: 'Панель',
|
||||
keywords: 'dashboard панель управление обзор',
|
||||
icon: LayoutDashboardIcon,
|
||||
},
|
||||
{
|
||||
to: '/domains',
|
||||
label: 'Домены',
|
||||
keywords: 'domains зоны cloudflare',
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
{
|
||||
to: '/services',
|
||||
label: 'Сервисы',
|
||||
keywords: 'services сервисы fqdn',
|
||||
icon: ServerIcon,
|
||||
},
|
||||
{
|
||||
to: '/groups',
|
||||
label: 'Группы',
|
||||
keywords: 'groups группы доменов',
|
||||
icon: FolderTreeIcon,
|
||||
},
|
||||
{
|
||||
to: '/certificates',
|
||||
label: 'Сертификаты',
|
||||
keywords: 'certificates ssl tls',
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
{
|
||||
to: '/settings/appearance',
|
||||
label: 'Настройки',
|
||||
keywords: 'settings настройки',
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
] as const
|
||||
|
||||
type PaletteTarget = (typeof SECTION_ITEMS)[number]['to']
|
||||
|
||||
/** Палитра ⌘K / Ctrl+K. Кнопки поиска в шапке нет. */
|
||||
export function CommandPalette() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const domainsQuery = useQuery({ ...domainsListQueryOptions(), enabled: open })
|
||||
const groupsQuery = useQuery({ ...groupsQueryOptions(), enabled: open })
|
||||
const servicesQuery = useQuery({ ...serviceGroupsQueryOptions(), enabled: open })
|
||||
|
||||
const services = useMemo(() => {
|
||||
const data = servicesQuery.data
|
||||
if (!data) return []
|
||||
return [...data.groups.flatMap((group) => group.services), ...data.ungrouped]
|
||||
}, [servicesQuery.data])
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.repeat) return
|
||||
if (event.key.toLowerCase() !== 'k' || !(event.metaKey || event.ctrlKey)) return
|
||||
event.preventDefault()
|
||||
setOpen((current) => !current)
|
||||
}
|
||||
|
||||
function onOpen() {
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpen)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener(OPEN_COMMAND_PALETTE_EVENT, onOpen)
|
||||
}
|
||||
}, [])
|
||||
|
||||
function go(to: PaletteTarget) {
|
||||
setOpen(false)
|
||||
void navigate({ to })
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Палитра команд"
|
||||
description="Разделы и действия приложения"
|
||||
className="sm:max-w-md"
|
||||
>
|
||||
<Command key={open ? 'open' : 'closed'}>
|
||||
<CommandInput autoFocus placeholder="Раздел, домен, сервис или действие…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Ничего не найдено</CommandEmpty>
|
||||
<CommandGroup heading="Действия">
|
||||
<CommandItem value="Синхронизировать домены" onSelect={() => go('/domains')}>
|
||||
<GlobeIcon />
|
||||
<span>Синхронизировать домены</span>
|
||||
</CommandItem>
|
||||
<CommandItem value="Создать сервис" onSelect={() => go('/services')}>
|
||||
<ServerIcon />
|
||||
<span>Создать сервис</span>
|
||||
</CommandItem>
|
||||
<CommandItem value="Создать группу" onSelect={() => go('/groups')}>
|
||||
<FolderTreeIcon />
|
||||
<span>Создать группу</span>
|
||||
</CommandItem>
|
||||
<CommandItem value="Проверить сертификаты" onSelect={() => go('/certificates')}>
|
||||
<ShieldCheckIcon />
|
||||
<span>Проверить сертификаты</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
value="Переключить тему"
|
||||
onSelect={() => {
|
||||
setTheme(theme === 'light' ? 'dark' : 'light')
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<PaletteIcon />
|
||||
<span>Переключить тему</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Разделы">
|
||||
{SECTION_ITEMS.map((item) => (
|
||||
<CommandItem
|
||||
key={item.to}
|
||||
value={`${item.label} ${item.keywords}`}
|
||||
onSelect={() => go(item.to)}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Домены">
|
||||
{(domainsQuery.data ?? []).slice(0, 8).map((domain) => (
|
||||
<CommandItem
|
||||
key={domain.id}
|
||||
value={`домен ${domain.zone_name}`}
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
void navigate({
|
||||
to: '/domains/$domainId',
|
||||
params: { domainId: String(domain.id) },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<GlobeIcon />
|
||||
<span>{domain.zone_name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Сервисы">
|
||||
{services.slice(0, 8).map((service) => (
|
||||
<CommandItem
|
||||
key={service.id}
|
||||
value={`сервис ${service.name}`}
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
void navigate({
|
||||
to: '/services/$serviceId',
|
||||
params: { serviceId: String(service.id) },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<ServerIcon />
|
||||
<span>{service.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandGroup heading="Группы">
|
||||
{(groupsQuery.data ?? []).slice(0, 8).map((group) => (
|
||||
<CommandItem
|
||||
key={group.id}
|
||||
value={`группа ${group.name} ${group.slug}`}
|
||||
onSelect={() => {
|
||||
setOpen(false)
|
||||
void navigate({
|
||||
to: '/groups/$groupId',
|
||||
params: { groupId: String(group.id) },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<FolderTreeIcon />
|
||||
<span>{group.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { formatRelative } from '@/lib/format'
|
||||
import { isLiveRefresh, setLiveRefresh } from '@/lib/live-refresh'
|
||||
|
||||
function useLatestDataUpdatedAt() {
|
||||
const queryClient = useQueryClient()
|
||||
const [updatedAt, setUpdatedAt] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const cache = queryClient.getQueryCache()
|
||||
let queued = false
|
||||
let active = true
|
||||
const read = () => {
|
||||
let latest = 0
|
||||
for (const query of cache.getAll()) {
|
||||
if (query.getObserversCount() === 0) continue
|
||||
if (query.state.dataUpdatedAt > latest) latest = query.state.dataUpdatedAt
|
||||
}
|
||||
setUpdatedAt((prev) => (prev === latest ? prev : latest))
|
||||
}
|
||||
// Cache notifies synchronously while another route renders (observerAdded).
|
||||
const schedule = () => {
|
||||
if (queued) return
|
||||
queued = true
|
||||
queueMicrotask(() => {
|
||||
queued = false
|
||||
if (active) read()
|
||||
})
|
||||
}
|
||||
read()
|
||||
const unsubscribe = cache.subscribe(schedule)
|
||||
return () => {
|
||||
active = false
|
||||
unsubscribe()
|
||||
}
|
||||
}, [queryClient])
|
||||
|
||||
return updatedAt
|
||||
}
|
||||
|
||||
function formatUpdatedAt(updatedAt: number) {
|
||||
if (updatedAt === 0) return '—'
|
||||
return formatRelative(new Date(updatedAt).toISOString())
|
||||
}
|
||||
|
||||
/** Компактный статус свежести слева от AppsMenu. */
|
||||
export function DataFreshness() {
|
||||
const queryClient = useQueryClient()
|
||||
const updatedAt = useLatestDataUpdatedAt()
|
||||
const [live, setLive] = useState(() => isLiveRefresh())
|
||||
const [label, setLabel] = useState('—')
|
||||
|
||||
useEffect(() => {
|
||||
const read = () => setLabel(formatUpdatedAt(updatedAt))
|
||||
read()
|
||||
if (updatedAt === 0) return
|
||||
const timer = window.setInterval(read, 15_000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [updatedAt])
|
||||
|
||||
function handleCheckedChange(checked: boolean) {
|
||||
const next = Boolean(checked)
|
||||
setLiveRefresh(queryClient, next)
|
||||
setLive(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-muted-foreground hidden items-center gap-1.5 text-xs sm:flex">
|
||||
<span className="tabular-nums">
|
||||
Обновлено{' '}
|
||||
<time dateTime={updatedAt ? new Date(updatedAt).toISOString() : undefined}>
|
||||
{label}
|
||||
</time>
|
||||
</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{live ? 'Live' : 'Пауза'}</span>
|
||||
</p>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={live}
|
||||
onCheckedChange={handleCheckedChange}
|
||||
aria-label="Автообновление"
|
||||
/>
|
||||
<span className="text-muted-foreground hidden text-xs sm:inline">авто</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import {
|
||||
FolderTreeIcon,
|
||||
GlobeIcon,
|
||||
HeartPulseIcon,
|
||||
LayoutDashboardIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@cfdm/ui/components/dialog'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@cfdm/ui/components/item'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{
|
||||
to: '/',
|
||||
label: 'Панель управления',
|
||||
keywords: ['dashboard', 'панель', 'обзор'],
|
||||
icon: LayoutDashboardIcon,
|
||||
},
|
||||
{
|
||||
to: '/domains',
|
||||
label: 'Домены',
|
||||
keywords: ['domains', 'зоны', 'cloudflare'],
|
||||
icon: GlobeIcon,
|
||||
},
|
||||
{
|
||||
to: '/groups',
|
||||
label: 'Группы доменов',
|
||||
keywords: ['groups', 'группы', 'канбан'],
|
||||
icon: FolderTreeIcon,
|
||||
},
|
||||
{
|
||||
to: '/services',
|
||||
label: 'Сервисы',
|
||||
keywords: ['services', 'сервисы', 'fqdn'],
|
||||
icon: ServerIcon,
|
||||
},
|
||||
{
|
||||
to: '/certificates',
|
||||
label: 'Сертификаты',
|
||||
keywords: ['certificates', 'ssl', 'tls'],
|
||||
icon: ShieldCheckIcon,
|
||||
},
|
||||
{
|
||||
to: '/settings/health',
|
||||
label: 'Health-check',
|
||||
keywords: ['health', 'health-check', 'cron', 'пороги', 'настройки'],
|
||||
icon: HeartPulseIcon,
|
||||
},
|
||||
{
|
||||
to: '/settings/integrations',
|
||||
label: 'Настройки',
|
||||
keywords: ['settings', 'интеграции', 'app switcher'],
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
] as const
|
||||
|
||||
/** Command-K search — hotkey dialog (no header chrome trigger). Etalon: EvoBGP CommandPalette hotkeyOnly. */
|
||||
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInputId = useId()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setQuery('')
|
||||
}, [open])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return NAV_ITEMS
|
||||
return NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
item.keywords.some((k) => k.includes(q)),
|
||||
)
|
||||
}, [query])
|
||||
|
||||
function goTo(to: string) {
|
||||
setOpen(false)
|
||||
void navigate({ to })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hotkeyOnly ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Поиск"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<SearchIcon
|
||||
className="size-4.5 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Поиск</DialogTitle>
|
||||
<DialogDescription>
|
||||
Переход к разделам приложения
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
|
||||
<div className="relative flex items-center gap-3 border-b px-4 py-2">
|
||||
<SearchIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none size-4 opacity-60 select-none"
|
||||
/>
|
||||
<Input
|
||||
id={searchInputId}
|
||||
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
||||
autoFocus
|
||||
placeholder="Перейти к разделу…"
|
||||
aria-label="Поиск разделов"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filtered[0]) {
|
||||
e.preventDefault()
|
||||
goTo(filtered[0].to)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ItemGroup className="max-h-72 overflow-y-auto p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||
Ничего не найдено
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<Item
|
||||
key={item.to}
|
||||
size="sm"
|
||||
variant="muted"
|
||||
className="cursor-pointer border-0"
|
||||
render={<Link to={item.to} onClick={() => setOpen(false)} />}
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<item.icon aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{item.label}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))
|
||||
)}
|
||||
</ItemGroup>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { Link, useMatches, useRouterState } from '@tanstack/react-router'
|
||||
import { useMemo } from 'react'
|
||||
import { MenuIcon } from 'lucide-react'
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -9,78 +10,18 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from '@cfdm/ui/components/breadcrumb'
|
||||
import { Separator } from '@cfdm/ui/components/separator'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||
import { DataFreshness } from '@/components/layout/data-freshness'
|
||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { SidebarTrigger } from '@cfdm/ui/components/sidebar'
|
||||
import { OPEN_COMMAND_PALETTE_EVENT } from '@/components/layout/command-palette'
|
||||
import { getBreadcrumbs } from '@/lib/breadcrumbs'
|
||||
|
||||
export interface RouteBreadcrumbLoaderData {
|
||||
breadcrumb?: string
|
||||
}
|
||||
|
||||
const routeTitles: Record<string, string> = {
|
||||
'/': 'Панель управления',
|
||||
'/domains': 'Домены',
|
||||
'/groups': 'Группы доменов',
|
||||
'/services': 'Сервисы',
|
||||
'/certificates': 'Сертификаты',
|
||||
'/settings/appearance': 'Внешний вид',
|
||||
'/settings/health': 'Health-check',
|
||||
'/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/health'
|
||||
? [{ label: 'Health-check', href: pathname }]
|
||||
: pathname === '/settings/appearance'
|
||||
? [{ label: 'Внешний вид', href: pathname }]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
const title = routeTitles[pathname]
|
||||
if (title) {
|
||||
return [{ label: title, href: pathname }]
|
||||
}
|
||||
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
function useDynamicBreadcrumbLabels() {
|
||||
const matches = useMatches()
|
||||
return useMemo(() => {
|
||||
@@ -95,25 +36,37 @@ function useDynamicBreadcrumbLabels() {
|
||||
}, [matches])
|
||||
}
|
||||
|
||||
/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */
|
||||
/** Header chrome — freshness + AppsMenu + SystemMonitor; theme in NavUser (app-shell-1). */
|
||||
export function SiteHeader() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const dynamicLabels = useDynamicBreadcrumbLabels()
|
||||
const crumbs = getBreadcrumbs(pathname, dynamicLabels)
|
||||
const crumbs = useMemo(
|
||||
() => getBreadcrumbs(pathname, dynamicLabels),
|
||||
[pathname, dynamicLabels],
|
||||
)
|
||||
|
||||
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">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Меню и поиск"
|
||||
onClick={() => window.dispatchEvent(new Event(OPEN_COMMAND_PALETTE_EVENT))}
|
||||
>
|
||||
<MenuIcon aria-hidden />
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{crumbs.map((crumb, index) => {
|
||||
const isLast = index === crumbs.length - 1
|
||||
return (
|
||||
<span key={crumb.href} className="contents">
|
||||
{index > 0 && (
|
||||
<Fragment key={`${index}-${crumb.href}`}>
|
||||
{index > 0 ? (
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
)}
|
||||
) : null}
|
||||
<BreadcrumbItem
|
||||
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
|
||||
>
|
||||
@@ -125,12 +78,13 @@ export function SiteHeader() {
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</span>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<DataFreshness />
|
||||
<AppsMenu />
|
||||
<SystemMonitorPopover />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
SystemAttentionContext,
|
||||
useSystemAttentionData,
|
||||
} from '@/hooks/use-system-attention'
|
||||
|
||||
export function SystemAttentionProvider({ children }: { children: ReactNode }) {
|
||||
const attention = useSystemAttentionData()
|
||||
return (
|
||||
<SystemAttentionContext.Provider value={attention}>
|
||||
{children}
|
||||
</SystemAttentionContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Activity,
|
||||
HeartPulse,
|
||||
@@ -8,18 +8,14 @@ import {
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { useSystemAttention } from '@/hooks/use-system-attention'
|
||||
import type { SystemIncident } from '@/lib/system-attention'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@cfdm/ui/components/item'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
|
||||
import { Progress } from '@cfdm/ui/components/progress'
|
||||
|
||||
import {
|
||||
certSummaryQueryOptions,
|
||||
domainsListQueryOptions,
|
||||
notificationLogQueryOptions,
|
||||
serviceGroupsQueryOptions,
|
||||
} from '@/queries'
|
||||
const VISIBLE_INCIDENTS = 8
|
||||
|
||||
type MonitorMetric = {
|
||||
id: string
|
||||
@@ -32,12 +28,6 @@ type MonitorMetric = {
|
||||
alert: boolean
|
||||
}
|
||||
|
||||
type ReadyResponse = {
|
||||
status: string
|
||||
database?: boolean
|
||||
cloudflare?: boolean
|
||||
}
|
||||
|
||||
function toneColor(tone: MonitorMetric['tone']) {
|
||||
switch (tone) {
|
||||
case 'success':
|
||||
@@ -81,110 +71,95 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
)
|
||||
}
|
||||
|
||||
function countByStatus(summary: [string, number][] | undefined, statuses: string[]) {
|
||||
if (!summary) return 0
|
||||
return summary
|
||||
.filter(([status]) => statuses.includes(status))
|
||||
.reduce((sum, [, count]) => sum + count, 0)
|
||||
function IncidentLink({ incident }: { incident: SystemIncident }) {
|
||||
const className =
|
||||
'hover:bg-muted flex items-center gap-2 rounded-md px-1.5 py-1 text-xs'
|
||||
const body = (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
incident.tone === 'destructive' ? 'bg-destructive' : 'bg-warning',
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{incident.title}</span>
|
||||
<span className="text-muted-foreground shrink-0">{incident.detail}</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (incident.href.to === '/certificates') {
|
||||
return (
|
||||
<Link to="/certificates" className={className}>
|
||||
{body}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: incident.href.serviceId }}
|
||||
className={className}
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
/** Live system monitor popover (app-shell pattern, CFDM API data). */
|
||||
export function SystemMonitorPopover() {
|
||||
const readyQ = useQuery({
|
||||
queryKey: ['ready'],
|
||||
queryFn: async (): Promise<ReadyResponse> => {
|
||||
const res = await fetch('/ready')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json() as Promise<ReadyResponse>
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
const servicesQ = useQuery({ ...serviceGroupsQueryOptions(), refetchInterval: 30_000 })
|
||||
const certsQ = useQuery({ ...certSummaryQueryOptions(), refetchInterval: 30_000 })
|
||||
const domainsQ = useQuery({ ...domainsListQueryOptions(), refetchInterval: 30_000 })
|
||||
const notifyQ = useQuery({ ...notificationLogQueryOptions(20), refetchInterval: 30_000 })
|
||||
|
||||
const readyOk = readyQ.data?.status === 'ready' && readyQ.isSuccess
|
||||
const readyDegraded = readyQ.data?.status === 'degraded' || readyQ.isError
|
||||
|
||||
let healthDown = 0
|
||||
let healthDegraded = 0
|
||||
for (const group of servicesQ.data?.groups ?? []) {
|
||||
for (const service of group.services) {
|
||||
if (service.health_status === 'down') healthDown += 1
|
||||
if (service.health_status === 'degraded') healthDegraded += 1
|
||||
}
|
||||
}
|
||||
for (const service of servicesQ.data?.ungrouped ?? []) {
|
||||
if (service.health_status === 'down') healthDown += 1
|
||||
if (service.health_status === 'degraded') healthDegraded += 1
|
||||
}
|
||||
const healthAlert = healthDown > 0
|
||||
const healthWarn = healthDegraded > 0
|
||||
|
||||
const certWarnings = countByStatus(certsQ.data, ['warning', 'expired', 'error'])
|
||||
const failedJobs = (notifyQ.data ?? []).filter((n) =>
|
||||
/fail|error/i.test(`${n.kind} ${n.title} ${n.message}`),
|
||||
).length
|
||||
const ungroupedCount = (domainsQ.data ?? []).filter((d) => d.group_id == null).length
|
||||
const attentionCount = healthDown + healthDegraded
|
||||
const attention = useSystemAttention()
|
||||
const visibleIncidents = attention.incidents.slice(0, VISIBLE_INCIDENTS)
|
||||
const hiddenIncidents = attention.incidents.length - visibleIncidents.length
|
||||
|
||||
const metrics = useMemo<MonitorMetric[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'ready',
|
||||
label: 'Ready',
|
||||
value: readyOk ? 'OK' : readyDegraded ? '!' : '—',
|
||||
value: attention.readyOk ? 'OK' : attention.readyDegraded ? '!' : '—',
|
||||
unit: '',
|
||||
percent: readyOk ? 100 : readyDegraded ? 40 : 0,
|
||||
percent: attention.readyOk ? 100 : attention.readyDegraded ? 40 : 0,
|
||||
icon: <HeartPulse aria-hidden />,
|
||||
tone: readyOk ? 'success' : 'destructive',
|
||||
alert: !readyOk,
|
||||
tone: attention.readyOk ? 'success' : 'destructive',
|
||||
alert: !attention.readyOk,
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
label: 'Health-check',
|
||||
value: String(healthDown + healthDegraded),
|
||||
value: String(attention.servicesDown + attention.servicesDegraded),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, (healthDown + healthDegraded) * 20),
|
||||
percent: Math.min(100, (attention.servicesDown + attention.servicesDegraded) * 20),
|
||||
icon: <Server aria-hidden />,
|
||||
tone: healthAlert ? 'destructive' : healthWarn ? 'warning' : 'success',
|
||||
alert: healthAlert || healthWarn,
|
||||
tone: attention.servicesDown > 0 ? 'destructive' : attention.servicesDegraded > 0 ? 'warning' : 'success',
|
||||
alert: attention.servicesDown > 0 || attention.servicesDegraded > 0,
|
||||
},
|
||||
{
|
||||
id: 'certs',
|
||||
label: 'Сертификаты',
|
||||
value: String(certWarnings),
|
||||
value: String(attention.certWarnings),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, certWarnings * 20),
|
||||
percent: Math.min(100, attention.certWarnings * 20),
|
||||
icon: <ShieldCheck aria-hidden />,
|
||||
tone: certWarnings > 0 ? 'warning' : 'success',
|
||||
alert: certWarnings > 0,
|
||||
tone: attention.certWarnings > 0 ? 'warning' : 'success',
|
||||
alert: attention.certWarnings > 0,
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'Ошибки',
|
||||
value: String(failedJobs),
|
||||
value: String(attention.failedJobs),
|
||||
unit: 'шт.',
|
||||
percent: Math.min(100, failedJobs * 15),
|
||||
percent: Math.min(100, attention.failedJobs * 15),
|
||||
icon: <ListChecks aria-hidden />,
|
||||
tone: failedJobs > 0 ? 'warning' : 'success',
|
||||
alert: failedJobs > 0,
|
||||
tone: attention.failedJobs > 0 ? 'warning' : 'success',
|
||||
alert: attention.failedJobs > 0,
|
||||
},
|
||||
],
|
||||
[
|
||||
certWarnings,
|
||||
failedJobs,
|
||||
healthAlert,
|
||||
healthDegraded,
|
||||
healthDown,
|
||||
healthWarn,
|
||||
readyDegraded,
|
||||
readyOk,
|
||||
],
|
||||
[attention],
|
||||
)
|
||||
|
||||
const spiking = metrics.some((m) => m.alert)
|
||||
const spiking = metrics.some((metric) => metric.alert)
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
@@ -192,7 +167,11 @@ export function SystemMonitorPopover() {
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Монитор системы"
|
||||
aria-label={
|
||||
spiking
|
||||
? `Состояние системы: внимание, ошибок ${attention.servicesDown}, предупреждений ${attention.servicesDegraded + attention.certWarnings}`
|
||||
: 'Состояние системы: норма'
|
||||
}
|
||||
className={cn(
|
||||
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
|
||||
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
|
||||
@@ -212,14 +191,15 @@ export function SystemMonitorPopover() {
|
||||
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
|
||||
<Badge
|
||||
variant={spiking ? 'destructive-light' : 'success-light'}
|
||||
size="xs"
|
||||
className="h-4 px-1.5 text-[10px]"
|
||||
>
|
||||
<span className="text-foreground hidden text-xs font-medium sm:inline">
|
||||
{spiking ? 'Внимание' : 'Норма'}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="text-muted-foreground hidden items-center gap-1 text-[11px] tabular-nums md:inline-flex">
|
||||
<i className="bg-destructive inline-block size-1.5 rounded-full" aria-hidden />
|
||||
{attention.servicesDown}
|
||||
<i className="bg-warning inline-block size-1.5 rounded-full" aria-hidden />
|
||||
{attention.servicesDegraded + attention.certWarnings}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
||||
@@ -242,12 +222,29 @@ export function SystemMonitorPopover() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-border flex flex-col gap-1 border-t px-2 py-2">
|
||||
<span className="text-muted-foreground px-1.5 text-xs font-medium">Инциденты</span>
|
||||
{visibleIncidents.length === 0 ? (
|
||||
<p className="text-muted-foreground px-1.5 py-1 text-xs">Нет инцидентов</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{visibleIncidents.map((incident) => (
|
||||
<IncidentLink key={incident.id} incident={incident} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hiddenIncidents > 0 ? (
|
||||
<p className="text-muted-foreground px-1.5 text-xs">Ещё {hiddenIncidents}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
||||
Без группы:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">{ungroupedCount}</span>
|
||||
<span className="text-foreground font-medium tabular-nums">{attention.ungroupedCount}</span>
|
||||
{' · '}
|
||||
Внимание:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">{attentionCount}</span>
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
{attention.servicesDown + attention.servicesDegraded}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -41,6 +41,14 @@ import {
|
||||
redirectToPortalLogout,
|
||||
resetPortalHandoff,
|
||||
} from '@/lib/auth'
|
||||
import {
|
||||
readContrast,
|
||||
readDensity,
|
||||
writeContrast,
|
||||
writeDensity,
|
||||
type ContrastMode,
|
||||
type DensityMode,
|
||||
} from '@/lib/appearance-mode'
|
||||
|
||||
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
|
||||
|
||||
@@ -107,6 +115,98 @@ function ThemeSegmentedToggle() {
|
||||
)
|
||||
}
|
||||
|
||||
function SegmentedChoice<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: T
|
||||
options: readonly { value: T; label: string }[]
|
||||
onChange: (value: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isActive = value === option.value
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
size="xs"
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppearanceToggles() {
|
||||
const [contrast, setContrast] = useState<ContrastMode>('standard')
|
||||
const [density, setDensity] = useState<DensityMode>('normal')
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setContrast(readContrast())
|
||||
setDensity(readDensity())
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<span className="text-sm">Контраст</span>
|
||||
<div className="ml-auto">
|
||||
<SegmentedChoice
|
||||
label="Контраст"
|
||||
value={contrast}
|
||||
options={[
|
||||
{ value: 'standard', label: 'Обычный' },
|
||||
{ value: 'high', label: 'Высокий' },
|
||||
]}
|
||||
onChange={(next) => {
|
||||
setContrast(next)
|
||||
writeContrast(next)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<span className="text-sm">Плотность</span>
|
||||
<div className="ml-auto">
|
||||
<SegmentedChoice
|
||||
label="Плотность"
|
||||
value={density}
|
||||
options={[
|
||||
{ value: 'normal', label: 'Обычная' },
|
||||
{ value: 'compact', label: 'Компакт' },
|
||||
]}
|
||||
onChange={(next) => {
|
||||
setDensity(next)
|
||||
writeDensity(next)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function initials(name: string, email: string): string {
|
||||
const base = name.trim() || email.trim()
|
||||
if (!base) return '?'
|
||||
@@ -202,6 +302,7 @@ export function NavUser() {
|
||||
<ThemeSegmentedToggle />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<AppearanceToggles />
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -16,7 +16,9 @@ export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { CircleAlertIcon, type LucideIcon } from 'lucide-react'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
@@ -9,12 +14,14 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
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
|
||||
* Each column is its own Frame; children must not wrap another Frame.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/dashboard-1
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/data-grid
|
||||
*/
|
||||
export interface AttentionQueueColumn {
|
||||
id: string
|
||||
@@ -26,6 +33,9 @@ export interface AttentionQueueColumn {
|
||||
emptyTitle: string
|
||||
emptyDescription: string
|
||||
emptyAction?: ReactNode
|
||||
isError?: boolean
|
||||
errorMessage?: string
|
||||
onRetry?: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
@@ -40,13 +50,14 @@ export function AttentionQueue({ columns, className }: AttentionQueueProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid min-w-0 items-start gap-2 @3xl:grid-cols-3',
|
||||
'flex min-w-0 flex-col items-stretch gap-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const Icon = column.icon
|
||||
const isEmpty = column.count === 0
|
||||
const isError = column.isError === true
|
||||
const isEmpty = !isError && column.count === 0
|
||||
return (
|
||||
<Frame key={column.id} dense spacing="sm" className="min-w-0 w-full">
|
||||
<FrameHeader>
|
||||
@@ -71,8 +82,30 @@ export function AttentionQueue({ columns, className }: AttentionQueueProps) {
|
||||
) : null}
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="min-w-0">
|
||||
{isEmpty ? (
|
||||
<FramePanel className={cn('min-w-0', !isEmpty && !isError && 'p-0')}>
|
||||
{isError ? (
|
||||
<div className="flex min-h-28 items-center py-4">
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col items-start gap-2">
|
||||
<span>
|
||||
{column.errorMessage ?? 'Не удалось загрузить данные'}
|
||||
</span>
|
||||
{column.onRetry ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={column.onRetry}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<div className="flex min-h-28 items-center justify-center py-4">
|
||||
<EmptyState
|
||||
icon={Icon}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useEffect, useRef } from 'react'
|
||||
import { ActivityIcon } from 'lucide-react'
|
||||
import { Bar, BarChart, CartesianGrid, Cell, Pie, PieChart, XAxis } from 'recharts'
|
||||
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
@@ -229,3 +230,31 @@ export function GroupDomainsChart({ data }: GroupDomainsChartProps) {
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Блок «Здоровье нод» в форме area-chart (chart-17): заголовок и легенда.
|
||||
* Почасового ряда Healthy/Unhealthy API не отдаёт — пустое состояние, без выдуманных точек.
|
||||
* Preview: https://reui.io/preview/base/chart-17
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function NodeHealthChart() {
|
||||
return (
|
||||
<Frame className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Здоровье нод</FrameTitle>
|
||||
<FrameDescription>
|
||||
Healthy / Unhealthy по проверкам health-check · последние 24 часа
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex min-h-52 flex-col">
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title="Нет ряда за 24 часа"
|
||||
description="Сводка нод есть. Почасовой ряд Healthy и Unhealthy API не отдаёт, поэтому точки не подставляются."
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
} from '@tanstack/react-table'
|
||||
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { formatRelative, sqliteUtcToIso } from '@/lib/format'
|
||||
import type { Certificate, DomainListItem, ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
|
||||
/**
|
||||
* Compact queue tables for the dashboard. Parent Frame owns the shell —
|
||||
* this grid does not render another Frame.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* Docs: https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
|
||||
function formatWhen(value: string | null | undefined) {
|
||||
if (!value) return '—'
|
||||
return formatRelative(sqliteUtcToIso(value) ?? value)
|
||||
}
|
||||
|
||||
function nodeLabel(service: ServiceView) {
|
||||
const health = service.ip_health
|
||||
const total = Math.max(service.ips.length, health.length)
|
||||
if (total === 0) return '—'
|
||||
if (health.length === 0) return String(total)
|
||||
const healthy = health.filter((row) => row.status === 'up').length
|
||||
return `${healthy}/${total}`
|
||||
}
|
||||
|
||||
function latestCheck(service: ServiceView) {
|
||||
let checkedAt: string | null = null
|
||||
for (const row of service.ip_health) {
|
||||
if (!row.last_checked_at) continue
|
||||
if (!checkedAt || row.last_checked_at > checkedAt) checkedAt = row.last_checked_at
|
||||
}
|
||||
return checkedAt
|
||||
}
|
||||
|
||||
function QueueGrid<T extends object>({
|
||||
data,
|
||||
columns,
|
||||
getRowId,
|
||||
}: {
|
||||
data: T[]
|
||||
columns: ColumnDef<T, unknown>[]
|
||||
getRowId: (row: T) => string
|
||||
}) {
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getRowId,
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
tableLayout={{
|
||||
dense: true,
|
||||
headerBorder: true,
|
||||
rowBorder: true,
|
||||
width: 'auto',
|
||||
}}
|
||||
>
|
||||
<DataGridScrollArea orientation="horizontal">
|
||||
<DataGridTable />
|
||||
</DataGridScrollArea>
|
||||
</DataGrid>
|
||||
)
|
||||
}
|
||||
|
||||
interface HealthProblemGridProps {
|
||||
services: ServiceView[]
|
||||
}
|
||||
|
||||
export function HealthProblemGrid({ services }: HealthProblemGridProps) {
|
||||
const columns = useMemo<ColumnDef<ServiceView, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
accessorKey: 'name',
|
||||
size: 140,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервис" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto max-w-full truncate p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link to="/services" search={{ serviceId: row.original.id }} />
|
||||
}
|
||||
>
|
||||
{row.original.name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'health_status',
|
||||
size: 96,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Статус" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<HealthCheckBadge status={row.original.health_status} size="xs" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'latency',
|
||||
accessorKey: 'health_latency_ms',
|
||||
size: 88,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Задержка" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{row.original.health_latency_ms != null
|
||||
? `${row.original.health_latency_ms} мс`
|
||||
: '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'nodes',
|
||||
accessorFn: (row) => Math.max(row.ips.length, row.ip_health.length),
|
||||
size: 72,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Ноды" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{nodeLabel(row.original)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'checked',
|
||||
accessorFn: (row) => latestCheck(row) ?? '',
|
||||
size: 104,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Когда" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatWhen(latestCheck(row.original))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<QueueGrid
|
||||
data={services}
|
||||
columns={columns}
|
||||
getRowId={(row) => String(row.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ExpiringCertGridProps {
|
||||
certs: Certificate[]
|
||||
}
|
||||
|
||||
export function ExpiringCertGrid({ certs }: ExpiringCertGridProps) {
|
||||
const columns = useMemo<ColumnDef<Certificate, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'hostname',
|
||||
accessorKey: 'hostname',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Хост" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto max-w-full truncate p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={<Link to="/certificates" />}
|
||||
>
|
||||
{row.original.hostname}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Статус" />
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'service',
|
||||
accessorFn: (row) => row.service_name ?? '',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервис" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="truncate">
|
||||
{row.original.service_name?.trim() || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'expires',
|
||||
accessorKey: 'expires_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Истекает" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatWhen(row.original.expires_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<QueueGrid data={certs} columns={columns} getRowId={(row) => String(row.id)} />
|
||||
)
|
||||
}
|
||||
|
||||
interface UngroupedDomainGridProps {
|
||||
domains: DomainListItem[]
|
||||
}
|
||||
|
||||
export function UngroupedDomainGrid({ domains }: UngroupedDomainGridProps) {
|
||||
const columns = useMemo<ColumnDef<DomainListItem, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'zone_name',
|
||||
accessorKey: 'zone_name',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Домен" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto max-w-full truncate p-0 font-medium"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
to="/domains/$domainId"
|
||||
params={{ domainId: String(row.original.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{row.original.zone_name}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Статус" />
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'services',
|
||||
accessorKey: 'service_count',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Сервисы" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.service_count}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'synced',
|
||||
accessorKey: 'last_synced_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Синхронизация" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatWhen(row.original.last_synced_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<QueueGrid
|
||||
data={domains}
|
||||
columns={columns}
|
||||
getRowId={(row) => String(row.id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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,5 +1,8 @@
|
||||
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 { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||
export { CertStatusChart, GroupDomainsChart, NodeHealthChart } from './dashboard-analytics'
|
||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||
export {
|
||||
KpiStatGrid,
|
||||
@@ -14,7 +17,21 @@ export {
|
||||
} from './kpi-stat-grid'
|
||||
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||
export { AttentionQueue, type AttentionQueueColumn } from './attention-queue'
|
||||
export {
|
||||
ExpiringCertGrid,
|
||||
HealthProblemGrid,
|
||||
UngroupedDomainGrid,
|
||||
} from './dashboard-queue-grid'
|
||||
export { OpsDashboard } from './ops-dashboard'
|
||||
export { KanbanBoard, KanbanBoardSkeleton, type KanbanBoardProps, type KanbanColumnConfig } from './kanban-board'
|
||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
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'
|
||||
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user