Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3da6de9311 | ||
|
|
7a8bacade9 | ||
|
|
5d84c7bf6c | ||
|
|
78811bc9b1 | ||
|
|
a458465153 | ||
|
|
69119a08a4 | ||
|
|
ba03d2be9d | ||
|
|
d8fc4ac949 | ||
|
|
d063323402 | ||
|
|
6bced71037 | ||
|
|
44d0eb0114 | ||
|
|
9f00dfcf84 | ||
|
|
9b9dcc3b12 | ||
|
|
6008cd763a | ||
|
|
b9bea44dce |
@@ -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
|
||||
|
||||
|
||||
@@ -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,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) {
|
||||
@@ -69,10 +70,31 @@ export async function serviceRoutes(app: FastifyInstance) {
|
||||
const { id } = request.params as { id: string };
|
||||
repos.getService(request.server.db, Number(id));
|
||||
return {
|
||||
items: repos.listHealthProbeLogForService(request.server.db, Number(id)),
|
||||
items: repos.listHealthProbeLogForService(
|
||||
request.server.db,
|
||||
Number(id),
|
||||
200,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
app.get("/services/:id/certificates", async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
return certificateService.listServiceCertificates(
|
||||
request.server.db,
|
||||
Number(id),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/services/:id/certificates/check", async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const checked = await certificateService.runServiceChecks(
|
||||
request.server.db,
|
||||
Number(id),
|
||||
);
|
||||
return { checked };
|
||||
});
|
||||
|
||||
app.get("/services/:id/overview", async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
return nodeService.getOverview(request.server.db, Number(id));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
|
||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||
import {
|
||||
getAppSettings,
|
||||
getAppSettingsSecrets,
|
||||
updateAppSettings,
|
||||
type HealthEngineFallbacks,
|
||||
} from "@cfdm/db";
|
||||
@@ -71,11 +72,17 @@ export function createHealthCheckTask(
|
||||
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 =
|
||||
|
||||
@@ -3,11 +3,20 @@ 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,
|
||||
@@ -15,6 +24,7 @@ import {
|
||||
originProbeKey,
|
||||
type HealthMailbox,
|
||||
} from "./health/mailbox.js";
|
||||
import type { GlobalpingClientOptions } from "../lib/globalping-client.js";
|
||||
|
||||
export interface HealthCheckThresholds {
|
||||
degradedFailures: number;
|
||||
@@ -275,6 +285,7 @@ export interface RunAllChecksOptions {
|
||||
mailbox?: HealthMailbox | null;
|
||||
/** Results older than this are stale (default 10 min). */
|
||||
staleAfterMs?: number;
|
||||
globalping?: GlobalpingClientOptions | null;
|
||||
onStatusChange?: (
|
||||
target: HealthCheckTarget,
|
||||
prevState: IpHealthState | null,
|
||||
@@ -287,20 +298,60 @@ function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* One network hit per key. Group+binding on the same IP share a single TCP/HTTP probe
|
||||
* 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 kind = target.provider === "cloudflare" ? "cloudflare" : "local";
|
||||
return `${kind}|${originProbeKey(target)}`;
|
||||
export function physicalProbeKey(
|
||||
target: HealthCheckTarget,
|
||||
provider: HealthCheckProvider = target.provider,
|
||||
): string {
|
||||
return `${provider}|${originProbeKey(target)}`;
|
||||
}
|
||||
|
||||
function applyProbeResult(
|
||||
function logSourceResult(
|
||||
db: Db,
|
||||
target: HealthCheckTarget,
|
||||
provider: HealthCheckProvider,
|
||||
result: ProbeResult,
|
||||
): void {
|
||||
repos.insertHealthProbeLog(db, {
|
||||
scope: target.scope,
|
||||
refId: target.ref_id,
|
||||
ip: target.ip,
|
||||
provider,
|
||||
status: result.ok ? "up" : "down",
|
||||
ok: result.ok,
|
||||
latencyMs: result.latencyMs,
|
||||
colo: result.colo ?? null,
|
||||
error: result.error,
|
||||
});
|
||||
}
|
||||
|
||||
function applyAggregatedStatus(
|
||||
db: Db,
|
||||
target: HealthCheckTarget,
|
||||
sources: Array<{ provider: HealthCheckProvider; result: ProbeResult }>,
|
||||
options: RunAllChecksOptions,
|
||||
): void {
|
||||
const policy = parseHealthAggregate(target.aggregate);
|
||||
const oks = sources.map((s) => s.result.ok);
|
||||
const aggregatedOk = aggregateHealthOk(oks, policy);
|
||||
const latencies = sources.map((s) => s.result.latencyMs);
|
||||
const latencyMs = latencies.length
|
||||
? Math.round(latencies.reduce((sum, n) => sum + n, 0) / latencies.length)
|
||||
: 0;
|
||||
const colo =
|
||||
sources.find((s) => s.result.colo)?.result.colo ??
|
||||
sources[0]?.result.colo ??
|
||||
null;
|
||||
const error = aggregatedOk
|
||||
? null
|
||||
: sources
|
||||
.map((s) => s.result.error)
|
||||
.filter((msg): msg is string => Boolean(msg))
|
||||
.join("; ") || "health aggregate down";
|
||||
const statusProvider =
|
||||
sources.length > 1 ? "aggregate" : (sources[0]?.provider ?? target.provider);
|
||||
|
||||
const prev = repos.getIpHealthStatusRow(
|
||||
db,
|
||||
target.scope,
|
||||
@@ -308,8 +359,8 @@ function applyProbeResult(
|
||||
target.ip,
|
||||
);
|
||||
const { state, failures, successes, node } = deriveState(
|
||||
result.ok,
|
||||
result.latencyMs,
|
||||
aggregatedOk,
|
||||
latencyMs,
|
||||
prev
|
||||
? {
|
||||
consecutive_failures: prev.consecutive_failures,
|
||||
@@ -322,30 +373,18 @@ function applyProbeResult(
|
||||
const prevState: IpHealthState | null = prev
|
||||
? (prev.status as IpHealthState)
|
||||
: null;
|
||||
const provider = target.provider === "cloudflare" ? "cloudflare" : "local";
|
||||
repos.upsertIpHealthStatus(
|
||||
db,
|
||||
target.scope,
|
||||
target.ref_id,
|
||||
target.ip,
|
||||
state,
|
||||
result.latencyMs,
|
||||
latencyMs,
|
||||
failures,
|
||||
result.error,
|
||||
error,
|
||||
successes,
|
||||
{ colo: result.colo ?? null, provider },
|
||||
{ colo, provider: statusProvider },
|
||||
);
|
||||
repos.insertHealthProbeLog(db, {
|
||||
scope: target.scope,
|
||||
refId: target.ref_id,
|
||||
ip: target.ip,
|
||||
provider,
|
||||
status: state,
|
||||
ok: result.ok,
|
||||
latencyMs: result.latencyMs,
|
||||
colo: result.colo ?? null,
|
||||
error: result.error,
|
||||
});
|
||||
const matchedNode = repos.findNodeByIp(db, target.ip);
|
||||
if (matchedNode && matchedNode.enabled) {
|
||||
repos.updateNode(db, matchedNode.id, {
|
||||
@@ -353,7 +392,7 @@ function applyProbeResult(
|
||||
consecutive_failures: failures,
|
||||
consecutive_successes: successes,
|
||||
last_check_at: new Date().toISOString().replace("T", " ").slice(0, 19),
|
||||
last_failure_reason: result.error,
|
||||
last_failure_reason: error,
|
||||
});
|
||||
}
|
||||
if (prevState !== state) {
|
||||
@@ -379,42 +418,26 @@ export async function runAllChecks(
|
||||
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]);
|
||||
}
|
||||
|
||||
const localGroups: HealthCheckTarget[][] = [];
|
||||
const cloudflareGroups: HealthCheckTarget[][] = [];
|
||||
for (const group of byPhysical.values()) {
|
||||
if (group[0]?.provider === "cloudflare") cloudflareGroups.push(group);
|
||||
else localGroups.push(group);
|
||||
}
|
||||
|
||||
let probeIndex = 0;
|
||||
for (const group of localGroups) {
|
||||
if (probeIndex > 0 && gapMs > 0) {
|
||||
await sleep(gapMs);
|
||||
}
|
||||
probeIndex += 1;
|
||||
const representative =
|
||||
group.find((t) => t.scope === "binding") ?? group[0]!;
|
||||
const result = await local.probe(representative);
|
||||
for (const target of group) {
|
||||
applyProbeResult(db, target, result, options);
|
||||
}
|
||||
}
|
||||
|
||||
if (cloudflareGroups.length > 0) {
|
||||
const mailbox = options.mailbox ?? null;
|
||||
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;
|
||||
const byKey = indexResults(resultsDoc);
|
||||
const stale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);
|
||||
const colo = resultsDoc?.colo ?? null;
|
||||
|
||||
mailboxResults = indexResults(resultsDoc);
|
||||
mailboxStale = !mailbox || isResultsStale(resultsDoc, staleAfterMs);
|
||||
mailboxColo = resultsDoc?.colo ?? null;
|
||||
if (mailbox) {
|
||||
try {
|
||||
const next = buildTargetsDoc(targets);
|
||||
@@ -426,28 +449,71 @@ export async function runAllChecks(
|
||||
// ingest still proceeds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of cloudflareGroups) {
|
||||
const representative =
|
||||
group.find((t) => t.scope === "binding") ?? group[0]!;
|
||||
const item = byKey.get(originProbeKey(representative));
|
||||
let result: ProbeResult;
|
||||
if (!mailbox) {
|
||||
result = workerNotConfiguredResult();
|
||||
} else if (stale || !item) {
|
||||
result = staleWorkerResult(colo);
|
||||
} else {
|
||||
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,
|
||||
colo: mailboxColo,
|
||||
};
|
||||
}
|
||||
for (const target of group) {
|
||||
applyProbeResult(db, target, result, options);
|
||||
} else {
|
||||
if (!options.globalping?.token?.trim()) {
|
||||
result = globalpingNotConfiguredResult();
|
||||
} else {
|
||||
if (probeIndex > 0 && gapMs > 0) await sleep(gapMs);
|
||||
probeIndex += 1;
|
||||
result = await probeWithGlobalping(representative, options.globalping);
|
||||
}
|
||||
}
|
||||
probeCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const [originKey, group] of byOrigin) {
|
||||
const representative =
|
||||
group.find((t) => t.scope === "binding") ?? group[0]!;
|
||||
const needed = new Set<HealthCheckProvider>();
|
||||
for (const target of group) {
|
||||
for (const provider of targetProviders(target)) needed.add(provider);
|
||||
}
|
||||
for (const provider of needed) {
|
||||
await resolveProvider(provider, representative, originKey);
|
||||
}
|
||||
for (const target of group) {
|
||||
const providers = targetProviders(target);
|
||||
const sources = providers.map((provider) => ({
|
||||
provider,
|
||||
result: probeCache.get(`${provider}|${originKey}`)!,
|
||||
}));
|
||||
for (const source of sources) {
|
||||
logSourceResult(db, target, source.provider, source.result);
|
||||
}
|
||||
applyAggregatedStatus(db, target, sources, options);
|
||||
}
|
||||
}
|
||||
|
||||
repos.pruneStaleIpHealthStatus(db, targets);
|
||||
@@ -473,6 +539,8 @@ export async function runDomainMonitors(
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getAppSettings, repos, updateAppSettings, type HealthEngineFallbacks } from "@cfdm/db";
|
||||
import type { Db } from "@cfdm/db";
|
||||
import { HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME } from "@cfdm/shared";
|
||||
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";
|
||||
@@ -126,7 +126,7 @@ export async function maybeEnsureHealthWorker(
|
||||
): Promise<void> {
|
||||
const hasCloudflare = repos
|
||||
.listHealthCheckTargets(db)
|
||||
.some((target) => target.provider === "cloudflare");
|
||||
.some((target) => targetHasProvider(target, "cloudflare"));
|
||||
if (!hasCloudflare) return;
|
||||
const settings = getAppSettings(db, fallbacks);
|
||||
if (settings.healthWorkerKvNamespaceId.trim() && !settings.healthWorkerError) {
|
||||
@@ -172,7 +172,7 @@ export function fireEnsureHealthWorker(
|
||||
if (!cf.isConfigured) return;
|
||||
const hasCloudflare = repos
|
||||
.listHealthCheckTargets(db)
|
||||
.some((target) => target.provider === "cloudflare");
|
||||
.some((target) => targetHasProvider(target, "cloudflare"));
|
||||
if (!hasCloudflare) {
|
||||
void syncCloudflareTargetsToKv(db, cf, fallbacks).catch((err) => {
|
||||
log?.warn({ err }, "health worker KV sync failed");
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
HealthProbeTargetItem,
|
||||
HealthProbeTargetsDoc,
|
||||
} from "@cfdm/shared";
|
||||
import { HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY } 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 {
|
||||
@@ -70,7 +70,7 @@ export function cloudflareMailboxTargets(
|
||||
): HealthProbeTargetItem[] {
|
||||
const unique = new Map<string, HealthProbeTargetItem>();
|
||||
for (const target of targets) {
|
||||
if (target.provider !== "cloudflare") continue;
|
||||
if (!targetHasProvider(target, "cloudflare")) continue;
|
||||
if (target.type !== "tcp" && target.type !== "http") continue;
|
||||
const key = originProbeKey(target);
|
||||
if (unique.has(key)) continue;
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { Db } from "@cfdm/db";
|
||||
import { repos } from "@cfdm/db";
|
||||
import type {
|
||||
DnsRecord,
|
||||
HealthCheckAggregate,
|
||||
HealthCheckProvider,
|
||||
HealthCheckScope,
|
||||
HealthCheckType,
|
||||
IpHealthState,
|
||||
@@ -51,7 +53,9 @@ export interface ServiceDomainInput {
|
||||
health_check_interval_sec?: number;
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: "local" | "cloudflare";
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
export interface ToggleRequest {
|
||||
@@ -72,7 +76,9 @@ export interface ServiceGroupBody {
|
||||
health_check_interval_sec?: number;
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: "local" | "cloudflare";
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
export interface UpdateServiceGroupBody {
|
||||
@@ -89,7 +95,9 @@ export interface UpdateServiceGroupBody {
|
||||
health_check_interval_sec?: number;
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: "local" | "cloudflare";
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
export interface UpdateServiceConfigRequest {
|
||||
@@ -296,10 +304,23 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||
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),
|
||||
};
|
||||
});
|
||||
|
||||
const activeIps = new Set<string>();
|
||||
for (const binding of bindings) {
|
||||
const { config, rows } = getBindingLbState(db, binding.id);
|
||||
for (const ip of selectActiveIpsByMode(config, rows)) {
|
||||
activeIps.add(ip);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
@@ -318,6 +339,8 @@ 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],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1164,7 +1187,9 @@ export async function updateConfig(
|
||||
input.health_check_interval_sec !== undefined ||
|
||||
input.health_check_timeout_ms !== undefined ||
|
||||
input.health_check_verify_tls !== undefined ||
|
||||
input.health_check_provider !== 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,
|
||||
@@ -1177,6 +1202,8 @@ export async function updateConfig(
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1281,6 +1308,8 @@ export async function createGroup(
|
||||
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);
|
||||
@@ -1320,6 +1349,8 @@ export async function updateGroup(
|
||||
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) {
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
@@ -51,6 +51,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 +143,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();
|
||||
});
|
||||
|
||||
@@ -164,4 +164,51 @@ describe("settings health engine", () => {
|
||||
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,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: 'Статус',
|
||||
|
||||
@@ -17,16 +17,22 @@ import {
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { FieldGroup } 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 +44,8 @@ export interface HealthCheckConfig {
|
||||
timeout_ms: number
|
||||
verify_tls: boolean
|
||||
provider: HealthProvider
|
||||
providers: HealthProvider[]
|
||||
aggregate: HealthAggregate
|
||||
method?: string | null
|
||||
retries?: number
|
||||
consecutive_fails?: number
|
||||
@@ -54,62 +62,6 @@ const defaultLbModeOptions = [
|
||||
{ value: 'weighted', label: 'Weighted (веса)' },
|
||||
]
|
||||
|
||||
const healthCheckTypes = [
|
||||
{ value: 'tcp', label: 'TCP connect' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
] as const
|
||||
|
||||
const cloudflareTypes = [
|
||||
{ value: 'tcp', label: 'TCP' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
] as const
|
||||
|
||||
export function HealthProviderToggle({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
}: {
|
||||
value: HealthProvider
|
||||
onChange: (next: HealthProvider) => void
|
||||
id?: string
|
||||
}) {
|
||||
const provider = value || 'local'
|
||||
return (
|
||||
<ButtonGroup className="w-full" id={id}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
variant={provider === 'local' ? 'secondary' : 'outline'}
|
||||
aria-pressed={provider === 'local'}
|
||||
onClick={() => onChange('local')}
|
||||
>
|
||||
Local
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
variant={provider === 'cloudflare' ? 'secondary' : 'outline'}
|
||||
aria-pressed={provider === 'cloudflare'}
|
||||
onClick={() => onChange('cloudflare')}
|
||||
>
|
||||
Cloudflare
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)
|
||||
}
|
||||
|
||||
interface HealthCheckConfigFieldsProps {
|
||||
value: LbAndHealthConfig
|
||||
onChange: (next: LbAndHealthConfig) => void
|
||||
lbModeLabel?: string
|
||||
lbModeOptions?: { value: string; label: string }[]
|
||||
idPrefix?: string
|
||||
showLbMode?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
function CompactNumberField({
|
||||
id,
|
||||
value,
|
||||
@@ -151,11 +103,24 @@ export function HealthCheckConfigFields({
|
||||
idPrefix = 'health',
|
||||
showLbMode = true,
|
||||
className,
|
||||
}: HealthCheckConfigFieldsProps) {
|
||||
}: {
|
||||
value: LbAndHealthConfig
|
||||
onChange: (next: LbAndHealthConfig) => void
|
||||
lbModeLabel?: string
|
||||
lbModeOptions?: { value: string; label: string }[]
|
||||
idPrefix?: string
|
||||
showLbMode?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
function patch(next: Partial<LbAndHealthConfig>) {
|
||||
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'
|
||||
|
||||
@@ -190,47 +155,40 @@ export function HealthCheckConfigFields({
|
||||
|
||||
<SettingRow
|
||||
title="Провайдер health-check"
|
||||
description="Откуда идёт проба: API CFDM или Cloudflare Worker (edge)"
|
||||
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 Worker</AlertTitle>
|
||||
<AlertDescription>
|
||||
Проба с edge Cloudflare, не продукт Health Checks API (на Free его нет).
|
||||
Worker создаётся автоматически и сам опрашивает IP (KV mailbox).
|
||||
Статус деплоя — в{' '}
|
||||
<Link to="/settings/health" className="text-foreground underline">
|
||||
Настройках → Health-check
|
||||
</Link>
|
||||
. Если Worker не создан, цель не пробируется как Local.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertTitle>Local health-check</AlertTitle>
|
||||
<AlertDescription>
|
||||
Проба TCP/HTTP с сервера API. Cron и пороги Slow/Down — в{' '}
|
||||
<Link to="/settings/health" className="text-foreground underline">
|
||||
Настройках → Health-check
|
||||
</Link>
|
||||
. Интервал в карточке не используется.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{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"
|
||||
@@ -259,25 +217,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`}>
|
||||
|
||||
@@ -29,15 +29,21 @@ export interface HealthTimelineEvent {
|
||||
|
||||
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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -36,6 +36,13 @@ function getBreadcrumbs(
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/services\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Сервисы', href: '/services' },
|
||||
{ label: dynamicLabels[pathname] ?? 'Сервис', href: pathname },
|
||||
]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/groups\/\d+$/)) {
|
||||
return [
|
||||
{ label: 'Группы доменов', href: '/groups' },
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
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 { 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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only status tiles for enabled probe sources; click filters the monitor.
|
||||
* Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export function HealthProviderStatusTiles({
|
||||
enabled,
|
||||
selected,
|
||||
statuses,
|
||||
onChange,
|
||||
}: {
|
||||
enabled: HealthProvider[]
|
||||
selected: HealthProvider[]
|
||||
statuses: Partial<Record<HealthProvider, HealthLogStatus>>
|
||||
onChange: (next: HealthProvider[]) => void
|
||||
}) {
|
||||
const visible = HEALTH_PROVIDER_ITEMS.filter((item) => enabled.includes(item.id))
|
||||
if (visible.length === 0) return null
|
||||
|
||||
const active = selected.length > 0 ? selected : enabled
|
||||
|
||||
function toggle(id: HealthProvider) {
|
||||
if (active.includes(id)) {
|
||||
if (active.length === 1) return
|
||||
onChange(active.filter((item) => item !== id))
|
||||
return
|
||||
}
|
||||
onChange([...active, id])
|
||||
}
|
||||
|
||||
return (
|
||||
<ChoiceFrame>
|
||||
{visible.map((item) => (
|
||||
<ChoicePanel
|
||||
key={item.id}
|
||||
selected={active.includes(item.id)}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
icon={item.icon}
|
||||
iconClassName={item.iconClassName}
|
||||
role="checkbox"
|
||||
trailing={
|
||||
<HealthCheckBadge
|
||||
status={statuses[item.id] ?? 'unknown'}
|
||||
provider={item.id}
|
||||
size="xs"
|
||||
/>
|
||||
}
|
||||
onActivate={() => toggle(item.id)}
|
||||
/>
|
||||
))}
|
||||
</ChoiceFrame>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export { UptimeChart, type UptimeProbe, type UptimePeriodKey, probeUptimePercent, lastProbeLatency } from './uptime-chart'
|
||||
export { ServiceHealthMonitor } from './service-health-monitor'
|
||||
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
|
||||
export { CertStatusChart, GroupDomainsChart } from './dashboard-analytics'
|
||||
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
|
||||
@@ -18,3 +20,10 @@ 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,
|
||||
type HealthProvider,
|
||||
type HealthAggregate,
|
||||
} from './health-source-tiles'
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { LayersIcon } from 'lucide-react'
|
||||
|
||||
import { HealthTimeline } from '@/components/health/health-timeline'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
lastProbeLatency,
|
||||
probeUptimePercent,
|
||||
UptimeChart,
|
||||
UPTIME_PERIODS,
|
||||
type UptimePeriodKey,
|
||||
} from '@/components/reui-kit/uptime-chart'
|
||||
import { HEALTH_PROVIDER_ITEMS } from '@/components/reui-kit/health-source-tiles'
|
||||
import {
|
||||
collapseStatusChanges,
|
||||
filterByPeriod,
|
||||
filterByProviders,
|
||||
type HealthLogProbe,
|
||||
type HealthLogStatus,
|
||||
} from '@/lib/health-log'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||
|
||||
const ALL_TAB = 'all' as const
|
||||
type SourceTab = typeof ALL_TAB | HealthCheckProvider
|
||||
|
||||
function formatUptime(value: number | null): string {
|
||||
if (value == null) return '—'
|
||||
return `${value.toFixed(value >= 99.95 ? 2 : 1)}%`
|
||||
}
|
||||
|
||||
function formatLatency(ms: number | null): string {
|
||||
if (ms == null) return 'нет проб'
|
||||
return `${ms} мс`
|
||||
}
|
||||
|
||||
function statusTileClass(status: HealthLogStatus | undefined): string {
|
||||
if (status === 'down') return 'text-destructive'
|
||||
if (status === 'degraded') return 'text-warning'
|
||||
if (status === 'up') return 'text-success'
|
||||
return 'text-muted-foreground'
|
||||
}
|
||||
|
||||
/**
|
||||
* Единый блок мониторинга: переключатель источников (dashboard-4) + график
|
||||
* (chart-17) + таймлайн смен статуса (solution-ai-ops-1 / timeline).
|
||||
*
|
||||
* Preview: https://reui.io/preview/base/dashboard-4
|
||||
* Preview: https://reui.io/preview/base/chart-17
|
||||
* Preview: https://reui.io/preview/base/solution-ai-ops-1
|
||||
* Docs: https://reui.io/docs/components/base/frame
|
||||
* Docs: https://reui.io/docs/components/base/icon-tile
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
* Docs: https://reui.io/docs/components/base/badge
|
||||
*/
|
||||
export function ServiceHealthMonitor({
|
||||
items,
|
||||
enabledProviders,
|
||||
statuses,
|
||||
isLoading = false,
|
||||
}: {
|
||||
items: HealthLogProbe[]
|
||||
enabledProviders: readonly HealthCheckProvider[]
|
||||
statuses: Partial<Record<HealthCheckProvider, HealthLogStatus>>
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const [period, setPeriod] = useState<UptimePeriodKey>('5D')
|
||||
const [source, setSource] = useState<SourceTab>(ALL_TAB)
|
||||
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||
|
||||
const enabledItems = useMemo(
|
||||
() => HEALTH_PROVIDER_ITEMS.filter((item) => enabledProviders.includes(item.id)),
|
||||
[enabledProviders],
|
||||
)
|
||||
|
||||
const selectedProviders = useMemo((): HealthCheckProvider[] => {
|
||||
if (source === ALL_TAB) return [...enabledProviders]
|
||||
if (enabledProviders.includes(source)) return [source]
|
||||
return [...enabledProviders]
|
||||
}, [enabledProviders, source])
|
||||
|
||||
const periodItems = useMemo(
|
||||
() => filterByPeriod(items, days),
|
||||
[items, days],
|
||||
)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => filterByProviders(periodItems, selectedProviders),
|
||||
[periodItems, selectedProviders],
|
||||
)
|
||||
|
||||
const changes = useMemo(() => collapseStatusChanges(filtered), [filtered])
|
||||
|
||||
const showAllTab = enabledItems.length > 1
|
||||
const tabCount = enabledItems.length + (showAllTab ? 1 : 0)
|
||||
const activeSource: SourceTab =
|
||||
source === ALL_TAB || enabledProviders.includes(source)
|
||||
? source
|
||||
: ALL_TAB
|
||||
|
||||
return (
|
||||
<Frame stacked spacing="sm" className="min-w-0 w-full">
|
||||
<FrameHeader className="p-0!">
|
||||
<div
|
||||
className={cn(
|
||||
'grid',
|
||||
tabCount <= 2 && 'grid-cols-2',
|
||||
tabCount === 3 && 'grid-cols-1 sm:grid-cols-3',
|
||||
tabCount >= 4 && 'grid-cols-2',
|
||||
)}
|
||||
>
|
||||
{showAllTab ? (
|
||||
<SourceMetricButton
|
||||
selected={activeSource === ALL_TAB}
|
||||
icon={<LayersIcon />}
|
||||
iconClassName="text-muted-foreground"
|
||||
label="Все источники"
|
||||
value={formatUptime(probeUptimePercent(periodItems))}
|
||||
hint={`${periodItems.length} проб`}
|
||||
onSelect={() => setSource(ALL_TAB)}
|
||||
/>
|
||||
) : null}
|
||||
{enabledItems.map((item) => {
|
||||
const series = filterByProviders(periodItems, [item.id])
|
||||
return (
|
||||
<SourceMetricButton
|
||||
key={item.id}
|
||||
selected={activeSource === item.id}
|
||||
icon={item.icon}
|
||||
iconClassName={item.iconClassName}
|
||||
label={item.title}
|
||||
value={formatUptime(probeUptimePercent(series))}
|
||||
hint={formatLatency(lastProbeLatency(series))}
|
||||
status={statuses[item.id] ?? 'unknown'}
|
||||
onSelect={() => setSource(item.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FrameHeader>
|
||||
|
||||
<UptimeChart
|
||||
items={filtered}
|
||||
isLoading={isLoading}
|
||||
period={period}
|
||||
onPeriodChange={setPeriod}
|
||||
skipPeriodFilter
|
||||
embedded
|
||||
hideHeader
|
||||
/>
|
||||
|
||||
<FramePanel className="flex flex-col gap-3">
|
||||
<FrameHeader className="px-0 py-0">
|
||||
<FrameTitle>Смены статуса</FrameTitle>
|
||||
<FrameDescription>
|
||||
Только переходы up / degraded / down · Cloudflare = Worker, не Health
|
||||
Checks API
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<HealthTimeline
|
||||
events={changes.map((row) => ({
|
||||
id: row.id,
|
||||
hostname: row.ip,
|
||||
type: row.provider,
|
||||
status: row.status,
|
||||
latency_ms: row.latency_ms,
|
||||
error: row.error,
|
||||
checked_at: row.checked_at,
|
||||
colo: row.colo,
|
||||
provider: row.provider,
|
||||
}))}
|
||||
emptyTitle="Нет смен статуса"
|
||||
emptyDescription="События появятся при переходе up / degraded / down"
|
||||
/>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceMetricButton({
|
||||
selected,
|
||||
icon,
|
||||
iconClassName,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
status,
|
||||
onSelect,
|
||||
}: {
|
||||
selected: boolean
|
||||
icon: ReactNode
|
||||
iconClassName: string
|
||||
label: string
|
||||
value: string
|
||||
hint: string
|
||||
status?: HealthLogStatus
|
||||
onSelect: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
aria-label={`${label}: ${value}`}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'focus-visible:ring-ring/50 hover:bg-muted/40 relative flex min-w-0 items-start gap-3 border-e border-b p-4 text-start transition-colors last:border-e-0 focus-visible:ring-2 focus-visible:outline-none sm:border-b-0',
|
||||
selected && 'bg-muted/40',
|
||||
)}
|
||||
>
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
className={cn(
|
||||
'size-10.5',
|
||||
status ? statusTileClass(status) : iconClassName,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</IconTile>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground text-sm font-medium">{label}</span>
|
||||
{status ? (
|
||||
<HealthCheckBadge status={status} size="xs" />
|
||||
) : (
|
||||
<Badge variant="outline" size="sm">
|
||||
{hint}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-foreground text-2xl leading-none font-bold tabular-nums">
|
||||
{value}
|
||||
</span>
|
||||
{status ? (
|
||||
<span className="text-muted-foreground text-xs">{hint}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { ActivityIcon, InfoIcon, TrendingDownIcon, TrendingUpIcon } from 'lucide-react'
|
||||
import { Area, AreaChart, XAxis } from 'recharts'
|
||||
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { filterByPeriod, probeTime } from '@/lib/health-log'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@cfdm/ui/components/chart'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
|
||||
/**
|
||||
* Uptime monitoring card — chart-17 DNA (Frame + value + AreaChart + period tabs).
|
||||
* Preview: https://reui.io/preview/base/chart-17
|
||||
* Frame: https://reui.io/docs/components/base/frame
|
||||
* Chart: shadcn Chart + Recharts AreaChart
|
||||
*/
|
||||
|
||||
export interface UptimeProbe {
|
||||
id: number
|
||||
status: 'up' | 'down' | 'degraded' | 'unknown'
|
||||
ok: boolean
|
||||
latency_ms: number | null
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
export type UptimePeriodKey = '5D' | '2W' | '1M'
|
||||
|
||||
export const UPTIME_PERIODS: { key: UptimePeriodKey; label: string; days: number }[] = [
|
||||
{ key: '5D', label: '5D', days: 5 },
|
||||
{ key: '2W', label: '2W', days: 14 },
|
||||
{ key: '1M', label: '1M', days: 30 },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
latency: {
|
||||
label: 'Задержка',
|
||||
color: 'var(--chart-1)',
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
interface ChartPoint {
|
||||
period: string
|
||||
latency: number
|
||||
ok: boolean
|
||||
at: string
|
||||
status: UptimeProbe['status']
|
||||
}
|
||||
|
||||
function toSeries(items: UptimeProbe[]): ChartPoint[] {
|
||||
return [...items]
|
||||
.sort((a, b) => probeTime(a.checked_at) - probeTime(b.checked_at))
|
||||
.map((item) => ({
|
||||
period: formatDate(item.checked_at),
|
||||
latency: item.latency_ms ?? 0,
|
||||
ok: item.ok && item.status !== 'down',
|
||||
at: item.checked_at,
|
||||
status: item.status,
|
||||
}))
|
||||
}
|
||||
|
||||
function uptimePercent(points: ChartPoint[]): number | null {
|
||||
if (points.length === 0) return null
|
||||
const okCount = points.filter((point) => point.ok).length
|
||||
return (okCount / points.length) * 100
|
||||
}
|
||||
|
||||
function deltaPercent(points: ChartPoint[]): number | null {
|
||||
if (points.length < 4) return null
|
||||
const mid = Math.floor(points.length / 2)
|
||||
const prev = uptimePercent(points.slice(0, mid))
|
||||
const next = uptimePercent(points.slice(mid))
|
||||
if (prev == null || next == null) return null
|
||||
return next - prev
|
||||
}
|
||||
|
||||
function UptimeDelta({ delta }: { delta: number }) {
|
||||
if (Math.abs(delta) < 0.05) {
|
||||
return <span className="text-muted-foreground">без изменений за период</span>
|
||||
}
|
||||
|
||||
if (delta > 0) {
|
||||
return (
|
||||
<>
|
||||
<TrendingUpIcon className="text-success size-4" aria-hidden="true" />
|
||||
<span className="text-success font-medium">+{delta.toFixed(1)} п.п.</span>
|
||||
<span className="text-muted-foreground">с начала периода</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TrendingDownIcon className="text-destructive size-4" aria-hidden="true" />
|
||||
<span className="text-destructive font-medium">{delta.toFixed(1)} п.п.</span>
|
||||
<span className="text-muted-foreground">с начала периода</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function probeUptimePercent(items: UptimeProbe[]): number | null {
|
||||
return uptimePercent(toSeries(items))
|
||||
}
|
||||
|
||||
export function lastProbeLatency(items: UptimeProbe[]): number | null {
|
||||
if (items.length === 0) return null
|
||||
const latest = [...items].sort(
|
||||
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at),
|
||||
)[0]
|
||||
return latest?.latency_ms ?? null
|
||||
}
|
||||
|
||||
function formatUptime(value: number | null): string {
|
||||
if (value == null) return '—'
|
||||
return `${value.toFixed(value >= 99.95 ? 2 : 1)}%`
|
||||
}
|
||||
|
||||
interface UptimeChartProps {
|
||||
items: UptimeProbe[]
|
||||
isLoading?: boolean
|
||||
period?: UptimePeriodKey
|
||||
onPeriodChange?: (period: UptimePeriodKey) => void
|
||||
skipPeriodFilter?: boolean
|
||||
embedded?: boolean
|
||||
/** dashboard-4: chrome живёт в родительском FrameHeader (переключатель серий). */
|
||||
hideHeader?: boolean
|
||||
}
|
||||
|
||||
export function UptimeChart({
|
||||
items,
|
||||
isLoading = false,
|
||||
period: periodProp,
|
||||
onPeriodChange,
|
||||
skipPeriodFilter = false,
|
||||
embedded = false,
|
||||
hideHeader = false,
|
||||
}: UptimeChartProps) {
|
||||
const gradientId = useId().replace(/:/g, '')
|
||||
const [internalPeriod, setInternalPeriod] = useState<UptimePeriodKey>('5D')
|
||||
const [tooltipPortal, setTooltipPortal] = useState<HTMLElement | null>(null)
|
||||
const period = periodProp ?? internalPeriod
|
||||
const days = UPTIME_PERIODS.find((entry) => entry.key === period)?.days ?? 5
|
||||
|
||||
useEffect(() => {
|
||||
setTooltipPortal(document.body)
|
||||
}, [])
|
||||
|
||||
function handlePeriodChange(next: UptimePeriodKey) {
|
||||
onPeriodChange?.(next)
|
||||
if (periodProp == null) setInternalPeriod(next)
|
||||
}
|
||||
|
||||
const points = useMemo(
|
||||
() => toSeries(skipPeriodFilter ? items : filterByPeriod(items, days)),
|
||||
[items, days, skipPeriodFilter],
|
||||
)
|
||||
const uptime = uptimePercent(points)
|
||||
const delta = deltaPercent(points)
|
||||
const lastOk = points.at(-1)?.ok ?? true
|
||||
const tileClass = lastOk ? 'text-success' : 'text-destructive'
|
||||
|
||||
const panel = (
|
||||
<FramePanel className="flex flex-col gap-6">
|
||||
{hideHeader ? null : (
|
||||
<div className="border-border flex items-center justify-between gap-2 border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
className={`size-10.5 ${tileClass}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ActivityIcon />
|
||||
</IconTile>
|
||||
<div className="flex flex-col justify-center">
|
||||
<h3 className="text-base font-semibold">Uptime</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Пробы health-check за период
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider delay={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label="О графике uptime"
|
||||
className="text-muted-foreground/70 -mr-1"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<InfoIcon data-icon="inline-start" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
<p>Доля успешных проб и задержка (мс) по журналу health-log.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-muted h-40 w-full animate-pulse rounded-xl" />
|
||||
) : points.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title="Нет проб за период"
|
||||
description="Результаты появятся после health-check"
|
||||
stackedIcon={false}
|
||||
centered={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-foreground text-3xl font-semibold tabular-nums">
|
||||
{formatUptime(uptime)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{delta == null ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
{points.length} проб
|
||||
</Badge>
|
||||
) : (
|
||||
<UptimeDelta delta={delta} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-40 w-full overflow-visible">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-full w-full overflow-visible rounded-b-xl"
|
||||
initialDimension={{ width: 320, height: 160 }}
|
||||
>
|
||||
<AreaChart
|
||||
data={points}
|
||||
margin={{ top: 16, left: 8, right: 8, bottom: 4 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-latency)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-latency)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="period" hide />
|
||||
<ChartTooltip
|
||||
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
portal={tooltipPortal ?? undefined}
|
||||
wrapperStyle={{ zIndex: 50, pointerEvents: 'none' }}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value, _name, item) => {
|
||||
const point = item.payload as ChartPoint | undefined
|
||||
const ping = Number(value)
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">
|
||||
{point?.ok === false ? 'Down' : 'Пинг'}
|
||||
</span>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{Number.isFinite(ping) ? `${ping} мс` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="latency"
|
||||
name="latency"
|
||||
type="natural"
|
||||
fill={`url(#${gradientId})`}
|
||||
stroke="var(--color-latency)"
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
dot={(dotProps) => {
|
||||
const { cx, cy, payload, index } = dotProps
|
||||
if (cx == null || cy == null) return <g key={index} />
|
||||
const point = payload as ChartPoint | undefined
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={4}
|
||||
fill={
|
||||
point?.ok
|
||||
? 'var(--color-latency)'
|
||||
: 'var(--destructive)'
|
||||
}
|
||||
stroke="var(--background)"
|
||||
strokeWidth={2}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
stroke: 'var(--background)',
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={period}
|
||||
onValueChange={(value) => handlePeriodChange(value as UptimePeriodKey)}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{UPTIME_PERIODS.map((entry) => (
|
||||
<TabsTrigger key={entry.key} value={entry.key} className="flex-1">
|
||||
{entry.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
)
|
||||
|
||||
if (embedded) return panel
|
||||
|
||||
return (
|
||||
<Frame spacing="sm" className="min-w-0 w-full">
|
||||
{panel}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
type LbAndHealthConfig,
|
||||
type LbMode,
|
||||
type HealthCheckType,
|
||||
type HealthProvider,
|
||||
type HealthAggregate,
|
||||
} from '@/components/health-check-config-fields'
|
||||
import type {
|
||||
CreateServiceWithConfigInput,
|
||||
@@ -16,6 +18,7 @@ import type {
|
||||
ServiceView,
|
||||
UpdateServiceConfigInput,
|
||||
} from '@/lib/schemas'
|
||||
import { parseHealthProviders } from '@cfdm/shared'
|
||||
import { bindingToFqdn, parseFqdn } from '@/lib/parse-fqdn'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { toast } from 'sonner'
|
||||
@@ -53,7 +56,9 @@ interface BindingHealthConfig {
|
||||
interval_sec: number
|
||||
timeout_ms: number
|
||||
verify_tls: boolean
|
||||
provider: 'local' | 'cloudflare'
|
||||
provider: HealthProvider
|
||||
providers: HealthProvider[]
|
||||
aggregate: HealthAggregate
|
||||
}
|
||||
|
||||
export interface ServiceBindingDraft {
|
||||
@@ -77,6 +82,8 @@ const defaultHealth: BindingHealthConfig = {
|
||||
timeout_ms: 3000,
|
||||
verify_tls: false,
|
||||
provider: 'local',
|
||||
providers: ['local'],
|
||||
aggregate: 'majority',
|
||||
}
|
||||
|
||||
interface ServiceEditSheetProps {
|
||||
@@ -102,15 +109,20 @@ function toBindingDrafts(service: ServiceView): ServiceBindingDraft[] {
|
||||
target_cname: binding.target_cname ?? '',
|
||||
lb_mode: binding.lb_mode,
|
||||
health: {
|
||||
enabled: binding.health_check_enabled,
|
||||
enabled: Boolean(binding.health_check_enabled),
|
||||
type: binding.health_check_type === 'http' ? 'http' : 'tcp',
|
||||
port: binding.health_check_port,
|
||||
path: binding.health_check_path,
|
||||
expected_status: binding.health_check_expected_status,
|
||||
interval_sec: binding.health_check_interval_sec,
|
||||
timeout_ms: binding.health_check_timeout_ms,
|
||||
verify_tls: binding.health_check_verify_tls ?? false,
|
||||
provider: binding.health_check_provider === 'cloudflare' ? 'cloudflare' : 'local',
|
||||
verify_tls: Boolean(binding.health_check_verify_tls),
|
||||
provider: binding.health_check_provider ?? 'local',
|
||||
providers: parseHealthProviders(
|
||||
binding.health_check_providers,
|
||||
binding.health_check_provider ?? 'local',
|
||||
),
|
||||
aggregate: binding.health_check_aggregate ?? 'majority',
|
||||
},
|
||||
target_ip_weights: binding.target_ip_weights ?? {},
|
||||
target_ip_priorities: binding.target_ip_priorities ?? {},
|
||||
@@ -139,6 +151,8 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
health_check_providers: binding.health.providers,
|
||||
health_check_aggregate: binding.health.aggregate,
|
||||
}
|
||||
: {
|
||||
fqdn: binding.fqdn.trim(),
|
||||
@@ -155,6 +169,8 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
||||
health_check_timeout_ms: binding.health.timeout_ms,
|
||||
health_check_verify_tls: binding.health.verify_tls,
|
||||
health_check_provider: binding.health.provider,
|
||||
health_check_providers: binding.health.providers,
|
||||
health_check_aggregate: binding.health.aggregate,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -217,6 +233,8 @@ export function ServiceEditSheet({
|
||||
[groups],
|
||||
)
|
||||
|
||||
// Reset only when the sheet opens or the service id changes.
|
||||
// Health polling replaces `service` by identity and would wipe unsaved settings.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (mode === 'edit' && service) {
|
||||
@@ -245,7 +263,7 @@ export function ServiceEditSheet({
|
||||
setLbWeight(1)
|
||||
setLbPriority(1)
|
||||
}
|
||||
}, [open, mode, service, defaultGroupId])
|
||||
}, [open, mode, service?.id, defaultGroupId])
|
||||
|
||||
const zoneHints = useMemo(
|
||||
() => knownDomains.map((domain) => domain.zone_name),
|
||||
@@ -334,6 +352,8 @@ export function ServiceEditSheet({
|
||||
timeout_ms: next.timeout_ms,
|
||||
verify_tls: next.verify_tls,
|
||||
provider: next.provider,
|
||||
providers: next.providers,
|
||||
aggregate: next.aggregate,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import {
|
||||
GlobeIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { createFilter, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { certRelativeBadge } from '@/components/columns/certificates-columns'
|
||||
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import type { ServiceCertificateRow, ServiceView } from '@/lib/schemas'
|
||||
import type { CertMonitoring } from '@cfdm/shared'
|
||||
import {
|
||||
certKeys,
|
||||
checkServiceCertificates,
|
||||
patchBindingCertMonitoring,
|
||||
serviceCertificatesQueryOptions,
|
||||
} from '@/queries'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@cfdm/ui/components/toggle-group'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
|
||||
type HealthStatus = 'up' | 'down' | 'degraded' | 'unknown'
|
||||
|
||||
interface ServiceIpRow {
|
||||
id: string
|
||||
ip: string
|
||||
status: HealthStatus
|
||||
enabled: boolean
|
||||
active: boolean
|
||||
weight: number
|
||||
priority: number
|
||||
latency_ms: number | null
|
||||
last_checked_at: string | null
|
||||
last_error: string | null
|
||||
colo: string | null
|
||||
provider: string | null
|
||||
}
|
||||
|
||||
export interface ServiceFqdnRow {
|
||||
id: string
|
||||
fqdn: string
|
||||
zone_name: string
|
||||
target_ips: string[]
|
||||
binding_id: number
|
||||
domain_id: number
|
||||
}
|
||||
|
||||
interface ServiceNodeRow {
|
||||
id: string
|
||||
nodeId: number
|
||||
address: string
|
||||
protocol: string
|
||||
port: number | null
|
||||
health_status: HealthStatus
|
||||
weight: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: 'ip', label: 'IP' },
|
||||
{ id: 'fqdn', label: 'FQDN' },
|
||||
{ id: 'nodes', label: 'Ноды' },
|
||||
{ id: 'ssl', label: 'SSL' },
|
||||
] as const
|
||||
|
||||
const HEALTH_OPTIONS = [
|
||||
{ value: 'up', label: 'OK' },
|
||||
{ value: 'degraded', label: 'Slow' },
|
||||
{ value: 'down', label: 'Down' },
|
||||
{ value: 'unknown', label: '—' },
|
||||
]
|
||||
|
||||
function mapNodeHealth(status: string): HealthStatus {
|
||||
if (status === 'healthy' || status === 'up') return 'up'
|
||||
if (status === 'unhealthy' || status === 'down') return 'down'
|
||||
if (status === 'degraded') return 'degraded'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function buildIpRows(service: ServiceView): ServiceIpRow[] {
|
||||
const healthByIp = new Map(service.ip_health.map((row) => [row.ip, row]))
|
||||
const weights = Object.assign(
|
||||
{},
|
||||
...service.domains.map((domain) => domain.target_ip_weights ?? {}),
|
||||
) as Record<string, number>
|
||||
const priorities = Object.assign(
|
||||
{},
|
||||
...service.domains.map((domain) => domain.target_ip_priorities ?? {}),
|
||||
) as Record<string, number>
|
||||
const activeSet = new Set(service.active_ips)
|
||||
|
||||
return service.ips.map((ip) => {
|
||||
const health = healthByIp.get(ip)
|
||||
return {
|
||||
id: ip,
|
||||
ip,
|
||||
status: health?.status ?? 'unknown',
|
||||
enabled: service.ip_enabled[ip] !== false,
|
||||
active: activeSet.has(ip),
|
||||
weight: weights[ip] ?? 1,
|
||||
priority: priorities[ip] ?? 1,
|
||||
latency_ms: health?.latency_ms ?? null,
|
||||
last_checked_at: health?.last_checked_at ?? null,
|
||||
last_error: health?.last_error ?? null,
|
||||
colo: health?.colo ?? null,
|
||||
provider: health?.provider ?? null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildFqdnRows(service: ServiceView): ServiceFqdnRow[] {
|
||||
return service.domains.map((domain) => ({
|
||||
id: String(domain.binding_id),
|
||||
fqdn: domain.fqdn,
|
||||
zone_name: domain.zone_name,
|
||||
target_ips: domain.target_ips ?? [],
|
||||
binding_id: domain.binding_id,
|
||||
domain_id: domain.domain_id,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildNodeRows(
|
||||
nodes: Array<{
|
||||
id: number
|
||||
address: string
|
||||
protocol: string
|
||||
port: number | null
|
||||
health_status: string
|
||||
weight: number
|
||||
priority: number
|
||||
}>,
|
||||
): ServiceNodeRow[] {
|
||||
return nodes.map((node) => ({
|
||||
id: String(node.id),
|
||||
nodeId: node.id,
|
||||
address: node.address,
|
||||
protocol: node.protocol,
|
||||
port: node.port,
|
||||
health_status: mapNodeHealth(node.health_status),
|
||||
weight: node.weight,
|
||||
priority: node.priority,
|
||||
}))
|
||||
}
|
||||
|
||||
function NameCell({
|
||||
icon,
|
||||
label,
|
||||
iconClassName,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
iconClassName?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="xs"
|
||||
className={iconClassName ?? 'text-muted-foreground'}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</IconTile>
|
||||
<span className="truncate font-mono text-sm">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServiceDetailGridProps {
|
||||
service: ServiceView
|
||||
nodes: Array<{
|
||||
id: number
|
||||
address: string
|
||||
protocol: string
|
||||
port: number | null
|
||||
health_status: string
|
||||
weight: number
|
||||
priority: number
|
||||
}>
|
||||
togglingIp: string | null
|
||||
onToggleIp: (ip: string, enabled: boolean) => void
|
||||
onChangeIp: (row: ServiceFqdnRow) => void
|
||||
onChangeDomain: () => void
|
||||
onAddNode: () => void
|
||||
onDeleteNode: (nodeId: number) => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function ServiceDetailGrid({
|
||||
service,
|
||||
nodes,
|
||||
togglingIp,
|
||||
onToggleIp,
|
||||
onChangeIp,
|
||||
onChangeDomain,
|
||||
onAddNode,
|
||||
onDeleteNode,
|
||||
isLoading = false,
|
||||
}: ServiceDetailGridProps) {
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]['id']>('ip')
|
||||
const [ipFilters, setIpFilters] = useState<Filter[]>(() => [
|
||||
createFilter('ip', 'contains', ['']),
|
||||
createFilter('status', 'is', ['']),
|
||||
])
|
||||
const [fqdnFilters, setFqdnFilters] = useState<Filter[]>(() => [
|
||||
createFilter('fqdn', 'contains', ['']),
|
||||
])
|
||||
const [nodeFilters, setNodeFilters] = useState<Filter[]>(() => [
|
||||
createFilter('address', 'contains', ['']),
|
||||
createFilter('health_status', 'is', ['']),
|
||||
])
|
||||
const [sslFilters, setSslFilters] = useState<Filter[]>(() => [
|
||||
createFilter('hostname', 'contains', ['']),
|
||||
])
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const certQuery = useQuery(serviceCertificatesQueryOptions(service.id))
|
||||
const sslRows = certQuery.data ?? []
|
||||
|
||||
const patchCertMonitoring = useMutation({
|
||||
mutationFn: ({
|
||||
bindingId,
|
||||
mode,
|
||||
}: {
|
||||
bindingId: number
|
||||
mode: CertMonitoring
|
||||
}) => patchBindingCertMonitoring(bindingId, mode),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.byService(service.id) }),
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.summary }),
|
||||
])
|
||||
toast.success('Режим проверки SSL обновлён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Не удалось обновить режим SSL',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const checkSsl = useMutation({
|
||||
mutationFn: () => checkServiceCertificates(service.id),
|
||||
onSuccess: async (result) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.byService(service.id) }),
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: certKeys.summary }),
|
||||
])
|
||||
toast.success(
|
||||
result.checked > 0
|
||||
? `Проверено FQDN: ${result.checked}`
|
||||
: 'Нет FQDN для проверки SSL',
|
||||
)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Не удалось проверить SSL',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const ipRows = useMemo(() => buildIpRows(service), [service])
|
||||
const fqdnRows = useMemo(() => buildFqdnRows(service), [service])
|
||||
const nodeRows = useMemo(() => buildNodeRows(nodes), [nodes])
|
||||
const markActive = service.lb_mode === 'failover' || service.lb_mode === 'weighted'
|
||||
|
||||
const tabs = TABS.map((entry) => ({
|
||||
...entry,
|
||||
count:
|
||||
entry.id === 'ip'
|
||||
? ipRows.length
|
||||
: entry.id === 'fqdn'
|
||||
? fqdnRows.length
|
||||
: entry.id === 'ssl'
|
||||
? sslRows.length
|
||||
: nodeRows.length,
|
||||
}))
|
||||
|
||||
const ipFilterFields = useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'ip',
|
||||
label: 'IP',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по IP…',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Статус',
|
||||
type: 'select',
|
||||
searchable: true,
|
||||
className: 'w-[168px]',
|
||||
options: HEALTH_OPTIONS,
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const fqdnFilterFields = useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'fqdn',
|
||||
label: 'FQDN',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по FQDN…',
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const nodeFilterFields = useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'address',
|
||||
label: 'Адрес',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по адресу…',
|
||||
},
|
||||
{
|
||||
key: 'health_status',
|
||||
label: 'Статус',
|
||||
type: 'select',
|
||||
searchable: true,
|
||||
className: 'w-[168px]',
|
||||
options: HEALTH_OPTIONS,
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const sslFilterFields = useMemo<FilterFieldConfig[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'hostname',
|
||||
label: 'FQDN',
|
||||
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||
type: 'text',
|
||||
className: 'w-52',
|
||||
placeholder: 'Поиск по FQDN…',
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const ipColumns = useMemo<ColumnDef<ServiceIpRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'ip',
|
||||
accessorKey: 'ip',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="IP" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<NameCell
|
||||
icon={<NetworkIcon />}
|
||||
label={row.original.ip}
|
||||
iconClassName="text-info"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: 'Health',
|
||||
cell: ({ row }) => (
|
||||
<HealthCheckBadge
|
||||
status={row.original.status}
|
||||
latencyMs={row.original.latency_ms}
|
||||
lastCheckedAt={row.original.last_checked_at}
|
||||
lastError={row.original.last_error}
|
||||
colo={row.original.colo}
|
||||
provider={row.original.provider}
|
||||
size="xs"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'active',
|
||||
header: 'Пул',
|
||||
cell: ({ row }) =>
|
||||
markActive && row.original.active ? (
|
||||
<StatusBadge status="active" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'weight',
|
||||
accessorKey: 'weight',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Вес" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{service.lb_mode === 'weighted' ? `w${row.original.weight}` : row.original.weight}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'priority',
|
||||
accessorKey: 'priority',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Приоритет" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.priority}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'enabled',
|
||||
header: 'Вкл',
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={row.original.enabled}
|
||||
disabled={togglingIp === row.original.ip}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleIp(row.original.ip, Boolean(checked))
|
||||
}
|
||||
aria-label={
|
||||
row.original.enabled
|
||||
? `Выключить IP ${row.original.ip}`
|
||||
: `Включить IP ${row.original.ip}`
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[markActive, onToggleIp, service.lb_mode, togglingIp],
|
||||
)
|
||||
|
||||
const fqdnColumns = useMemo<ColumnDef<ServiceFqdnRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'fqdn',
|
||||
accessorKey: 'fqdn',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="FQDN" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<NameCell
|
||||
icon={<GlobeIcon />}
|
||||
label={row.original.fqdn}
|
||||
iconClassName="text-foreground"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'zone',
|
||||
accessorKey: 'zone_name',
|
||||
header: 'Зона',
|
||||
},
|
||||
{
|
||||
id: 'ips',
|
||||
header: 'Target IP',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{row.original.target_ips.join(', ') || '—'}
|
||||
</span>
|
||||
<Badge variant="outline" size="xs">
|
||||
{row.original.target_ips.length} IP
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChangeIp(row.original)}
|
||||
>
|
||||
Сменить IP
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onChangeIp],
|
||||
)
|
||||
|
||||
const nodeColumns = useMemo<ColumnDef<ServiceNodeRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'address',
|
||||
accessorKey: 'address',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Адрес" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<NameCell
|
||||
icon={<ServerIcon />}
|
||||
label={row.original.address}
|
||||
iconClassName="text-foreground"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
accessorKey: 'health_status',
|
||||
header: 'Health',
|
||||
cell: ({ row }) => (
|
||||
<HealthCheckBadge status={row.original.health_status} size="xs" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'meta',
|
||||
header: 'Вес / приоритет',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{row.original.protocol}
|
||||
{row.original.port ? `:${row.original.port}` : ''} · w
|
||||
{row.original.weight} · p{row.original.priority}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onDeleteNode(row.original.nodeId)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[onDeleteNode],
|
||||
)
|
||||
|
||||
const sslColumns = useMemo<ColumnDef<ServiceCertificateRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'hostname',
|
||||
accessorKey: 'hostname',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="FQDN" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<NameCell
|
||||
icon={<ShieldCheckIcon />}
|
||||
label={row.original.hostname}
|
||||
iconClassName="text-foreground"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'expires_at',
|
||||
accessorKey: 'expires_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Истекает" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDate(row.original.expires_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'relative',
|
||||
header: 'Срок',
|
||||
cell: ({ row }) =>
|
||||
certRelativeBadge(row.original.status, row.original.expires_at),
|
||||
},
|
||||
{
|
||||
id: 'mode',
|
||||
header: 'Режим',
|
||||
cell: ({ row }) => (
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
size="sm"
|
||||
value={[row.original.cert_monitoring]}
|
||||
onValueChange={(next) => {
|
||||
const value = Array.isArray(next) ? next[0] : next
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value === row.original.cert_monitoring
|
||||
) {
|
||||
return
|
||||
}
|
||||
patchCertMonitoring.mutate({
|
||||
bindingId: row.original.binding_id,
|
||||
mode: value as CertMonitoring,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{certMonitoringOptions.map((option) => (
|
||||
<ToggleGroupItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'last_checked_at',
|
||||
accessorKey: 'last_checked_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Проверка" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{formatDate(row.original.last_checked_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[patchCertMonitoring],
|
||||
)
|
||||
|
||||
const sharedTabs = {
|
||||
tabs,
|
||||
activeTab: tab,
|
||||
onTabChange: (id: string) => setTab(id as typeof tab),
|
||||
}
|
||||
|
||||
if (tab === 'fqdn') {
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN и ноды этого сервиса"
|
||||
{...sharedTabs}
|
||||
filterFields={fqdnFilterFields}
|
||||
filters={fqdnFilters}
|
||||
onFiltersChange={setFqdnFilters}
|
||||
onClearFilters={() => setFqdnFilters([createFilter('fqdn', 'contains', [''])])}
|
||||
getFilterFieldValue={(item, field) =>
|
||||
field === 'fqdn' ? `${item.fqdn} ${item.zone_name}` : ''
|
||||
}
|
||||
columns={fqdnColumns}
|
||||
data={fqdnRows}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
primaryAction={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onChangeDomain}
|
||||
disabled={fqdnRows.length === 0}
|
||||
>
|
||||
Сменить домен
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (tab === 'nodes') {
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN и ноды этого сервиса"
|
||||
{...sharedTabs}
|
||||
filterFields={nodeFilterFields}
|
||||
filters={nodeFilters}
|
||||
onFiltersChange={setNodeFilters}
|
||||
onClearFilters={() =>
|
||||
setNodeFilters([
|
||||
createFilter('address', 'contains', ['']),
|
||||
createFilter('health_status', 'is', ['']),
|
||||
])
|
||||
}
|
||||
getFilterFieldValue={(item, field) => {
|
||||
if (field === 'address') return item.address
|
||||
if (field === 'health_status') return item.health_status
|
||||
return ''
|
||||
}}
|
||||
columns={nodeColumns}
|
||||
data={nodeRows}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
primaryAction={
|
||||
<Button size="sm" onClick={onAddNode}>
|
||||
<PlusIcon className="size-4" aria-hidden />
|
||||
Добавить ноду
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (tab === 'ssl') {
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN, ноды и SSL этого сервиса"
|
||||
{...sharedTabs}
|
||||
filterFields={sslFilterFields}
|
||||
filters={sslFilters}
|
||||
onFiltersChange={setSslFilters}
|
||||
onClearFilters={() =>
|
||||
setSslFilters([createFilter('hostname', 'contains', [''])])
|
||||
}
|
||||
getFilterFieldValue={(item, field) =>
|
||||
field === 'hostname' ? item.hostname : ''
|
||||
}
|
||||
columns={sslColumns}
|
||||
data={sslRows}
|
||||
getRowId={(row) => String(row.binding_id)}
|
||||
isLoading={isLoading || certQuery.isLoading}
|
||||
primaryAction={
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Проверить SSL"
|
||||
disabled={checkSsl.isPending || sslRows.length === 0}
|
||||
onClick={() => checkSsl.mutate()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ShieldCheckIcon className="size-4.5" aria-hidden />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Проверить</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
emptyState={{
|
||||
title: 'Нет FQDN',
|
||||
description: 'Привяжите домен к сервису, чтобы мониторить SSL.',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResourcePage
|
||||
title="Активы сервиса"
|
||||
description="IP, FQDN и ноды этого сервиса"
|
||||
{...sharedTabs}
|
||||
filterFields={ipFilterFields}
|
||||
filters={ipFilters}
|
||||
onFiltersChange={setIpFilters}
|
||||
onClearFilters={() =>
|
||||
setIpFilters([
|
||||
createFilter('ip', 'contains', ['']),
|
||||
createFilter('status', 'is', ['']),
|
||||
])
|
||||
}
|
||||
getFilterFieldValue={(item, field) => {
|
||||
if (field === 'ip') return item.ip
|
||||
if (field === 'status') return item.status
|
||||
return ''
|
||||
}}
|
||||
columns={ipColumns}
|
||||
data={ipRows}
|
||||
getRowId={(row) => row.id}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2,12 +2,20 @@ import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { TruncatedText } from '@/components/truncated-text'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
||||
import type { ServiceView } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -17,6 +25,9 @@ import {
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
/** Matches `Button size="icon-sm"` so Switch columns align with the card menu. */
|
||||
const MENU_SLOT_CLASS = 'size-7 shrink-0'
|
||||
|
||||
export function CopyFqdnButton({
|
||||
value,
|
||||
className,
|
||||
@@ -126,6 +137,11 @@ interface ServiceIpListProps {
|
||||
togglingIp?: string | null
|
||||
ipToggleDisabled?: boolean
|
||||
onToggleIp?: (ip: string, enabled: boolean) => void
|
||||
/** Invisible icon-sm slot so IP Switch lines up with the card overflow menu. */
|
||||
alignWithMenu?: boolean
|
||||
lbMode?: ServiceView['lb_mode']
|
||||
activeIps?: string[]
|
||||
ipWeights?: Record<string, number>
|
||||
className?: string
|
||||
emptyLabel?: string
|
||||
copyable?: boolean
|
||||
@@ -139,6 +155,10 @@ export function ServiceIpList({
|
||||
togglingIp = null,
|
||||
ipToggleDisabled = false,
|
||||
onToggleIp,
|
||||
alignWithMenu = false,
|
||||
lbMode,
|
||||
activeIps = [],
|
||||
ipWeights = {},
|
||||
className,
|
||||
emptyLabel = 'Нет IP',
|
||||
copyable = false,
|
||||
@@ -155,49 +175,83 @@ export function ServiceIpList({
|
||||
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
|
||||
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
|
||||
const extraCount = ips.length - visible.length
|
||||
const showMenuSlot = Boolean(onToggleIp && alignWithMenu)
|
||||
const markActive = lbMode === 'failover' || lbMode === 'weighted'
|
||||
const activeSet = new Set(activeIps)
|
||||
|
||||
return (
|
||||
<div className={cn('flex min-w-0 flex-col gap-1', className)}>
|
||||
<ItemGroup className={cn('gap-1', className)}>
|
||||
{visible.map((ip) => {
|
||||
const health = healthByIp.get(ip)
|
||||
const enabled = ipEnabled[ip] !== false
|
||||
return (
|
||||
<div key={ip} className="flex min-w-0 items-center gap-1.5">
|
||||
<HealthCheckBadge
|
||||
status={health?.status ?? 'unknown'}
|
||||
latencyMs={health?.latency_ms}
|
||||
lastCheckedAt={health?.last_checked_at}
|
||||
lastError={health?.last_error}
|
||||
colo={health?.colo}
|
||||
provider={health?.provider}
|
||||
size="xs"
|
||||
/>
|
||||
<TruncatedText
|
||||
className={cn(
|
||||
'min-w-0 font-mono text-xs',
|
||||
enabled ? 'text-muted-foreground' : 'text-muted-foreground/60',
|
||||
textClassName,
|
||||
)}
|
||||
>
|
||||
{ip}
|
||||
</TruncatedText>
|
||||
{copyable ? <CopyFqdnButton value={ip} /> : null}
|
||||
{onToggleIp ? (
|
||||
<Switch
|
||||
size="sm"
|
||||
className="ml-auto shrink-0"
|
||||
checked={enabled}
|
||||
disabled={ipToggleDisabled || togglingIp === ip}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onCheckedChange={(checked) => onToggleIp(ip, Boolean(checked))}
|
||||
aria-label={
|
||||
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
|
||||
}
|
||||
<Item
|
||||
key={ip}
|
||||
size="sm"
|
||||
className="w-full min-w-0 flex-nowrap border-0 p-0"
|
||||
>
|
||||
<ItemMedia>
|
||||
<HealthCheckBadge
|
||||
status={health?.status ?? 'unknown'}
|
||||
latencyMs={health?.latency_ms}
|
||||
lastCheckedAt={health?.last_checked_at}
|
||||
lastError={health?.last_error}
|
||||
colo={health?.colo}
|
||||
provider={health?.provider}
|
||||
size="xs"
|
||||
/>
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0 gap-0">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<TruncatedText
|
||||
className={cn(
|
||||
'min-w-0 font-mono text-xs',
|
||||
enabled
|
||||
? 'text-muted-foreground'
|
||||
: 'text-muted-foreground/60',
|
||||
textClassName,
|
||||
)}
|
||||
>
|
||||
{ip}
|
||||
</TruncatedText>
|
||||
{copyable ? <CopyFqdnButton value={ip} /> : null}
|
||||
{markActive && activeSet.has(ip) ? (
|
||||
<StatusBadge status="active" className="shrink-0" />
|
||||
) : null}
|
||||
{lbMode === 'weighted' ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="shrink-0 tabular-nums"
|
||||
>
|
||||
w{ipWeights[ip] ?? 1}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</ItemContent>
|
||||
{onToggleIp ? (
|
||||
<ItemActions className="ml-auto shrink-0 gap-1">
|
||||
<Switch
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
checked={enabled}
|
||||
disabled={ipToggleDisabled || togglingIp === ip}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleIp(ip, Boolean(checked))
|
||||
}
|
||||
aria-label={
|
||||
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
|
||||
}
|
||||
/>
|
||||
{showMenuSlot ? (
|
||||
<span className={MENU_SLOT_CLASS} aria-hidden="true" />
|
||||
) : null}
|
||||
</ItemActions>
|
||||
) : null}
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
})}
|
||||
{extraCount > 0 ? (
|
||||
@@ -224,6 +278,6 @@ export function ServiceIpList({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
</ItemGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
|
||||
import {
|
||||
GitForkIcon,
|
||||
MoreHorizontalIcon,
|
||||
Repeat2Icon,
|
||||
ScaleIcon,
|
||||
ServerIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
@@ -23,6 +29,12 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@cfdm/ui/components/dropdown-menu'
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemMedia,
|
||||
} from '@cfdm/ui/components/item'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -30,6 +42,63 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
/**
|
||||
* Compact service card — settings-8 DNA (Badge + copy + Switch + menu).
|
||||
* Preview: https://reui.io/preview/base/settings-8
|
||||
* Frame: https://reui.io/docs/components/base/frame
|
||||
* IconTile: https://reui.io/docs/components/base/icon-tile
|
||||
* Header fill: FramePanel `bg-muted` (overrides `--frame-panel-bg`; see frame.tsx).
|
||||
*/
|
||||
|
||||
type LbMode = ServiceView['lb_mode']
|
||||
|
||||
const LB_MODE_META: Record<
|
||||
LbMode,
|
||||
{ icon: LucideIcon; className: string; label: string }
|
||||
> = {
|
||||
round_robin: {
|
||||
icon: Repeat2Icon,
|
||||
className: 'text-info',
|
||||
label: 'Round Robin',
|
||||
},
|
||||
failover: {
|
||||
icon: GitForkIcon,
|
||||
className: 'text-warning',
|
||||
label: 'Failover (приоритет)',
|
||||
},
|
||||
weighted: {
|
||||
icon: ScaleIcon,
|
||||
className: 'text-info',
|
||||
label: 'Weighted (веса)',
|
||||
},
|
||||
}
|
||||
|
||||
export function LbModeTile({ mode }: { mode: LbMode }) {
|
||||
const meta = LB_MODE_META[mode]
|
||||
const Icon = meta.icon
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="xs"
|
||||
className={cn('shrink-0', meta.className)}
|
||||
aria-label={meta.label}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{meta.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
interface ServiceUnitCardProps {
|
||||
service: ServiceView
|
||||
@@ -55,27 +124,32 @@ export function ServiceUnitCard({
|
||||
const extraCount = Math.max(0, fqdns.length - 1)
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden">
|
||||
<FrameHeader className="flex-row items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ServerIcon />
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle className="min-w-0 truncate text-sm">
|
||||
<Link
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: String(service.id) }}
|
||||
className="hover:underline"
|
||||
>
|
||||
{service.name}
|
||||
</Link>
|
||||
</FrameTitle>
|
||||
<Frame stacked spacing="sm" className="h-full min-w-0">
|
||||
<FramePanel fit className="bg-muted">
|
||||
<Item size="sm" className="w-full min-w-0 flex-nowrap border-0 p-0">
|
||||
<ItemMedia>
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
size="sm"
|
||||
className="text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ServerIcon />
|
||||
</IconTile>
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0 gap-px">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<FrameTitle className="min-w-0 truncate text-base font-semibold">
|
||||
<Link
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: String(service.id) }}
|
||||
className="hover:underline"
|
||||
>
|
||||
{service.name}
|
||||
</Link>
|
||||
</FrameTitle>
|
||||
<LbModeTile mode={service.lb_mode} />
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
||||
{primaryDomain}
|
||||
@@ -108,66 +182,70 @@ export function ServiceUnitCard({
|
||||
<CopyFqdnButton value={fqdns.join('\n')} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={service.enabled}
|
||||
disabled={togglingId === service.id}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleService(service.id, Boolean(checked))
|
||||
}
|
||||
aria-label={
|
||||
service.enabled ? 'Выключить сервис' : 'Включить сервис'
|
||||
}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Действия ${service.name}`}
|
||||
/>
|
||||
</ItemContent>
|
||||
<ItemActions className="ml-auto shrink-0 gap-1">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={service.enabled}
|
||||
disabled={togglingId === service.id}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleService(service.id, Boolean(checked))
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
aria-label={
|
||||
service.enabled ? 'Выключить сервис' : 'Включить сервис'
|
||||
}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Link
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: String(service.id) }}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Действия ${service.name}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEditService(service)}>
|
||||
Изменить
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDeleteService(service)}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<MoreHorizontalIcon aria-hidden />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
to="/services/$serviceId"
|
||||
params={{ serviceId: String(service.id) }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Обзор
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEditService(service)}>
|
||||
Изменить
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDeleteService(service)}
|
||||
>
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</FramePanel>
|
||||
|
||||
<FramePanel className="flex min-w-0 flex-col gap-1 pt-0 shadow-none!">
|
||||
<FramePanel className="flex min-w-0 flex-col">
|
||||
<ServiceIpList
|
||||
copyable
|
||||
alignWithMenu
|
||||
ips={service.ips ?? []}
|
||||
ipHealth={service.ip_health ?? []}
|
||||
ipEnabled={service.ip_enabled ?? {}}
|
||||
ipToggleDisabled={togglingId === service.id}
|
||||
togglingIp={togglingIp}
|
||||
lbMode={service.lb_mode}
|
||||
activeIps={service.active_ips}
|
||||
ipWeights={service.domains[0]?.target_ip_weights}
|
||||
onToggleIp={(ip, enabled) =>
|
||||
onToggleServiceIp(service.id, ip, enabled)
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function SettingRow({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full justify-start',
|
||||
'flex w-full min-w-0 justify-start',
|
||||
stacked ? 'justify-start' : '@md/field-group:justify-end',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,10 +3,8 @@ import { useEffect, useMemo } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import type { CertMonitoring } from '@cfdm/shared'
|
||||
import type { ServiceView, SubdomainRecord } from '@/lib/schemas'
|
||||
import type { SubdomainServiceLink } from '@/hooks/use-domain-page'
|
||||
import { certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||
import { formatServiceGroupLabel } from '@/lib/service-utils'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
@@ -30,7 +28,6 @@ import {
|
||||
const subdomainEditSchema = z.object({
|
||||
name: z.string().min(1, 'Укажите имя'),
|
||||
serviceId: z.string(),
|
||||
certMonitoring: z.enum(['auto', 'required', 'skipped']),
|
||||
})
|
||||
|
||||
export type SubdomainEditValues = z.infer<typeof subdomainEditSchema>
|
||||
@@ -67,7 +64,6 @@ export function SubdomainEditSheet({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
serviceId: 'none',
|
||||
certMonitoring: 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -85,42 +81,27 @@ export function SubdomainEditSheet({
|
||||
[services, serviceGroupById],
|
||||
)
|
||||
|
||||
const certMonitoringItems = useMemo(
|
||||
() =>
|
||||
certMonitoringOptions.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
})),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (mode === 'edit' && subdomain) {
|
||||
form.reset({
|
||||
name: subdomain.name,
|
||||
serviceId: currentServiceId || 'none',
|
||||
certMonitoring: subdomain.cert_monitoring,
|
||||
})
|
||||
return
|
||||
}
|
||||
form.reset({
|
||||
name: '',
|
||||
serviceId: 'none',
|
||||
certMonitoring: 'auto',
|
||||
})
|
||||
}, [open, mode, subdomain, currentServiceId, form])
|
||||
|
||||
const certMonitoring = form.watch('certMonitoring')
|
||||
const certHint =
|
||||
certMonitoringOptions.find((o) => o.value === certMonitoring)?.description
|
||||
const hasMultipleServices = mode === 'edit' && serviceLinks.length > 1
|
||||
|
||||
function handleSubmit(values: SubdomainEditValues) {
|
||||
onSubmit({
|
||||
name: values.name.trim(),
|
||||
serviceId: values.serviceId,
|
||||
certMonitoring: values.certMonitoring as CertMonitoring,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -218,39 +199,6 @@ export function SubdomainEditSheet({
|
||||
)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple
|
||||
label="Мониторинг SSL"
|
||||
htmlFor="subdomain_cert_monitoring"
|
||||
hint={certHint}
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="certMonitoring"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={certMonitoringItems}
|
||||
value={field.value}
|
||||
onValueChange={(value) =>
|
||||
field.onChange((value ?? 'auto') as CertMonitoring)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="subdomain_cert_monitoring"
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{certMonitoringOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
</>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
serviceGroupsQueryOptions,
|
||||
servicesQueryOptions,
|
||||
subdomainsListQueryOptions,
|
||||
updateDomain,
|
||||
updateSubdomain,
|
||||
} from '@/queries'
|
||||
import type { CertMonitoring } from '@cfdm/shared'
|
||||
@@ -172,21 +171,6 @@ export function useDomainPage(domainId: number) {
|
||||
},
|
||||
})
|
||||
|
||||
const updateDomainCertMonitoringMutation = useMutation({
|
||||
mutationFn: (certMonitoring: CertMonitoring) =>
|
||||
updateDomain(domainId, { cert_monitoring: certMonitoring }),
|
||||
onSuccess: () => {
|
||||
invalidate()
|
||||
void queryClient.invalidateQueries({ queryKey: ['domains'] })
|
||||
toast.success('Режим мониторинга SSL обновлён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : 'Не удалось обновить мониторинг SSL',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteSubdomainMutation = useMutation({
|
||||
mutationFn: (id: number) => deleteSubdomain(id),
|
||||
onSuccess: () => {
|
||||
@@ -249,7 +233,6 @@ export function useDomainPage(domainId: number) {
|
||||
syncMutation,
|
||||
createSubdomainMutation,
|
||||
updateSubdomainMutation,
|
||||
updateDomainCertMonitoringMutation,
|
||||
deleteSubdomainMutation,
|
||||
linkServiceMutation,
|
||||
}
|
||||
|
||||
@@ -8,17 +8,17 @@ export const certMonitoringOptions: Array<{
|
||||
{
|
||||
value: 'auto',
|
||||
label: 'Авто',
|
||||
description: 'Проверять, если хост обслуживается активным сервисом',
|
||||
description: 'Проверять, если health-check сервиса с verify TLS',
|
||||
},
|
||||
{
|
||||
value: 'required',
|
||||
label: 'Обязательно',
|
||||
description: 'Всегда проверять SSL, даже без привязок',
|
||||
description: 'Всегда проверять SSL для этого FQDN',
|
||||
},
|
||||
{
|
||||
value: 'skipped',
|
||||
label: 'Не проверять',
|
||||
description: 'Исключить из мониторинга сертификатов',
|
||||
description: 'Исключить FQDN из мониторинга сертификатов',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
collapseStatusChanges,
|
||||
enabledHealthProviders,
|
||||
providerHealthStatuses,
|
||||
worstHealthStatus,
|
||||
type HealthLogProbe,
|
||||
} from '@/lib/health-log'
|
||||
|
||||
function probe(
|
||||
overrides: Partial<HealthLogProbe> & Pick<HealthLogProbe, 'id' | 'status' | 'checked_at'>,
|
||||
): HealthLogProbe {
|
||||
return {
|
||||
ip: '1.1.1.1',
|
||||
provider: 'local',
|
||||
ok: overrides.status === 'up',
|
||||
latency_ms: 12,
|
||||
colo: null,
|
||||
error: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('collapseStatusChanges', () => {
|
||||
it('keeps only status transitions per ip+provider', () => {
|
||||
const items = [
|
||||
probe({ id: 1, status: 'up', checked_at: '2026-01-01T00:00:00Z' }),
|
||||
probe({ id: 2, status: 'up', checked_at: '2026-01-01T00:01:00Z' }),
|
||||
probe({ id: 3, status: 'down', checked_at: '2026-01-01T00:02:00Z' }),
|
||||
probe({ id: 4, status: 'down', checked_at: '2026-01-01T00:03:00Z' }),
|
||||
probe({ id: 5, status: 'up', checked_at: '2026-01-01T00:04:00Z' }),
|
||||
]
|
||||
const changes = collapseStatusChanges(items)
|
||||
expect(changes.map((item) => item.id)).toEqual([5, 3, 1])
|
||||
})
|
||||
|
||||
it('tracks series independently by provider', () => {
|
||||
const items = [
|
||||
probe({ id: 1, provider: 'local', status: 'up', checked_at: '2026-01-01T00:00:00Z' }),
|
||||
probe({
|
||||
id: 2,
|
||||
provider: 'cloudflare',
|
||||
status: 'up',
|
||||
checked_at: '2026-01-01T00:00:00Z',
|
||||
}),
|
||||
probe({ id: 3, provider: 'local', status: 'up', checked_at: '2026-01-01T00:01:00Z' }),
|
||||
probe({
|
||||
id: 4,
|
||||
provider: 'cloudflare',
|
||||
status: 'down',
|
||||
checked_at: '2026-01-01T00:01:00Z',
|
||||
}),
|
||||
]
|
||||
const changes = collapseStatusChanges(items)
|
||||
expect(changes.map((item) => item.id).sort()).toEqual([1, 2, 4])
|
||||
})
|
||||
})
|
||||
|
||||
describe('enabledHealthProviders', () => {
|
||||
it('unions bindings in registry order', () => {
|
||||
expect(
|
||||
enabledHealthProviders([
|
||||
{ health_check_providers: ['globalping'] },
|
||||
{ health_check_providers: ['local', 'cloudflare'] },
|
||||
]),
|
||||
).toEqual(['local', 'cloudflare', 'globalping'])
|
||||
})
|
||||
|
||||
it('falls back to local', () => {
|
||||
expect(enabledHealthProviders([])).toEqual(['local'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('providerHealthStatuses', () => {
|
||||
it('uses worst latest-per-ip status', () => {
|
||||
const items = [
|
||||
probe({ id: 1, ip: '1.1.1.1', status: 'up', checked_at: '2026-01-01T00:02:00Z' }),
|
||||
probe({ id: 2, ip: '2.2.2.2', status: 'down', checked_at: '2026-01-01T00:01:00Z' }),
|
||||
probe({
|
||||
id: 3,
|
||||
ip: '2.2.2.2',
|
||||
status: 'up',
|
||||
checked_at: '2026-01-01T00:00:00Z',
|
||||
}),
|
||||
]
|
||||
expect(providerHealthStatuses(items, ['local']).local).toBe('down')
|
||||
})
|
||||
})
|
||||
|
||||
describe('worstHealthStatus', () => {
|
||||
it('ranks down over degraded over up', () => {
|
||||
expect(worstHealthStatus(['up', 'degraded'])).toBe('degraded')
|
||||
expect(worstHealthStatus(['degraded', 'down'])).toBe('down')
|
||||
expect(worstHealthStatus([])).toBe('unknown')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { HealthCheckProvider } from '@cfdm/shared'
|
||||
import { HEALTH_CHECK_PROVIDERS, uniqueHealthProviders } from '@cfdm/shared'
|
||||
|
||||
import { sqliteUtcToIso } from '@/lib/format'
|
||||
import type { IpHealthStatus } from '@/lib/schemas'
|
||||
|
||||
export type HealthLogStatus = IpHealthStatus['status']
|
||||
|
||||
export interface HealthLogProbe {
|
||||
id: number
|
||||
ip: string
|
||||
provider: HealthCheckProvider
|
||||
status: HealthLogStatus
|
||||
ok: boolean
|
||||
latency_ms: number | null
|
||||
colo: string | null
|
||||
error: string | null
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
const STATUS_RANK: Record<HealthLogStatus, number> = {
|
||||
unknown: 0,
|
||||
up: 1,
|
||||
degraded: 2,
|
||||
down: 3,
|
||||
}
|
||||
|
||||
export function probeTime(checkedAt: string): number {
|
||||
const iso = sqliteUtcToIso(checkedAt) ?? checkedAt
|
||||
const time = new Date(iso).getTime()
|
||||
return Number.isNaN(time) ? 0 : time
|
||||
}
|
||||
|
||||
export function filterByPeriod<T extends { checked_at: string }>(
|
||||
items: T[],
|
||||
days: number,
|
||||
): T[] {
|
||||
const cutoff = Date.now() - days * 86_400_000
|
||||
return items.filter((item) => probeTime(item.checked_at) >= cutoff)
|
||||
}
|
||||
|
||||
export function filterByProviders<T extends { provider: string }>(
|
||||
items: T[],
|
||||
providers: readonly HealthCheckProvider[],
|
||||
): T[] {
|
||||
if (providers.length === 0) return items
|
||||
const allowed = new Set(providers)
|
||||
return items.filter((item) => allowed.has(item.provider as HealthCheckProvider))
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the first probe of each ip+provider series and every later probe
|
||||
* whose status differs from the previous one. Newest first.
|
||||
*/
|
||||
export function collapseStatusChanges<T extends HealthLogProbe>(items: T[]): T[] {
|
||||
const byKey = new Map<string, T[]>()
|
||||
for (const item of items) {
|
||||
const key = `${item.ip}\0${item.provider}`
|
||||
const list = byKey.get(key)
|
||||
if (list) list.push(item)
|
||||
else byKey.set(key, [item])
|
||||
}
|
||||
|
||||
const changes: T[] = []
|
||||
for (const list of byKey.values()) {
|
||||
list.sort(
|
||||
(a, b) => probeTime(a.checked_at) - probeTime(b.checked_at) || a.id - b.id,
|
||||
)
|
||||
let previous: HealthLogStatus | undefined
|
||||
for (const item of list) {
|
||||
if (item.status !== previous) {
|
||||
changes.push(item)
|
||||
previous = item.status
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changes.sort(
|
||||
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id,
|
||||
)
|
||||
return changes
|
||||
}
|
||||
|
||||
export function enabledHealthProviders(
|
||||
domains: Array<{ health_check_providers?: readonly HealthCheckProvider[] | null }>,
|
||||
): HealthCheckProvider[] {
|
||||
const collected = uniqueHealthProviders(
|
||||
domains.flatMap((domain) => domain.health_check_providers ?? []),
|
||||
)
|
||||
if (collected.length === 0) return ['local']
|
||||
return HEALTH_CHECK_PROVIDERS.filter((provider) => collected.includes(provider))
|
||||
}
|
||||
|
||||
export function worstHealthStatus(statuses: readonly HealthLogStatus[]): HealthLogStatus {
|
||||
if (statuses.length === 0) return 'unknown'
|
||||
return statuses.reduce((worst, status) =>
|
||||
STATUS_RANK[status] > STATUS_RANK[worst] ? status : worst,
|
||||
)
|
||||
}
|
||||
|
||||
/** Latest probe per IP for a provider, then worst among those IPs. */
|
||||
export function providerHealthStatuses(
|
||||
items: readonly HealthLogProbe[],
|
||||
providers: readonly HealthCheckProvider[],
|
||||
): Record<HealthCheckProvider, HealthLogStatus> {
|
||||
const latestByIp = new Map<string, HealthLogProbe>()
|
||||
const sorted = [...items].sort(
|
||||
(a, b) => probeTime(b.checked_at) - probeTime(a.checked_at) || b.id - a.id,
|
||||
)
|
||||
for (const item of sorted) {
|
||||
const key = `${item.provider}\0${item.ip}`
|
||||
if (!latestByIp.has(key)) latestByIp.set(key, item)
|
||||
}
|
||||
|
||||
const result = Object.fromEntries(
|
||||
HEALTH_CHECK_PROVIDERS.map((provider) => [provider, 'unknown' as HealthLogStatus]),
|
||||
) as Record<HealthCheckProvider, HealthLogStatus>
|
||||
|
||||
for (const provider of providers) {
|
||||
const statuses = [...latestByIp.values()]
|
||||
.filter((item) => item.provider === provider)
|
||||
.map((item) => item.status)
|
||||
result[provider] = worstHealthStatus(statuses)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -36,7 +36,9 @@ export const serviceGroupSchema = z.object({
|
||||
health_check_interval_sec: z.number().default(30),
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: z.enum(['local', 'cloudflare']).catch('local'),
|
||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
@@ -77,7 +79,10 @@ export const serviceDomainBindingSchema = z
|
||||
health_check_interval_sec: z.number().default(30),
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: z.enum(['local', 'cloudflare']).catch('local'),
|
||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
})
|
||||
.transform((binding) => ({
|
||||
@@ -102,7 +107,7 @@ export const serviceIpHealthSchema = z.object({
|
||||
latency_ms: z.number().nullable(),
|
||||
last_checked_at: z.string().nullable().optional(),
|
||||
last_error: z.string().nullable().optional(),
|
||||
provider: z.enum(['local', 'cloudflare']).optional(),
|
||||
provider: z.enum(['local', 'cloudflare', 'globalping', 'aggregate']).optional(),
|
||||
colo: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
@@ -111,7 +116,7 @@ export const healthProbeLogSchema = z.object({
|
||||
scope: z.string(),
|
||||
ref_id: z.number(),
|
||||
ip: z.string(),
|
||||
provider: z.enum(['local', 'cloudflare']),
|
||||
provider: z.enum(['local', 'cloudflare', 'globalping']),
|
||||
status: z.enum(['up', 'down', 'degraded', 'unknown']),
|
||||
ok: z.coerce.boolean(),
|
||||
latency_ms: z.number().nullable(),
|
||||
@@ -129,6 +134,8 @@ export const serviceViewSchema = serviceSchema.extend({
|
||||
health_latency_ms: z.number().nullable().default(null),
|
||||
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
||||
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
|
||||
active_ips: z.array(z.string()).default([]),
|
||||
})
|
||||
|
||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||
@@ -188,7 +195,10 @@ export const serviceBindingSchema = z
|
||||
health_check_interval_sec: z.number().default(30),
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: z.enum(['local', 'cloudflare']).catch('local'),
|
||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).catch('local'),
|
||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).catch(['local']),
|
||||
health_check_aggregate: z.enum(['any', 'all', 'majority']).catch('majority'),
|
||||
cert_monitoring: z.enum(['auto', 'required', 'skipped']).default('auto'),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
@@ -226,6 +236,8 @@ export const certificateSchema = z.object({
|
||||
id: z.number(),
|
||||
domain_id: z.number(),
|
||||
subdomain_id: z.number().nullable(),
|
||||
service_id: z.number().nullable().optional().default(null),
|
||||
service_name: z.string().nullable().optional().default(null),
|
||||
hostname: z.string(),
|
||||
expires_at: z.string().nullable(),
|
||||
last_checked_at: z.string().nullable(),
|
||||
@@ -235,6 +247,19 @@ export const certificateSchema = z.object({
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const serviceCertificateRowSchema = z.object({
|
||||
binding_id: z.number(),
|
||||
domain_id: z.number(),
|
||||
service_id: z.number(),
|
||||
hostname: z.string(),
|
||||
cert_monitoring: z.enum(['auto', 'required', 'skipped']),
|
||||
id: z.number().nullable(),
|
||||
status: z.string(),
|
||||
expires_at: z.string().nullable(),
|
||||
last_checked_at: z.string().nullable(),
|
||||
last_error: z.string().nullable(),
|
||||
})
|
||||
|
||||
export type Group = z.infer<typeof groupSchema>
|
||||
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
|
||||
export type Service = z.infer<typeof serviceSchema>
|
||||
@@ -248,6 +273,7 @@ export type DomainListItem = z.infer<typeof domainListItemSchema>
|
||||
export type ServiceBinding = z.infer<typeof serviceBindingSchema>
|
||||
export type DnsRecord = z.infer<typeof dnsRecordSchema>
|
||||
export type Certificate = z.infer<typeof certificateSchema>
|
||||
export type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>
|
||||
|
||||
export const createGroupSchema = z.object({
|
||||
name: z.string().min(1, '╨г╨║╨░╨╢╨╕╤В╨╡ ╨╜╨░╨╖╨▓╨░╨╜╨╕╨╡'),
|
||||
@@ -265,15 +291,17 @@ const lbModeSchema = z.enum(['round_robin', 'failover', 'weighted'])
|
||||
const healthCheckTypeSchema = z.enum(['tcp', 'http', 'ping', 'dns'])
|
||||
|
||||
const healthCheckConfigFields = {
|
||||
health_check_enabled: z.boolean().optional(),
|
||||
health_check_enabled: z.coerce.boolean().optional(),
|
||||
health_check_type: healthCheckTypeSchema.optional(),
|
||||
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
|
||||
health_check_path: z.string().nullable().optional(),
|
||||
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
|
||||
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
|
||||
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
|
||||
health_check_verify_tls: z.boolean().optional(),
|
||||
health_check_provider: z.enum(['local', 'cloudflare']).optional(),
|
||||
health_check_verify_tls: z.coerce.boolean().optional(),
|
||||
health_check_provider: z.enum(['local', 'cloudflare', 'globalping']).optional(),
|
||||
health_check_providers: z.array(z.enum(['local', 'cloudflare', 'globalping'])).min(1).optional(),
|
||||
health_check_aggregate: z.enum(['any', 'all', 'majority']).optional(),
|
||||
}
|
||||
|
||||
const serviceDomainInputSchema = z
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { certificateSchema } from '@/lib/schemas'
|
||||
import { certificateSchema, serviceCertificateRowSchema } from '@/lib/schemas'
|
||||
import type { CertMonitoring } from '@cfdm/shared'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const certKeys = {
|
||||
all: ['certificates'] as const,
|
||||
summary: ['certificates', 'summary'] as const,
|
||||
byService: (serviceId: number) =>
|
||||
[...certKeys.all, 'service', serviceId] as const,
|
||||
}
|
||||
|
||||
export const certificatesQueryOptions = () =>
|
||||
@@ -23,3 +26,29 @@ export const certSummaryQueryOptions = () =>
|
||||
queryKey: certKeys.summary,
|
||||
queryFn: () => api.get<[string, number][]>('/api/v1/certificates/summary'),
|
||||
})
|
||||
|
||||
export const serviceCertificatesQueryOptions = (serviceId: number) =>
|
||||
queryOptions({
|
||||
queryKey: certKeys.byService(serviceId),
|
||||
queryFn: async () => {
|
||||
const data = await api.get<unknown[]>(
|
||||
`/api/v1/services/${serviceId}/certificates`,
|
||||
)
|
||||
return z.array(serviceCertificateRowSchema).parse(data)
|
||||
},
|
||||
})
|
||||
|
||||
export async function patchBindingCertMonitoring(
|
||||
bindingId: number,
|
||||
certMonitoring: CertMonitoring,
|
||||
) {
|
||||
return api.patch(`/api/v1/service-bindings/${bindingId}`, {
|
||||
cert_monitoring: certMonitoring,
|
||||
})
|
||||
}
|
||||
|
||||
export async function checkServiceCertificates(serviceId: number) {
|
||||
return api.post<{ checked: number }>(
|
||||
`/api/v1/services/${serviceId}/certificates/check`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ function CertificatesPage() {
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Сертификаты"
|
||||
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
|
||||
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
|
||||
actions={primaryAction}
|
||||
/>
|
||||
<CertKpiStats
|
||||
@@ -86,7 +86,7 @@ function CertificatesPage() {
|
||||
/>
|
||||
<ResourcePage
|
||||
title="Сертификаты"
|
||||
description="Мониторинг SSL: health-check с проверкой TLS, либо режим «Обязательно»"
|
||||
description="Сводка SSL флота: FQDN сервисов. Строка ведёт на деталку сервиса."
|
||||
hideHeader
|
||||
tabs={CERT_TABS.map((tab) => ({ ...tab }))}
|
||||
activeTab={activeTab}
|
||||
|
||||
@@ -6,11 +6,9 @@ import {
|
||||
GlobeIcon,
|
||||
Link2Icon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import type { CertMonitoring } from '@cfdm/shared'
|
||||
import {
|
||||
domainDetailQueryOptions,
|
||||
domainServiceBindingsQueryOptions,
|
||||
@@ -41,19 +39,11 @@ import {
|
||||
import { DomainBindingsPanel } from '@/components/domain-bindings-panel'
|
||||
import { DomainAvailabilityPanel } from '@/components/domains/domain-availability-panel'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { certMonitoringLabel, certMonitoringOptions } from '@/lib/cert-monitoring'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { TabsContent } from '@cfdm/ui/components/tabs'
|
||||
|
||||
export const Route = createFileRoute('/_auth/domains/$domainId/')({
|
||||
@@ -108,7 +98,6 @@ function DomainOverviewPage() {
|
||||
syncMutation,
|
||||
createSubdomainMutation,
|
||||
updateSubdomainMutation,
|
||||
updateDomainCertMonitoringMutation,
|
||||
deleteSubdomainMutation,
|
||||
linkServiceMutation,
|
||||
} = useDomainPage(id)
|
||||
@@ -167,20 +156,15 @@ function DomainOverviewPage() {
|
||||
if (!editTarget) return
|
||||
|
||||
const nameChanged = values.name !== editTarget.subdomain.name
|
||||
const certMonitoringChanged =
|
||||
values.certMonitoring !== editTarget.subdomain.cert_monitoring
|
||||
const currentServiceId = resolveServiceId(editTarget)
|
||||
const serviceChanged = values.serviceId !== currentServiceId
|
||||
const targetServiceId =
|
||||
values.serviceId === 'none' ? null : Number(values.serviceId)
|
||||
|
||||
if (nameChanged || certMonitoringChanged) {
|
||||
if (nameChanged) {
|
||||
await updateSubdomainMutation.mutateAsync({
|
||||
id: editTarget.subdomain.id,
|
||||
...(nameChanged ? { name: values.name } : {}),
|
||||
...(certMonitoringChanged
|
||||
? { cert_monitoring: values.certMonitoring }
|
||||
: {}),
|
||||
name: values.name,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -209,11 +193,6 @@ function DomainOverviewPage() {
|
||||
updateSubdomainMutation.isPending ||
|
||||
linkServiceMutation.isPending
|
||||
|
||||
const certMonitoringItems = certMonitoringOptions.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
}))
|
||||
|
||||
const metricCards = useMemo(() => {
|
||||
if (!domain) return []
|
||||
return [
|
||||
@@ -344,40 +323,6 @@ function DomainOverviewPage() {
|
||||
>
|
||||
<TabsContent value="overview" className="flex flex-col gap-4">
|
||||
<DetailPanel.Metrics cards={metricCards} />
|
||||
<DetailPanel.Section
|
||||
title="Мониторинг SSL"
|
||||
description="Настройка проверки сертификата для apex-зоны"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
|
||||
<span className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<ShieldCheckIcon className="size-4" aria-hidden="true" />
|
||||
Мониторинг SSL (apex):
|
||||
</span>
|
||||
<Select
|
||||
items={certMonitoringItems}
|
||||
value={domain.cert_monitoring}
|
||||
onValueChange={(value) =>
|
||||
updateDomainCertMonitoringMutation.mutate(
|
||||
(value ?? 'auto') as CertMonitoring,
|
||||
)
|
||||
}
|
||||
disabled={updateDomainCertMonitoringMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-56">
|
||||
<SelectValue>
|
||||
{certMonitoringLabel(domain.cert_monitoring)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{certMonitoringOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="availability" className="flex flex-col gap-4">
|
||||
|
||||
@@ -1,104 +1,11 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
|
||||
import { DetailPanel, KpiStatGrid } from '@/components/reui-kit'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { HealthTimeline } from '@/components/health/health-timeline'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import {
|
||||
serviceHealthLogQueryOptions,
|
||||
serviceViewQueryOptions,
|
||||
} from '@/queries'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/health')({
|
||||
component: ServiceHealthPage,
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/services/$serviceId',
|
||||
params,
|
||||
})
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
|
||||
export function ServiceHealthPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const id = Number(serviceId)
|
||||
const serviceQuery = useQuery(serviceViewQueryOptions(id))
|
||||
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
||||
const service = serviceQuery.data
|
||||
const items = logQuery.data?.items ?? []
|
||||
const ipHealth = service?.ip_health ?? []
|
||||
|
||||
const kpiCards = ipHealth.map((row) => {
|
||||
const variant =
|
||||
row.status === 'down'
|
||||
? ('destructive' as const)
|
||||
: row.status === 'degraded'
|
||||
? ('warning' as const)
|
||||
: ('default' as const)
|
||||
return {
|
||||
id: row.ip,
|
||||
label: row.ip,
|
||||
value: row.latency_ms != null ? `${row.latency_ms} мс` : '—',
|
||||
hint: row.colo ? `colo ${row.colo}` : row.provider === 'cloudflare' ? 'Worker' : 'Local',
|
||||
icon: row.provider === 'cloudflare' ? <GlobeIcon /> : <ServerIcon />,
|
||||
variant,
|
||||
footer: (
|
||||
<HealthCheckBadge
|
||||
status={row.status}
|
||||
latencyMs={row.latency_ms}
|
||||
lastCheckedAt={row.last_checked_at}
|
||||
lastError={row.last_error}
|
||||
colo={row.colo}
|
||||
provider={row.provider}
|
||||
size="xs"
|
||||
/>
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title="Health"
|
||||
description="Снимок проб этого сервиса. Cloudflare = Worker с edge, не Health Checks API."
|
||||
/>
|
||||
<Alert>
|
||||
<AlertTitle>XOR провайдеров</AlertTitle>
|
||||
<AlertDescription>
|
||||
Local ходит с API CFDM; Cloudflare — через Worker. Cron и пороги Slow/Down общие, в{' '}
|
||||
<Link to="/settings/health" className="text-foreground underline">
|
||||
Настройках → Health-check
|
||||
</Link>
|
||||
. Если Worker не задан, цель не пробируется как Local.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{kpiCards.length > 0 ? (
|
||||
<KpiStatGrid cards={kpiCards} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={ActivityIcon}
|
||||
title="Нет проб"
|
||||
description="Включите health-check на привязке — статус IP появится после cron."
|
||||
/>
|
||||
)}
|
||||
<DetailPanel.Header
|
||||
title="Журнал проб"
|
||||
description={
|
||||
items[0]?.checked_at
|
||||
? `Последняя: ${formatDate(items[0].checked_at)}`
|
||||
: 'Последние пробы по IP этого сервиса'
|
||||
}
|
||||
/>
|
||||
<HealthTimeline
|
||||
events={items.map((row) => ({
|
||||
id: row.id,
|
||||
hostname: row.ip,
|
||||
type: row.provider,
|
||||
status: row.status,
|
||||
latency_ms: row.latency_ms,
|
||||
error: row.error,
|
||||
checked_at: row.checked_at,
|
||||
colo: row.colo,
|
||||
provider: row.provider,
|
||||
}))}
|
||||
/>
|
||||
</DetailPanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,94 +1,431 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ActivityIcon, GlobeIcon, ServerIcon } from 'lucide-react'
|
||||
import { DetailPanel } from '@/components/reui-kit'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ActivityIcon,
|
||||
GlobeIcon,
|
||||
NetworkIcon,
|
||||
PencilIcon,
|
||||
ServerIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
|
||||
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { FailoverTimeline } from '@/components/failover-timeline'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { serviceOverviewQueryOptions } from '@/queries'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||
import {
|
||||
ServiceDetailGrid,
|
||||
type ServiceFqdnRow,
|
||||
} from '@/components/services/service-detail-grid'
|
||||
import { LbModeTile } from '@/components/services/service-unit-card'
|
||||
import {
|
||||
KpiStatGrid,
|
||||
ServiceHealthMonitor,
|
||||
} from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { api } from '@/lib/api-client'
|
||||
import {
|
||||
enabledHealthProviders,
|
||||
providerHealthStatuses,
|
||||
} from '@/lib/health-log'
|
||||
import type { ServiceView, UpdateServiceConfigInput } from '@/lib/schemas'
|
||||
import {
|
||||
createServiceNode,
|
||||
deleteServiceNode,
|
||||
domainKeys,
|
||||
domainsListQueryOptions,
|
||||
serviceBindingKeys,
|
||||
serviceDetailKeys,
|
||||
serviceGroupKeys,
|
||||
serviceGroupsQueryOptions,
|
||||
serviceHealthLogQueryOptions,
|
||||
serviceKeys,
|
||||
serviceNodesQueryOptions,
|
||||
serviceOverviewQueryOptions,
|
||||
serviceViewQueryOptions,
|
||||
} from '@/queries'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/')({
|
||||
component: ServiceOverviewPage,
|
||||
component: ServiceDetailPage,
|
||||
})
|
||||
|
||||
function ServiceOverviewPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
|
||||
const overview = data as {
|
||||
service: {
|
||||
name: string
|
||||
enabled: boolean
|
||||
health_status: 'up' | 'down' | 'degraded' | 'unknown'
|
||||
domains: Array<{ fqdn: string; zone_name: string }>
|
||||
}
|
||||
nodes: Array<{ id: number; address: string; health_status: string }>
|
||||
routing_strategy: string
|
||||
active_addresses: string[]
|
||||
} | undefined
|
||||
interface OverviewPayload {
|
||||
routing_strategy?: string
|
||||
active_addresses?: string[]
|
||||
nodes?: Array<{
|
||||
id: number
|
||||
address: string
|
||||
protocol: string
|
||||
port: number | null
|
||||
health_status: string
|
||||
weight: number
|
||||
priority: number
|
||||
consecutive_failures: number
|
||||
last_failure_reason: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
if (!overview) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Сервис не найден"
|
||||
description="Вернитесь в каталог и выберите сервис."
|
||||
/>
|
||||
)
|
||||
function ServiceDetailPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const id = Number(serviceId)
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const viewQuery = useQuery(serviceViewQueryOptions(id))
|
||||
const overviewQuery = useQuery(serviceOverviewQueryOptions(id))
|
||||
const logQuery = useQuery(serviceHealthLogQueryOptions(id))
|
||||
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
|
||||
const groupsQuery = useQuery(serviceGroupsQueryOptions())
|
||||
const domainsQuery = useQuery(domainsListQueryOptions())
|
||||
|
||||
const service = viewQuery.data
|
||||
const overview = overviewQuery.data as OverviewPayload | undefined
|
||||
const logItems = useMemo(
|
||||
() => logQuery.data?.items ?? [],
|
||||
[logQuery.data?.items],
|
||||
)
|
||||
const nodes = (nodesQuery.data as OverviewPayload['nodes']) ?? []
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [togglingIp, setTogglingIp] = useState<string | null>(null)
|
||||
const [changeIp, setChangeIp] = useState<{
|
||||
bindingId: number
|
||||
ip?: string
|
||||
} | null>(null)
|
||||
const [changeDomain, setChangeDomain] = useState(false)
|
||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||
const nodeForm = useForm<{ address: string; port: string }>({
|
||||
defaultValues: { address: '', port: '' },
|
||||
})
|
||||
|
||||
const groups = groupsQuery.data
|
||||
? [...groupsQuery.data.groups]
|
||||
: []
|
||||
|
||||
async function invalidateService() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: serviceKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceBindingKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: domainKeys.all }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.view(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.overview(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.nodes(id) }),
|
||||
queryClient.invalidateQueries({ queryKey: serviceDetailKeys.healthLog(id) }),
|
||||
])
|
||||
}
|
||||
|
||||
const nodes = overview.nodes ?? []
|
||||
const domains = overview.service.domains ?? []
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ body }: { body: UpdateServiceConfigInput }) =>
|
||||
api.patch<ServiceView>(`/api/v1/services/${id}`, body),
|
||||
onSuccess: async () => {
|
||||
await invalidateService()
|
||||
setEditOpen(false)
|
||||
toast.success('Сервис сохранён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось сохранить сервис')
|
||||
},
|
||||
onSettled: () => setSaving(false),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/api/v1/services/${id}`),
|
||||
onSuccess: async () => {
|
||||
await invalidateService()
|
||||
toast.success('Сервис удалён')
|
||||
await navigate({ to: '/services' })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось удалить сервис')
|
||||
},
|
||||
})
|
||||
|
||||
const toggleIpMutation = useMutation({
|
||||
mutationFn: ({ ip, enabled }: { ip: string; enabled: boolean }) =>
|
||||
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, { ip, enabled }),
|
||||
onSuccess: async (_data, { enabled }) => {
|
||||
await invalidateService()
|
||||
toast.success(
|
||||
enabled
|
||||
? 'IP включён и добавлен в DNS-привязки'
|
||||
: 'IP выключен и снят с DNS-привязок',
|
||||
)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Не удалось переключить IP')
|
||||
},
|
||||
onSettled: () => setTogglingIp(null),
|
||||
})
|
||||
|
||||
const createNodeMut = useMutation({
|
||||
mutationFn: (values: { address: string; port: string }) =>
|
||||
createServiceNode(id, {
|
||||
address: values.address.trim(),
|
||||
port: values.port ? Number(values.port) : null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success('Нода добавлена, статус CHECKING')
|
||||
await invalidateService()
|
||||
setAddNodeOpen(false)
|
||||
nodeForm.reset()
|
||||
},
|
||||
onError: (e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
|
||||
})
|
||||
|
||||
const deleteNodeMut = useMutation({
|
||||
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
|
||||
onSuccess: async () => {
|
||||
toast.success('Нода удалена')
|
||||
await invalidateService()
|
||||
},
|
||||
})
|
||||
|
||||
const isLoading = viewQuery.isLoading || overviewQuery.isLoading
|
||||
const isError = viewQuery.isError || overviewQuery.isError
|
||||
const error = viewQuery.error ?? overviewQuery.error
|
||||
|
||||
const failoverEvents =
|
||||
(nodes.length > 0 ? nodes : (overview?.nodes ?? []))
|
||||
.filter(
|
||||
(node) =>
|
||||
node.health_status === 'unhealthy' ||
|
||||
node.health_status === 'down' ||
|
||||
node.health_status === 'checking',
|
||||
)
|
||||
.map((node) => ({
|
||||
id: node.address,
|
||||
title: `${node.address}: ${node.health_status}`,
|
||||
detail: node.last_failure_reason
|
||||
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
|
||||
: `fail ${node.consecutive_failures}`,
|
||||
}))
|
||||
|
||||
const enabledProviders = useMemo(
|
||||
() => enabledHealthProviders(service?.domains ?? []),
|
||||
[service],
|
||||
)
|
||||
const providerStatuses = useMemo(
|
||||
() => providerHealthStatuses(logItems, enabledProviders),
|
||||
[logItems, enabledProviders],
|
||||
)
|
||||
|
||||
return (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={overview.service.name}
|
||||
description={`Маршрутизация: ${overview.routing_strategy}. Активные IP: ${
|
||||
overview.active_addresses.join(', ') || '—'
|
||||
}`}
|
||||
actions={
|
||||
<HealthCheckBadge status={overview.service.health_status} />
|
||||
}
|
||||
/>
|
||||
<DetailPanel.Metrics
|
||||
cards={[
|
||||
{
|
||||
id: 'subdomains',
|
||||
icon: <GlobeIcon />,
|
||||
label: 'Поддомены',
|
||||
description:
|
||||
domains.length > 0
|
||||
? domains.map((d) => d.fqdn).join(', ')
|
||||
: 'Нет привязанных FQDN',
|
||||
footer: <Badge variant="outline">{domains.length}</Badge>,
|
||||
},
|
||||
{
|
||||
id: 'nodes',
|
||||
icon: <ServerIcon />,
|
||||
label: 'Ноды',
|
||||
description:
|
||||
nodes.length > 0
|
||||
? nodes.map((n) => n.address).join(', ')
|
||||
: 'Добавьте ноду, чтобы публиковать DNS',
|
||||
footer: <Badge variant="outline">{nodes.length}</Badge>,
|
||||
},
|
||||
{
|
||||
id: 'health',
|
||||
icon: <ActivityIcon />,
|
||||
label: 'Пул',
|
||||
description:
|
||||
overview.active_addresses.length > 0
|
||||
? 'Здоровые адреса участвуют в DNS'
|
||||
: 'unknown не попадает в пул, пока не станет healthy',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{domains.length === 0 && nodes.length === 0 ? (
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => {
|
||||
void viewQuery.refetch()
|
||||
void overviewQuery.refetch()
|
||||
}}
|
||||
>
|
||||
{!service ? (
|
||||
<EmptyState
|
||||
title="Пустой сервис"
|
||||
description="Добавьте поддомен и ноду, затем настройте health-check."
|
||||
stackedIcon
|
||||
title="Сервис не найден"
|
||||
description="Вернитесь в каталог и выберите сервис."
|
||||
/>
|
||||
) : null}
|
||||
</DetailPanel>
|
||||
) : (
|
||||
<div className="@container flex w-full flex-col gap-4 md:gap-6">
|
||||
<PageHeader
|
||||
title={service.name}
|
||||
description="Domain → Service → Node → Health → Failover"
|
||||
actions={
|
||||
<>
|
||||
<LbModeTile mode={service.lb_mode} />
|
||||
<HealthCheckBadge status={service.health_status} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Изменить"
|
||||
onClick={() => setEditOpen(true)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PencilIcon aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Изменить</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStatGrid
|
||||
cards={[
|
||||
{
|
||||
id: 'status',
|
||||
icon: <ActivityIcon />,
|
||||
label: 'Статус',
|
||||
value: service.health_status === 'up' ? 'OK' : service.health_status,
|
||||
variant:
|
||||
service.health_status === 'down'
|
||||
? 'destructive'
|
||||
: service.health_status === 'degraded'
|
||||
? 'warning'
|
||||
: 'default',
|
||||
iconClassName:
|
||||
service.health_status === 'down'
|
||||
? 'text-destructive'
|
||||
: service.health_status === 'degraded'
|
||||
? 'text-warning'
|
||||
: 'text-success',
|
||||
hint: <HealthCheckBadge status={service.health_status} size="xs" />,
|
||||
},
|
||||
{
|
||||
id: 'fqdn',
|
||||
icon: <GlobeIcon />,
|
||||
label: 'FQDN',
|
||||
value: String(service.domains.length),
|
||||
hint: service.domains[0]?.fqdn ?? 'Нет привязанных FQDN',
|
||||
},
|
||||
{
|
||||
id: 'ip',
|
||||
icon: <NetworkIcon />,
|
||||
label: 'IP',
|
||||
value: String(service.ips.length),
|
||||
hint: `${service.active_ips.length} в пуле`,
|
||||
},
|
||||
{
|
||||
id: 'pool',
|
||||
icon: <ServerIcon />,
|
||||
label: 'Активный пул',
|
||||
value: String((overview?.active_addresses ?? service.active_ips).length),
|
||||
hint: (overview?.active_addresses ?? service.active_ips).join(', ') || 'нет',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<section
|
||||
aria-label="Мониторинг"
|
||||
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||
>
|
||||
<ServiceHealthMonitor
|
||||
items={logItems}
|
||||
enabledProviders={enabledProviders}
|
||||
statuses={providerStatuses}
|
||||
isLoading={logQuery.isLoading}
|
||||
/>
|
||||
<Frame dense spacing="sm" className="min-w-0 w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Failover</FrameTitle>
|
||||
<FrameDescription>
|
||||
Нездоровые ноды и причины последней ошибки
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<FailoverTimeline events={failoverEvents} />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</section>
|
||||
|
||||
{service.ips.length === 0 && service.domains.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Пустой сервис"
|
||||
description="Добавьте поддомен и ноду, затем настройте health-check."
|
||||
stackedIcon
|
||||
/>
|
||||
) : (
|
||||
<ServiceDetailGrid
|
||||
service={service}
|
||||
nodes={nodes}
|
||||
togglingIp={togglingIp}
|
||||
onToggleIp={(ip, enabled) => {
|
||||
setTogglingIp(ip)
|
||||
toggleIpMutation.mutate({ ip, enabled })
|
||||
}}
|
||||
onChangeIp={(row: ServiceFqdnRow) =>
|
||||
setChangeIp({
|
||||
bindingId: row.binding_id,
|
||||
ip: row.target_ips[0],
|
||||
})
|
||||
}
|
||||
onChangeDomain={() => setChangeDomain(true)}
|
||||
onAddNode={() => setAddNodeOpen(true)}
|
||||
onDeleteNode={(nodeId) => deleteNodeMut.mutate(nodeId)}
|
||||
isLoading={nodesQuery.isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ServiceEditSheet
|
||||
mode="edit"
|
||||
service={service}
|
||||
groups={groups}
|
||||
open={editOpen}
|
||||
knownDomains={domainsQuery.data ?? []}
|
||||
isSaving={saving}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
onOpenChange={setEditOpen}
|
||||
onSave={(_serviceId, body) => {
|
||||
setSaving(true)
|
||||
updateMutation.mutate({ body })
|
||||
}}
|
||||
onDelete={() => deleteMutation.mutate()}
|
||||
/>
|
||||
<ChangeIpSheet
|
||||
open={changeIp != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setChangeIp(null)
|
||||
}}
|
||||
bindingId={changeIp?.bindingId ?? null}
|
||||
serviceId={id}
|
||||
currentIp={changeIp?.ip}
|
||||
/>
|
||||
<ChangeDomainSheet
|
||||
open={changeDomain}
|
||||
onOpenChange={setChangeDomain}
|
||||
serviceId={id}
|
||||
fromDomainId={service.domains[0]?.domain_id ?? null}
|
||||
/>
|
||||
<FormSheet
|
||||
open={addNodeOpen}
|
||||
onOpenChange={setAddNodeOpen}
|
||||
title="Добавить ноду"
|
||||
description="IP станет CHECKING до порога успешных проверок."
|
||||
form={nodeForm}
|
||||
onSubmit={(values) => createNodeMut.mutate(values)}
|
||||
footer={
|
||||
<LoadingButton type="submit" isLoading={createNodeMut.isPending}>
|
||||
Добавить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FormFieldSimple label="IP" htmlFor="address">
|
||||
<Input id="address" {...nodeForm.register('address')} placeholder="10.0.0.10" />
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
|
||||
<Input id="port" {...nodeForm.register('port')} placeholder="443" />
|
||||
</FormFieldSimple>
|
||||
</FormSheet>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,143 +1,11 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { PlusIcon } from 'lucide-react'
|
||||
import { DetailPanel } from '@/components/reui-kit'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { createServiceNode, deleteServiceNode, serviceNodesQueryOptions } from '@/queries'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/nodes')({
|
||||
component: ServiceNodesPage,
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/services/$serviceId',
|
||||
params,
|
||||
})
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
|
||||
interface NodeRow {
|
||||
id: number
|
||||
address: string
|
||||
port: number | null
|
||||
protocol: string
|
||||
health_status: 'up' | 'down' | 'degraded' | 'unknown' | 'healthy' | 'unhealthy' | 'checking' | 'disabled'
|
||||
weight: number
|
||||
priority: number
|
||||
}
|
||||
|
||||
function mapHealth(
|
||||
status: NodeRow['health_status'],
|
||||
): 'up' | 'down' | 'degraded' | 'unknown' {
|
||||
if (status === 'healthy' || status === 'up') return 'up'
|
||||
if (status === 'unhealthy' || status === 'down') return 'down'
|
||||
if (status === 'degraded') return 'degraded'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export function ServiceNodesPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const id = Number(serviceId)
|
||||
const queryClient = useQueryClient()
|
||||
const nodesQuery = useQuery(serviceNodesQueryOptions(id))
|
||||
const nodes = (nodesQuery.data ?? []) as NodeRow[]
|
||||
const [open, setOpen] = useState(false)
|
||||
const form = useForm<{ address: string; port: string }>({
|
||||
defaultValues: { address: '', port: '' },
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (values: { address: string; port: string }) =>
|
||||
createServiceNode(id, {
|
||||
address: values.address.trim(),
|
||||
port: values.port ? Number(values.port) : null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success('Нода добавлена, статус CHECKING')
|
||||
await queryClient.invalidateQueries({ queryKey: ['services'] })
|
||||
setOpen(false)
|
||||
form.reset()
|
||||
},
|
||||
onError: (e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось добавить ноду'),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (nodeId: number) => deleteServiceNode(id, nodeId),
|
||||
onSuccess: async () => {
|
||||
toast.success('Нода удалена')
|
||||
await queryClient.invalidateQueries({ queryKey: ['services'] })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title="Ноды"
|
||||
description="Адреса происхождения сервиса."
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
<PlusIcon className="size-4" aria-hidden />
|
||||
Добавить ноду
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{nodes.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Нет нод"
|
||||
description="Добавьте IP, затем настройте health-check."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{nodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">{node.address}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{node.protocol}
|
||||
{node.port ? `:${node.port}` : ''} · вес {node.weight} · приоритет{' '}
|
||||
{node.priority}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HealthCheckBadge status={mapHealth(node.health_status)} />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => deleteMut.mutate(node.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Добавить ноду"
|
||||
description="IP станет CHECKING до порога успешных проверок."
|
||||
form={form}
|
||||
onSubmit={(values) => createMut.mutate(values)}
|
||||
footer={
|
||||
<LoadingButton type="submit" isLoading={createMut.isPending}>
|
||||
Добавить
|
||||
</LoadingButton>
|
||||
}
|
||||
>
|
||||
<FormFieldSimple label="IP" htmlFor="address">
|
||||
<Input id="address" {...form.register('address')} placeholder="10.0.0.10" />
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Порт" htmlFor="port" hint="Необязательно">
|
||||
<Input id="port" {...form.register('port')} placeholder="443" />
|
||||
</FormFieldSimple>
|
||||
</FormSheet>
|
||||
</DetailPanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,79 +1,30 @@
|
||||
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ArrowLeftIcon } from 'lucide-react'
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { serviceOverviewQueryOptions } from '@/queries'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import {
|
||||
serviceHealthLogQueryOptions,
|
||||
serviceNodesQueryOptions,
|
||||
serviceOverviewQueryOptions,
|
||||
serviceViewQueryOptions,
|
||||
} from '@/queries'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId')({
|
||||
loader: ({ context: { queryClient }, params }) =>
|
||||
queryClient.ensureQueryData(serviceOverviewQueryOptions(Number(params.serviceId))),
|
||||
loader: async ({ context: { queryClient }, params }) => {
|
||||
const id = Number(params.serviceId)
|
||||
const [view] = await Promise.all([
|
||||
queryClient.ensureQueryData(serviceViewQueryOptions(id)),
|
||||
queryClient.ensureQueryData(serviceOverviewQueryOptions(id)),
|
||||
queryClient.ensureQueryData(serviceHealthLogQueryOptions(id)),
|
||||
queryClient.ensureQueryData(serviceNodesQueryOptions(id)),
|
||||
])
|
||||
return { breadcrumb: view.name }
|
||||
},
|
||||
component: ServiceLayout,
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
{ to: '/services/$serviceId', label: 'Обзор', exact: true },
|
||||
{ to: '/services/$serviceId/subdomains', label: 'Поддомены', exact: false },
|
||||
{ to: '/services/$serviceId/nodes', label: 'Ноды', exact: false },
|
||||
{ to: '/services/$serviceId/health', label: 'Health', exact: false },
|
||||
{ to: '/services/$serviceId/routing', label: 'Маршрутизация', exact: false },
|
||||
] as const
|
||||
|
||||
function ServiceLayout() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const id = Number(serviceId)
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const overview = useQuery(serviceOverviewQueryOptions(id))
|
||||
const name = (overview.data as { service?: { name?: string } } | undefined)?.service?.name
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title={name ?? 'Сервис'}
|
||||
description="Domain → Service → Node → Health → Failover"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={<Link to="/services" />}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" aria-hidden />
|
||||
К каталогу
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<nav className="flex flex-wrap gap-4 border-b">
|
||||
{tabs.map((tab) => {
|
||||
const href = tab.to.replace('$serviceId', serviceId)
|
||||
const active = tab.exact
|
||||
? pathname === `/services/${serviceId}` || pathname === `/services/${serviceId}/`
|
||||
: pathname.startsWith(href)
|
||||
return (
|
||||
<Link
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
params={{ serviceId }}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground pb-3 text-sm font-medium',
|
||||
active && 'text-foreground border-b-2 border-primary',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<QueryState
|
||||
isLoading={overview.isLoading}
|
||||
isError={overview.isError}
|
||||
error={overview.error}
|
||||
onRetry={() => void overview.refetch()}
|
||||
>
|
||||
<Outlet />
|
||||
</QueryState>
|
||||
<Outlet />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,59 +1,11 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { DetailPanel } from '@/components/reui-kit'
|
||||
import { FailoverTimeline } from '@/components/failover-timeline'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { serviceOverviewQueryOptions } from '@/queries'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/routing')({
|
||||
component: ServiceRoutingPage,
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/services/$serviceId',
|
||||
params,
|
||||
})
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
|
||||
export function ServiceRoutingPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const { data } = useQuery(serviceOverviewQueryOptions(Number(serviceId)))
|
||||
const overview = data as {
|
||||
routing_strategy: string
|
||||
active_addresses: string[]
|
||||
nodes: Array<{
|
||||
address: string
|
||||
health_status: string
|
||||
consecutive_failures: number
|
||||
last_failure_reason: string | null
|
||||
}>
|
||||
} | undefined
|
||||
|
||||
const events =
|
||||
overview?.nodes
|
||||
.filter(
|
||||
(node) =>
|
||||
node.health_status === 'unhealthy' ||
|
||||
node.health_status === 'down' ||
|
||||
node.health_status === 'checking',
|
||||
)
|
||||
.map((node) => ({
|
||||
id: node.address,
|
||||
title: `${node.address}: ${node.health_status}`,
|
||||
detail: node.last_failure_reason
|
||||
? `${node.last_failure_reason} · fail ${node.consecutive_failures}`
|
||||
: `fail ${node.consecutive_failures}`,
|
||||
})) ?? []
|
||||
|
||||
return (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title="Маршрутизация"
|
||||
description="Round Robin / Failover. Weighted на DNS = alias Round Robin."
|
||||
actions={<Badge variant="outline">{overview?.routing_strategy ?? 'round_robin'}</Badge>}
|
||||
/>
|
||||
<p className="text-sm">
|
||||
Активные адреса:{' '}
|
||||
{overview?.active_addresses.join(', ') || 'нет (unknown не в пуле)'}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Запись обновляется в Cloudflare. Распространение зависит от TTL.
|
||||
</p>
|
||||
<FailoverTimeline events={events} />
|
||||
</DetailPanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,114 +1,11 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ArrowRightLeftIcon } from 'lucide-react'
|
||||
import { DetailPanel } from '@/components/reui-kit'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { ChangeIpSheet } from '@/components/change-ip-sheet'
|
||||
import { ChangeDomainSheet } from '@/components/change-domain-sheet'
|
||||
import { serviceOverviewQueryOptions } from '@/queries'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services/$serviceId/subdomains')({
|
||||
component: ServiceSubdomainsPage,
|
||||
beforeLoad: ({ params }) => {
|
||||
throw redirect({
|
||||
to: '/services/$serviceId',
|
||||
params,
|
||||
})
|
||||
},
|
||||
component: () => null,
|
||||
})
|
||||
|
||||
export function ServiceSubdomainsPage() {
|
||||
const { serviceId } = Route.useParams()
|
||||
const id = Number(serviceId)
|
||||
const { data } = useQuery(serviceOverviewQueryOptions(id))
|
||||
const overview = data as {
|
||||
service: {
|
||||
domains: Array<{
|
||||
binding_id: number
|
||||
domain_id: number
|
||||
fqdn: string
|
||||
zone_name: string
|
||||
target_ips: string[]
|
||||
}>
|
||||
}
|
||||
} | undefined
|
||||
const rows = overview?.service.domains ?? []
|
||||
const [changeIp, setChangeIp] = useState<{
|
||||
bindingId: number
|
||||
ip?: string
|
||||
} | null>(null)
|
||||
const [changeDomain, setChangeDomain] = useState(false)
|
||||
const fromDomainId = useMemo(
|
||||
() => rows[0]?.domain_id ?? null,
|
||||
[rows],
|
||||
)
|
||||
|
||||
return (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title="Поддомены"
|
||||
description="FQDN сервиса в одной или нескольких зонах Cloudflare."
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setChangeDomain(true)}
|
||||
disabled={rows.length === 0}
|
||||
>
|
||||
<ArrowRightLeftIcon className="size-4" aria-hidden />
|
||||
Сменить домен
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Нет поддоменов"
|
||||
description="Привяжите FQDN к сервису из карточки редактирования."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.binding_id}
|
||||
className="flex items-center justify-between gap-3 border-b py-3 last:border-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="font-medium">{row.fqdn}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.zone_name} · {row.target_ips.join(', ') || 'нет IP'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{row.target_ips.length} IP</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setChangeIp({
|
||||
bindingId: row.binding_id,
|
||||
ip: row.target_ips[0],
|
||||
})
|
||||
}
|
||||
>
|
||||
Сменить IP
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChangeIpSheet
|
||||
open={changeIp != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setChangeIp(null)
|
||||
}}
|
||||
bindingId={changeIp?.bindingId ?? null}
|
||||
serviceId={id}
|
||||
currentIp={changeIp?.ip}
|
||||
/>
|
||||
<ChangeDomainSheet
|
||||
open={changeDomain}
|
||||
onOpenChange={setChangeDomain}
|
||||
serviceId={id}
|
||||
fromDomainId={fromDomainId}
|
||||
/>
|
||||
</DetailPanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import { HeartPulseIcon } from 'lucide-react'
|
||||
import { HeartPulseIcon, GlobeIcon } from 'lucide-react'
|
||||
|
||||
import { api } from '@/lib/api-client'
|
||||
import { SettingRow } from '@/components/setting-row'
|
||||
@@ -47,7 +47,14 @@ const formSchema = z.object({
|
||||
}
|
||||
})
|
||||
|
||||
const globalpingSchema = z.object({
|
||||
globalpingToken: z.string().optional(),
|
||||
globalpingLocations: z.string().trim().min(1).max(200),
|
||||
globalpingLimit: z.number().int().min(1).max(10),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
type GlobalpingValues = z.infer<typeof globalpingSchema>
|
||||
type HealthWorkerStatus = 'missing' | 'ready' | 'error'
|
||||
|
||||
type SettingsResponse = FormValues & {
|
||||
@@ -58,6 +65,9 @@ type SettingsResponse = FormValues & {
|
||||
healthWorkerDeployedAt?: string | null
|
||||
healthWorkerLastIngestAt?: string | null
|
||||
healthWorkerKvNamespaceId?: string
|
||||
globalpingTokenSet?: boolean
|
||||
globalpingLocations?: string
|
||||
globalpingLimit?: number
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings/health')({
|
||||
@@ -140,6 +150,15 @@ function HealthSettingsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const gpForm = useForm<GlobalpingValues>({
|
||||
resolver: zodResolver(globalpingSchema),
|
||||
defaultValues: {
|
||||
globalpingToken: '',
|
||||
globalpingLocations: 'World',
|
||||
globalpingLimit: 3,
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
form.reset({
|
||||
@@ -149,7 +168,12 @@ function HealthSettingsPage() {
|
||||
healthLatencyWarnMs: data.healthLatencyWarnMs,
|
||||
healthSuccessRecoveries: data.healthSuccessRecoveries,
|
||||
})
|
||||
}, [data, form])
|
||||
gpForm.reset({
|
||||
globalpingToken: '',
|
||||
globalpingLocations: data.globalpingLocations || 'World',
|
||||
globalpingLimit: data.globalpingLimit ?? 3,
|
||||
})
|
||||
}, [data, form, gpForm])
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (values: FormValues) =>
|
||||
@@ -168,6 +192,27 @@ function HealthSettingsPage() {
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||
})
|
||||
|
||||
const saveGpMut = useMutation({
|
||||
mutationFn: (values: GlobalpingValues) =>
|
||||
api.patch<SettingsResponse>('/api/v1/settings', {
|
||||
globalpingLocations: values.globalpingLocations,
|
||||
globalpingLimit: values.globalpingLimit,
|
||||
...(values.globalpingToken?.trim()
|
||||
? { globalpingToken: values.globalpingToken.trim() }
|
||||
: {}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||
toast.success('Настройки Globalping сохранены')
|
||||
gpForm.reset({
|
||||
...gpForm.getValues(),
|
||||
globalpingToken: '',
|
||||
})
|
||||
},
|
||||
onError: (e: unknown) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||
})
|
||||
|
||||
const ensureMut = useMutation({
|
||||
mutationFn: () =>
|
||||
api.post<SettingsResponse>('/api/v1/settings/health/worker/ensure'),
|
||||
@@ -180,6 +225,7 @@ function HealthSettingsPage() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<form
|
||||
className="flex w-full flex-col gap-4"
|
||||
onSubmit={(event) =>
|
||||
@@ -193,8 +239,8 @@ function HealthSettingsPage() {
|
||||
Local health-check
|
||||
</FrameTitle>
|
||||
<FrameDescription>
|
||||
Расписание и пороги движка — общие для Local и Cloudflare Worker.
|
||||
Тип/порт/path задаются в карточке сервиса.
|
||||
Расписание и пороги движка — общие для Local, Cloudflare Worker и Globalping.
|
||||
Тип/порт/path и правило агрегации задаются в карточке сервиса.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
@@ -416,5 +462,127 @@ function HealthSettingsPage() {
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</form>
|
||||
|
||||
<form
|
||||
className="flex w-full flex-col gap-4"
|
||||
onSubmit={(event) =>
|
||||
void gpForm.handleSubmit((values) => saveGpMut.mutate(values))(event)
|
||||
}
|
||||
>
|
||||
<Frame dense spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle className="flex items-center gap-2">
|
||||
<GlobeIcon className="size-4" aria-hidden />
|
||||
Globalping
|
||||
<Badge
|
||||
variant={data?.globalpingTokenSet ? 'success-light' : 'outline'}
|
||||
size="sm"
|
||||
>
|
||||
{data?.globalpingTokenSet ? 'Токен задан' : 'Нет токена'}
|
||||
</Badge>
|
||||
</FrameTitle>
|
||||
<FrameDescription>
|
||||
Пробы из сети globalping.io. Poll ≥ 500 мс.{' '}
|
||||
<a
|
||||
href="https://reui.io/preview/base/settings-16"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
settings-16
|
||||
</a>
|
||||
.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<Alert>
|
||||
<AlertTitle>Лимиты и credits</AlertTitle>
|
||||
<AlertDescription>
|
||||
Без токена — 250 tests/hour, с токеном — 500 +{' '}
|
||||
<a
|
||||
href="https://globalping.io/credits"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
credits
|
||||
</a>
|
||||
. Токен: dash.globalping.io/tokens. Один measurement на
|
||||
уникальный IP/порт за тик cron. При десятках IP следите за
|
||||
hourly credits.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
<FieldGroup className="gap-0">
|
||||
<SettingRow
|
||||
title="Токен"
|
||||
description={
|
||||
data?.globalpingTokenSet
|
||||
? 'Оставьте пустым, чтобы не менять сохранённый токен.'
|
||||
: 'Authorization: Bearer. Без токена источник Globalping = fail.'
|
||||
}
|
||||
labelFor="gp-token"
|
||||
stacked
|
||||
>
|
||||
<Input
|
||||
id="gp-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={data?.globalpingTokenSet ? '••••••••' : 'gp_…'}
|
||||
disabled={isLoading || saveGpMut.isPending}
|
||||
{...gpForm.register('globalpingToken')}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Локации"
|
||||
description="Magic CSV, например World или EU,US. Default World."
|
||||
labelFor="gp-locations"
|
||||
stacked
|
||||
>
|
||||
<Input
|
||||
id="gp-locations"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
disabled={isLoading || saveGpMut.isPending}
|
||||
{...gpForm.register('globalpingLocations')}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Проб в measurement"
|
||||
description="limit 1–10. Default 3."
|
||||
labelFor="gp-limit"
|
||||
compact
|
||||
last
|
||||
>
|
||||
<Controller
|
||||
control={gpForm.control}
|
||||
name="globalpingLimit"
|
||||
render={({ field }) => (
|
||||
<CompactNumberInput
|
||||
id="gp-limit"
|
||||
value={field.value}
|
||||
min={1}
|
||||
max={10}
|
||||
disabled={isLoading || saveGpMut.isPending}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</FieldGroup>
|
||||
<FrameFooter className="flex flex-row justify-end">
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
isLoading={saveGpMut.isPending}
|
||||
disabled={isLoading || !gpForm.formState.isDirty}
|
||||
>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+25
-11
@@ -49,18 +49,26 @@ health-check работают на двух уровнях:
|
||||
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в
|
||||
**Настройки → Health-check** (env — fallback, пока значения не сохранены в UI).
|
||||
|
||||
### Local XOR Cloudflare Worker
|
||||
### Источники проб: Local, Cloudflare Worker, Globalping
|
||||
|
||||
Провайдер задаётся на привязке (`service_bindings.health_check_provider`): **local**
|
||||
или **cloudflare**. Одновременно оба не работают.
|
||||
На привязке/группе задаётся **мультивыбор** источников (`health_check_providers` JSON)
|
||||
и **правило агрегации** (`health_check_aggregate`: `any` | `all` | `majority`).
|
||||
Failover читает одну строку `ip_health_status` (агрегат). Журнал `health_probe_log` —
|
||||
строка на каждый источник.
|
||||
|
||||
| | Local | Cloudflare Worker |
|
||||
|---|---|---|
|
||||
| Кто пробирует | процесс API CFDM | Worker на edge (Cron Trigger) |
|
||||
| Планировщик | глобальный cron CFDM | cron Worker + ingest KV в CFDM |
|
||||
| Пороги Slow/Down | Настройки → Health-check | те же |
|
||||
| Результат | SQLite `ip_health_status` | та же SQLite + `colo` из KV |
|
||||
| Регионы Health Checks | нет | нет (на Free продукта нет) |
|
||||
| | Local | Cloudflare Worker | Globalping |
|
||||
|---|---|---|---|
|
||||
| Кто пробирует | процесс API CFDM | Worker на edge (Cron Trigger) | [globalping.io](https://globalping.io) |
|
||||
| Планировщик | глобальный cron CFDM | cron Worker + ingest KV | тот же cron CFDM (POST/GET measurements) |
|
||||
| Пороги Slow/Down | Настройки → Health-check | те же | те же (по агрегату) |
|
||||
| Результат | SQLite `ip_health_status` | та же SQLite + `colo` из KV | та же SQLite, colo = city/country пробы |
|
||||
| Fallback | — | нет (не Local) | нет (нет токена / 429 / timeout = fail) |
|
||||
|
||||
**Агрегация (на сервисе/группе):**
|
||||
|
||||
- `any` — Down, если хотя бы один выбранный источник Down
|
||||
- `all` — Down, только если все выбранные Down
|
||||
- `majority` — Down по большинству (2 источника → оба; 3 → ≥2)
|
||||
|
||||
**Cloudflare в CFDM — это Worker**, не [Health Checks API](https://developers.cloudflare.com/api/resources/healthchecks).
|
||||
Продукт Health Checks на Free-плане недоступен и **не используется**.
|
||||
@@ -75,7 +83,13 @@ Account `Workers Scripts Write` + `Workers KV Storage Write`. Zone DNS недо
|
||||
Free: 5 Cron Triggers на аккаунт; KV 1000 writes/сутки (интервал ≥ 2 мин);
|
||||
≤ 48 целей за тик. Исходник: [`workers/health-probe/`](../workers/health-probe/).
|
||||
|
||||
Reconcile DNS запускается cron-задачей `health-check` после ingest KV.
|
||||
**Globalping:** `POST /v1/measurements` → poll `GET` каждые ≥ 500 мс.
|
||||
CFDM TCP → `type: ping` + `protocol: TCP`; HTTP → `type: http`, `target` = IP, `request.host` = hostname.
|
||||
Токен: [dash.globalping.io/tokens](https://dash.globalping.io/tokens). Без токена 250 tests/hour, с токеном 500 + [credits](https://globalping.io/credits).
|
||||
Локации (magic CSV, default `World`) и `limit` (1–10, default 3) — **Настройки → Health-check**.
|
||||
Один measurement на уникальный origin (IP/порт/path) за тик.
|
||||
|
||||
Reconcile DNS запускается cron-задачей `health-check` после ingest KV и агрегации.
|
||||
|
||||
## Docker
|
||||
|
||||
|
||||
Vendored
+352
-4
@@ -1,7 +1,7 @@
|
||||
import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { AuditSourceApp, AuditSeverity, AuditTargetType, AuditLogEntry, HealthWorkerStatus, LbMode, HealthCheckType, HealthCheckProvider, IpHealthState, HealthCheckScope, ServiceBinding, Domain, Group, OriginHealthCheck, ServiceNode, Service, ServiceGroup, Subdomain, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared';
|
||||
import { AuditSourceApp, AuditSeverity, AuditTargetType, AuditLogEntry, HealthWorkerStatus, LbMode, HealthCheckType, HealthCheckProvider, HealthCheckAggregate, IpHealthState, HealthStatusProvider, HealthCheckScope, ServiceBinding, Domain, Group, OriginHealthCheck, ServiceNode, Service, ServiceGroup, Subdomain, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared';
|
||||
|
||||
declare const groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
||||
name: "groups";
|
||||
@@ -599,6 +599,44 @@ declare const serviceGroups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_providers: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_providers";
|
||||
tableName: "service_groups";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_aggregate: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_aggregate";
|
||||
tableName: "service_groups";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "created_at";
|
||||
tableName: "service_groups";
|
||||
@@ -1537,6 +1575,63 @@ declare const serviceBindings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_providers: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_providers";
|
||||
tableName: "service_bindings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_aggregate: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_aggregate";
|
||||
tableName: "service_bindings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
cert_monitoring: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "cert_monitoring";
|
||||
tableName: "service_bindings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
routing_strategy: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "routing_strategy";
|
||||
tableName: "service_bindings";
|
||||
@@ -2606,6 +2701,23 @@ declare const certificates: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {}>;
|
||||
service_id: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "service_id";
|
||||
tableName: "certificates";
|
||||
dataType: "number";
|
||||
columnType: "SQLiteInteger";
|
||||
data: number;
|
||||
driverParam: number;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: undefined;
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {}>;
|
||||
hostname: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "hostname";
|
||||
tableName: "certificates";
|
||||
@@ -3460,6 +3572,61 @@ declare const appSettings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
globalping_token: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "globalping_token";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
globalping_locations: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "globalping_locations";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
globalping_limit: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "globalping_limit";
|
||||
tableName: "app_settings";
|
||||
dataType: "number";
|
||||
columnType: "SQLiteInteger";
|
||||
data: number;
|
||||
driverParam: number;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: undefined;
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {}>;
|
||||
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "created_at";
|
||||
tableName: "app_settings";
|
||||
@@ -5151,6 +5318,44 @@ declare const schema: {
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_providers: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_providers";
|
||||
tableName: "service_groups";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_aggregate: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_aggregate";
|
||||
tableName: "service_groups";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "created_at";
|
||||
tableName: "service_groups";
|
||||
@@ -6089,6 +6294,63 @@ declare const schema: {
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_providers: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_providers";
|
||||
tableName: "service_bindings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
health_check_aggregate: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "health_check_aggregate";
|
||||
tableName: "service_bindings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
cert_monitoring: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "cert_monitoring";
|
||||
tableName: "service_bindings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: true;
|
||||
hasDefault: true;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
routing_strategy: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "routing_strategy";
|
||||
tableName: "service_bindings";
|
||||
@@ -7158,6 +7420,23 @@ declare const schema: {
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {}>;
|
||||
service_id: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "service_id";
|
||||
tableName: "certificates";
|
||||
dataType: "number";
|
||||
columnType: "SQLiteInteger";
|
||||
data: number;
|
||||
driverParam: number;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: undefined;
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {}>;
|
||||
hostname: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "hostname";
|
||||
tableName: "certificates";
|
||||
@@ -8012,6 +8291,61 @@ declare const schema: {
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
globalping_token: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "globalping_token";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
globalping_locations: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "globalping_locations";
|
||||
tableName: "app_settings";
|
||||
dataType: "string";
|
||||
columnType: "SQLiteText";
|
||||
data: string;
|
||||
driverParam: string;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: [string, ...string[]];
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {
|
||||
length: number | undefined;
|
||||
}>;
|
||||
globalping_limit: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "globalping_limit";
|
||||
tableName: "app_settings";
|
||||
dataType: "number";
|
||||
columnType: "SQLiteInteger";
|
||||
data: number;
|
||||
driverParam: number;
|
||||
notNull: false;
|
||||
hasDefault: false;
|
||||
isPrimaryKey: false;
|
||||
isAutoincrement: false;
|
||||
hasRuntimeDefault: false;
|
||||
enumValues: undefined;
|
||||
baseColumn: never;
|
||||
identity: undefined;
|
||||
generated: undefined;
|
||||
}, {}, {}>;
|
||||
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
|
||||
name: "created_at";
|
||||
tableName: "app_settings";
|
||||
@@ -9176,6 +9510,9 @@ type AppSettingsDto = {
|
||||
healthWorkerError: string | null;
|
||||
healthWorkerDeployedAt: string | null;
|
||||
healthWorkerLastIngestAt: string | null;
|
||||
globalpingTokenSet: boolean;
|
||||
globalpingLocations: string;
|
||||
globalpingLimit: number;
|
||||
} & HealthEngineSettings;
|
||||
type AppSettingsPatch = {
|
||||
vpsTrackerUrl?: string;
|
||||
@@ -9194,6 +9531,9 @@ type AppSettingsPatch = {
|
||||
healthWorkerError?: string | null;
|
||||
healthWorkerDeployedAt?: string | null;
|
||||
healthWorkerLastIngestAt?: string | null;
|
||||
globalpingToken?: string;
|
||||
globalpingLocations?: string;
|
||||
globalpingLimit?: number;
|
||||
};
|
||||
type HealthEngineFallbacks = HealthEngineSettings & {
|
||||
healthWorkerUrl: string;
|
||||
@@ -9206,6 +9546,9 @@ declare function getAppSettingsSecrets(db: Db): {
|
||||
vpsTrackerSyncEnabled: boolean;
|
||||
healthWorkerUrl: string;
|
||||
healthWorkerToken: string;
|
||||
globalpingToken: string;
|
||||
globalpingLocations: string;
|
||||
globalpingLimit: number;
|
||||
};
|
||||
declare function updateAppSettings(db: Db, patch: AppSettingsPatch, fallbacks?: HealthEngineFallbacks): AppSettingsDto;
|
||||
declare function touchVpsTrackerSync(db: Db): void;
|
||||
@@ -9294,6 +9637,8 @@ interface ServiceGroupLbPatch {
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
declare function createServiceGroup(db: Db, name: string, groupType: string, icon: string | null, domain: string | null, lbPatch?: ServiceGroupLbPatch): ServiceGroup;
|
||||
declare function updateServiceGroup(db: Db, id: number, name: string, groupType: string, icon: string | null, domain: string | null, lbPatch?: ServiceGroupLbPatch): ServiceGroup;
|
||||
@@ -9402,6 +9747,9 @@ interface BindingLbPatch {
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
cert_monitoring?: string;
|
||||
}
|
||||
declare function updateBindingLbConfig(db: Db, bindingId: number, patch: BindingLbPatch): void;
|
||||
declare function setBindingCnameTarget(db: Db, bindingId: number, target: string | null): void;
|
||||
@@ -9427,7 +9775,7 @@ declare function deleteBindingsExcept(db: Db, serviceId: number, keepIds: number
|
||||
declare function deleteBinding(db: Db, id: number): void;
|
||||
declare function listCertificates(db: Db, status?: string): Certificate[];
|
||||
declare function getCertificate(db: Db, id: number): Certificate;
|
||||
declare function upsertCertificateCheck(db: Db, domainId: number, subdomainId: number | null, hostname: string, expiresAt: string | null, status: string, lastError: string | null): Certificate;
|
||||
declare function upsertCertificateCheck(db: Db, domainId: number, subdomainId: number | null, hostname: string, expiresAt: string | null, status: string, lastError: string | null, serviceId?: number | null): Certificate;
|
||||
declare function countCertificatesByStatus(db: Db): Array<[string, number]>;
|
||||
declare function deleteCertificatesNotIn(db: Db, hostnames: string[]): number;
|
||||
declare function createSyncJob(db: Db, id: string, domainId: number | null): void;
|
||||
@@ -9453,7 +9801,7 @@ type ServiceIpHealthRow = {
|
||||
latency_ms: number | null;
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
provider: HealthCheckProvider;
|
||||
provider: HealthStatusProvider;
|
||||
colo: string | null;
|
||||
};
|
||||
/** Per-IP binding-scope health, worst status if the same IP is on several bindings. */
|
||||
@@ -9462,7 +9810,7 @@ declare function mergeHealthAggregates(parts: Array<HealthAggregate | undefined
|
||||
declare function getIpHealthStatusRow(db: Db, scope: HealthCheckScope, refId: number, ip: string): IpHealthStatus | null;
|
||||
declare function upsertIpHealthStatus(db: Db, scope: HealthCheckScope, refId: number, ip: string, status: string, latencyMs: number | null, consecutiveFailures: number, lastError: string | null, consecutiveSuccesses?: number, extras?: {
|
||||
colo?: string | null;
|
||||
provider?: HealthCheckProvider;
|
||||
provider?: HealthStatusProvider;
|
||||
}): void;
|
||||
declare function deleteIpHealthStatusForRef(db: Db, scope: HealthCheckScope, refId: number): void;
|
||||
declare function deleteIpHealthStatusForIp(db: Db, scope: HealthCheckScope, refId: number, ip: string): void;
|
||||
|
||||
Vendored
+181
-30
@@ -53,6 +53,8 @@ var serviceGroups = sqliteTable("service_groups", {
|
||||
health_check_timeout_ms: integer("health_check_timeout_ms").notNull().default(3e3),
|
||||
health_check_verify_tls: integer("health_check_verify_tls", { mode: "boolean" }).notNull().default(false),
|
||||
health_check_provider: text("health_check_provider").notNull().default("local"),
|
||||
health_check_providers: text("health_check_providers").notNull().default('["local"]'),
|
||||
health_check_aggregate: text("health_check_aggregate").notNull().default("majority"),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||
});
|
||||
@@ -119,6 +121,9 @@ var serviceBindings = sqliteTable(
|
||||
mode: "boolean"
|
||||
}).notNull().default(false),
|
||||
health_check_provider: text("health_check_provider").notNull().default("local"),
|
||||
health_check_providers: text("health_check_providers").notNull().default('["local"]'),
|
||||
health_check_aggregate: text("health_check_aggregate").notNull().default("majority"),
|
||||
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
|
||||
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
|
||||
operation_version: integer("operation_version").notNull().default(0),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||
@@ -224,6 +229,9 @@ var certificates = sqliteTable("certificates", {
|
||||
subdomain_id: integer("subdomain_id").references(() => subdomains.id, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
service_id: integer("service_id").references(() => services.id, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
hostname: text("hostname").notNull().unique(),
|
||||
expires_at: text("expires_at"),
|
||||
last_checked_at: text("last_checked_at"),
|
||||
@@ -286,6 +294,9 @@ var appSettings = sqliteTable("app_settings", {
|
||||
health_worker_error: text("health_worker_error"),
|
||||
health_worker_deployed_at: text("health_worker_deployed_at"),
|
||||
health_worker_last_ingest_at: text("health_worker_last_ingest_at"),
|
||||
globalping_token: text("globalping_token"),
|
||||
globalping_locations: text("globalping_locations"),
|
||||
globalping_limit: integer("globalping_limit"),
|
||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||
});
|
||||
@@ -577,7 +588,10 @@ function toDto(row, fallbacks) {
|
||||
healthWorkerError: row.health_worker_error?.trim() || null,
|
||||
healthWorkerDeployedAt: row.health_worker_deployed_at ?? null,
|
||||
healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null,
|
||||
healthWorkerStatus: workerStatus(row, env.healthWorkerUrl)
|
||||
healthWorkerStatus: workerStatus(row, env.healthWorkerUrl),
|
||||
globalpingTokenSet: Boolean(row.globalping_token?.trim()),
|
||||
globalpingLocations: row.globalping_locations?.trim() || "World",
|
||||
globalpingLimit: row.globalping_limit == null || Number.isNaN(row.globalping_limit) || row.globalping_limit < 1 ? 3 : Math.min(10, row.globalping_limit)
|
||||
};
|
||||
}
|
||||
function getAppSettings(db, fallbacks) {
|
||||
@@ -593,12 +607,16 @@ function getAppSettings(db, fallbacks) {
|
||||
}
|
||||
function getAppSettingsSecrets(db) {
|
||||
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
||||
const limit = row?.globalping_limit;
|
||||
return {
|
||||
vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "",
|
||||
vpsTrackerIntegrationToken: row?.vps_tracker_integration_token?.trim() ?? "",
|
||||
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled),
|
||||
healthWorkerUrl: row?.health_worker_url?.trim() ?? "",
|
||||
healthWorkerToken: row?.health_worker_token?.trim() ?? ""
|
||||
healthWorkerToken: row?.health_worker_token?.trim() ?? "",
|
||||
globalpingToken: row?.globalping_token?.trim() ?? "",
|
||||
globalpingLocations: row?.globalping_locations?.trim() || "World",
|
||||
globalpingLimit: limit == null || Number.isNaN(limit) || limit < 1 ? 3 : Math.min(10, limit)
|
||||
};
|
||||
}
|
||||
function updateAppSettings(db, patch, fallbacks) {
|
||||
@@ -624,6 +642,9 @@ function updateAppSettings(db, patch, fallbacks) {
|
||||
health_worker_error: patch.healthWorkerError !== void 0 ? patch.healthWorkerError?.trim() || null : current.health_worker_error,
|
||||
health_worker_deployed_at: patch.healthWorkerDeployedAt !== void 0 ? patch.healthWorkerDeployedAt : current.health_worker_deployed_at,
|
||||
health_worker_last_ingest_at: patch.healthWorkerLastIngestAt !== void 0 ? patch.healthWorkerLastIngestAt : current.health_worker_last_ingest_at,
|
||||
globalping_token: patch.globalpingToken !== void 0 && patch.globalpingToken.trim() !== "" ? patch.globalpingToken : current.globalping_token,
|
||||
globalping_locations: patch.globalpingLocations !== void 0 ? patch.globalpingLocations.trim() || "World" : current.globalping_locations,
|
||||
globalping_limit: patch.globalpingLimit !== void 0 ? Math.min(10, Math.max(1, patch.globalpingLimit)) : current.globalping_limit,
|
||||
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
||||
}).where(eq2(appSettings.id, SETTINGS_ID)).run();
|
||||
return getAppSettings(db, fallbacks);
|
||||
@@ -771,7 +792,15 @@ __export(repos_exports, {
|
||||
upsertIpHealthStatus: () => upsertIpHealthStatus,
|
||||
upsertSubdomain: () => upsertSubdomain
|
||||
});
|
||||
import { dnsRecordNamesMatch, isIpLiteral } from "@cfdm/shared";
|
||||
import {
|
||||
derivePrimaryProvider,
|
||||
dnsRecordNamesMatch,
|
||||
isIpLiteral,
|
||||
parseHealthAggregate,
|
||||
parseHealthProviders,
|
||||
serializeHealthProviders,
|
||||
normalizeStatusProvider
|
||||
} from "@cfdm/shared";
|
||||
import { and as and2, asc, count, eq as eq3, isNull, like, notInArray, or as or2, sql as sql2 } from "drizzle-orm";
|
||||
function listGroups(db) {
|
||||
return db.select().from(groups).orderBy(asc(groups.name)).all();
|
||||
@@ -1158,8 +1187,59 @@ function deleteService(db, id) {
|
||||
const result = db.delete(services).where(eq3(services.id, id)).run();
|
||||
if (result.changes === 0) throw new NotFoundError(`service ${id}`);
|
||||
}
|
||||
function normalizeHealthProvider(value) {
|
||||
return value === "cloudflare" ? "cloudflare" : "local";
|
||||
function healthProviderColumns(patch) {
|
||||
const out = {};
|
||||
if (patch.health_check_providers !== void 0) {
|
||||
const list = parseHealthProviders(patch.health_check_providers);
|
||||
out.health_check_providers = serializeHealthProviders(list);
|
||||
out.health_check_provider = derivePrimaryProvider(list);
|
||||
} else if (patch.health_check_provider !== void 0) {
|
||||
const list = parseHealthProviders(null, patch.health_check_provider);
|
||||
out.health_check_providers = serializeHealthProviders(list);
|
||||
out.health_check_provider = derivePrimaryProvider(list);
|
||||
}
|
||||
if (patch.health_check_aggregate !== void 0) {
|
||||
out.health_check_aggregate = parseHealthAggregate(
|
||||
patch.health_check_aggregate
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function mapHealthFields(row) {
|
||||
const providers = parseHealthProviders(
|
||||
row.health_check_providers,
|
||||
row.health_check_provider
|
||||
);
|
||||
return {
|
||||
health_check_providers: providers,
|
||||
health_check_provider: derivePrimaryProvider(providers),
|
||||
health_check_aggregate: parseHealthAggregate(row.health_check_aggregate)
|
||||
};
|
||||
}
|
||||
function mapServiceBinding(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
domain_id: row.domain_id,
|
||||
service_id: row.service_id,
|
||||
hostname: row.hostname,
|
||||
cname_target: row.cname_target,
|
||||
dns_record_id: row.dns_record_id,
|
||||
lb_mode: row.lb_mode,
|
||||
health_check_enabled: Boolean(row.health_check_enabled),
|
||||
health_check_type: row.health_check_type,
|
||||
health_check_port: row.health_check_port,
|
||||
health_check_path: row.health_check_path,
|
||||
health_check_expected_status: row.health_check_expected_status,
|
||||
health_check_interval_sec: row.health_check_interval_sec,
|
||||
health_check_timeout_ms: row.health_check_timeout_ms,
|
||||
health_check_verify_tls: Boolean(row.health_check_verify_tls),
|
||||
...mapHealthFields(row),
|
||||
cert_monitoring: row.cert_monitoring ?? "auto",
|
||||
routing_strategy: row.routing_strategy,
|
||||
operation_version: row.operation_version,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
function mapServiceGroup(row) {
|
||||
return {
|
||||
@@ -1178,7 +1258,7 @@ function mapServiceGroup(row) {
|
||||
health_check_interval_sec: row.health_check_interval_sec,
|
||||
health_check_timeout_ms: row.health_check_timeout_ms,
|
||||
health_check_verify_tls: row.health_check_verify_tls,
|
||||
health_check_provider: normalizeHealthProvider(row.health_check_provider),
|
||||
...mapHealthFields(row),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
@@ -1206,7 +1286,11 @@ function createServiceGroup(db, name, groupType, icon, domain, lbPatch) {
|
||||
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
|
||||
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3e3,
|
||||
health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false,
|
||||
health_check_provider: lbPatch?.health_check_provider ?? "local"
|
||||
...healthProviderColumns({
|
||||
health_check_provider: lbPatch?.health_check_provider ?? "local",
|
||||
health_check_providers: lbPatch?.health_check_providers,
|
||||
health_check_aggregate: lbPatch?.health_check_aggregate ?? "majority"
|
||||
})
|
||||
}).returning({ id: serviceGroups.id }).get().id;
|
||||
return getServiceGroup(db, id);
|
||||
}
|
||||
@@ -1236,8 +1320,7 @@ function updateServiceGroup(db, id, name, groupType, icon, domain, lbPatch) {
|
||||
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
|
||||
if (lbPatch.health_check_verify_tls !== void 0)
|
||||
update.health_check_verify_tls = lbPatch.health_check_verify_tls;
|
||||
if (lbPatch.health_check_provider !== void 0)
|
||||
update.health_check_provider = lbPatch.health_check_provider;
|
||||
Object.assign(update, healthProviderColumns(lbPatch));
|
||||
}
|
||||
const result = db.update(serviceGroups).set(update).where(eq3(serviceGroups.id, id)).run();
|
||||
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
|
||||
@@ -1547,8 +1630,9 @@ function updateBindingLbConfig(db, bindingId, patch) {
|
||||
update.health_check_timeout_ms = patch.health_check_timeout_ms;
|
||||
if (patch.health_check_verify_tls !== void 0)
|
||||
update.health_check_verify_tls = patch.health_check_verify_tls;
|
||||
if (patch.health_check_provider !== void 0)
|
||||
update.health_check_provider = patch.health_check_provider;
|
||||
if (patch.cert_monitoring !== void 0)
|
||||
update.cert_monitoring = patch.cert_monitoring;
|
||||
Object.assign(update, healthProviderColumns(patch));
|
||||
db.update(serviceBindings).set(update).where(eq3(serviceBindings.id, bindingId)).run();
|
||||
}
|
||||
function setBindingCnameTarget(db, bindingId, target) {
|
||||
@@ -1607,7 +1691,8 @@ function dnsRecordMatchesHostname(recordName, hostname, zoneName) {
|
||||
var SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
|
||||
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
|
||||
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
|
||||
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider, sb.cname_target,
|
||||
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider,
|
||||
sb.health_check_providers, sb.health_check_aggregate, sb.cert_monitoring, sb.cname_target,
|
||||
d.zone_name, d.group_id, g.name AS group_name,
|
||||
s.name AS service_name, s.slug AS service_slug,
|
||||
dr.content AS target_ip, dr.sync_status,
|
||||
@@ -1655,6 +1740,8 @@ function enrichServiceBindingView(db, row) {
|
||||
return {
|
||||
...row,
|
||||
cname_target: row.cname_target ?? null,
|
||||
cert_monitoring: row.cert_monitoring ?? "auto",
|
||||
...mapHealthFields(row),
|
||||
target_ips,
|
||||
target_ip: target_ips[0] ?? null,
|
||||
target_ip_weights,
|
||||
@@ -1686,16 +1773,20 @@ function listBindingsByDomain(db, domainId) {
|
||||
`).map((row) => enrichServiceBindingView(db, row));
|
||||
}
|
||||
function listBindingsByService(db, serviceId) {
|
||||
return db.all(sql2`
|
||||
const rows = db.all(sql2`
|
||||
SELECT sb.*, d.zone_name FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
WHERE sb.service_id = ${serviceId}
|
||||
`);
|
||||
return rows.map((row) => ({
|
||||
...mapServiceBinding(row),
|
||||
zone_name: row.zone_name
|
||||
}));
|
||||
}
|
||||
function getBinding(db, id) {
|
||||
const row = db.select().from(serviceBindings).where(eq3(serviceBindings.id, id)).get();
|
||||
if (!row) throw new NotFoundError(`service binding ${id}`);
|
||||
return row;
|
||||
return mapServiceBinding(row);
|
||||
}
|
||||
function getBindingView(db, id) {
|
||||
const rows = db.all(sql2`
|
||||
@@ -1718,7 +1809,8 @@ function findBinding(db, serviceId, domainId, hostname) {
|
||||
eq3(serviceBindings.hostname, hostname)
|
||||
)
|
||||
).get();
|
||||
return row ?? null;
|
||||
if (!row) return null;
|
||||
return mapServiceBinding(row);
|
||||
}
|
||||
function insertBinding(db, domainId, serviceId, hostname, dnsRecordId) {
|
||||
const id = db.insert(serviceBindings).values({
|
||||
@@ -1744,7 +1836,7 @@ function setBindingDnsRecordId(db, bindingId, dnsRecordId) {
|
||||
}).where(eq3(serviceBindings.id, bindingId)).run();
|
||||
}
|
||||
function bindingsToRemove(db, serviceId, keepIds) {
|
||||
const all = db.select().from(serviceBindings).where(eq3(serviceBindings.service_id, serviceId)).all();
|
||||
const all = db.select().from(serviceBindings).where(eq3(serviceBindings.service_id, serviceId)).all().map(mapServiceBinding);
|
||||
return all.filter((b) => !keepIds.includes(b.id));
|
||||
}
|
||||
function deleteBindingsExcept(db, serviceId, keepIds) {
|
||||
@@ -1759,23 +1851,57 @@ function deleteBinding(db, id) {
|
||||
const result = db.delete(serviceBindings).where(eq3(serviceBindings.id, id)).run();
|
||||
if (result.changes === 0) throw new NotFoundError(`service binding ${id}`);
|
||||
}
|
||||
var CERTIFICATE_SELECT = `c.id, c.domain_id, c.subdomain_id, c.service_id, c.hostname,
|
||||
c.expires_at, c.last_checked_at, c.last_error, c.status, c.created_at, c.updated_at,
|
||||
s.name AS service_name`;
|
||||
function mapCertificate(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
domain_id: row.domain_id,
|
||||
subdomain_id: row.subdomain_id,
|
||||
service_id: row.service_id ?? null,
|
||||
service_name: row.service_name ?? null,
|
||||
hostname: row.hostname,
|
||||
expires_at: row.expires_at,
|
||||
last_checked_at: row.last_checked_at,
|
||||
last_error: row.last_error,
|
||||
status: row.status,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
function listCertificates(db, status) {
|
||||
if (status) {
|
||||
return db.select().from(certificates).where(eq3(certificates.status, status)).orderBy(asc(certificates.expires_at)).all();
|
||||
}
|
||||
return db.select().from(certificates).orderBy(asc(certificates.expires_at)).all();
|
||||
const rows = status ? db.all(sql2`
|
||||
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
|
||||
FROM certificates c
|
||||
LEFT JOIN services s ON s.id = c.service_id
|
||||
WHERE c.status = ${status}
|
||||
ORDER BY c.expires_at ASC
|
||||
`) : db.all(sql2`
|
||||
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
|
||||
FROM certificates c
|
||||
LEFT JOIN services s ON s.id = c.service_id
|
||||
ORDER BY c.expires_at ASC
|
||||
`);
|
||||
return rows.map(mapCertificate);
|
||||
}
|
||||
function getCertificate(db, id) {
|
||||
const row = db.select().from(certificates).where(eq3(certificates.id, id)).get();
|
||||
const row = db.all(sql2`
|
||||
SELECT ${sql2.raw(CERTIFICATE_SELECT)}
|
||||
FROM certificates c
|
||||
LEFT JOIN services s ON s.id = c.service_id
|
||||
WHERE c.id = ${id}
|
||||
`)[0];
|
||||
if (!row) throw new NotFoundError(`certificate ${id}`);
|
||||
return row;
|
||||
return mapCertificate(row);
|
||||
}
|
||||
function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, status, lastError) {
|
||||
function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, status, lastError, serviceId) {
|
||||
const existing = db.select().from(certificates).where(eq3(certificates.hostname, hostname)).get();
|
||||
if (existing) {
|
||||
db.update(certificates).set({
|
||||
domain_id: domainId,
|
||||
subdomain_id: subdomainId,
|
||||
service_id: serviceId === void 0 ? existing.service_id : serviceId,
|
||||
expires_at: expiresAt,
|
||||
last_checked_at: sql2`datetime('now')`,
|
||||
last_error: lastError,
|
||||
@@ -1787,6 +1913,7 @@ function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt,
|
||||
const id = db.insert(certificates).values({
|
||||
domain_id: domainId,
|
||||
subdomain_id: subdomainId,
|
||||
service_id: serviceId ?? null,
|
||||
hostname,
|
||||
expires_at: expiresAt,
|
||||
last_checked_at: sql2`datetime('now')`,
|
||||
@@ -1975,7 +2102,7 @@ function listIpHealthByServiceIds(db, serviceIds) {
|
||||
latency_ms: parsed.health_latency_ms,
|
||||
last_checked_at: row.last_checked_at,
|
||||
last_error: row.last_error,
|
||||
provider: normalizeHealthProvider(row.provider),
|
||||
provider: normalizeStatusProvider(row.provider),
|
||||
colo: row.colo
|
||||
});
|
||||
result.set(row.service_id, list);
|
||||
@@ -2101,6 +2228,8 @@ function listHealthCheckTargets(db) {
|
||||
sb.health_check_expected_status AS expected_status,
|
||||
sb.health_check_timeout_ms AS timeout_ms,
|
||||
sb.health_check_verify_tls AS verify_tls,
|
||||
sb.health_check_providers AS providers_json,
|
||||
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sb.health_check_provider, 'local') AS provider
|
||||
FROM service_binding_ips sbi
|
||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||
@@ -2116,6 +2245,8 @@ function listHealthCheckTargets(db) {
|
||||
sg.health_check_expected_status AS expected_status,
|
||||
sg.health_check_timeout_ms AS timeout_ms,
|
||||
sg.health_check_verify_tls AS verify_tls,
|
||||
sg.health_check_providers AS providers_json,
|
||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||
FROM service_binding_ips sbi
|
||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||
@@ -2137,6 +2268,8 @@ function listHealthCheckTargets(db) {
|
||||
sg.health_check_expected_status AS expected_status,
|
||||
sg.health_check_timeout_ms AS timeout_ms,
|
||||
sg.health_check_verify_tls AS verify_tls,
|
||||
sg.health_check_providers AS providers_json,
|
||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||
FROM service_binding_ips sbi
|
||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||
@@ -2158,6 +2291,8 @@ function listHealthCheckTargets(db) {
|
||||
sb.health_check_expected_status AS expected_status,
|
||||
sb.health_check_timeout_ms AS timeout_ms,
|
||||
sb.health_check_verify_tls AS verify_tls,
|
||||
sb.health_check_providers AS providers_json,
|
||||
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sb.health_check_provider, 'local') AS provider
|
||||
FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
@@ -2176,6 +2311,8 @@ function listHealthCheckTargets(db) {
|
||||
sg.health_check_expected_status AS expected_status,
|
||||
sg.health_check_timeout_ms AS timeout_ms,
|
||||
sg.health_check_verify_tls AS verify_tls,
|
||||
sg.health_check_providers AS providers_json,
|
||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||
FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
@@ -2195,11 +2332,25 @@ function listHealthCheckTargets(db) {
|
||||
...groupInheritedBindingTargets,
|
||||
...cnameBindingTargets,
|
||||
...groupInheritedCnameBindingTargets
|
||||
].map((t) => ({
|
||||
...t,
|
||||
verify_tls: Boolean(t.verify_tls),
|
||||
provider: normalizeHealthProvider(t.provider)
|
||||
}));
|
||||
].map((t) => {
|
||||
const row = t;
|
||||
const providers = parseHealthProviders(row.providers_json, row.provider);
|
||||
return {
|
||||
scope: row.scope,
|
||||
ref_id: row.ref_id,
|
||||
ip: row.ip,
|
||||
hostname: row.hostname,
|
||||
type: row.type,
|
||||
port: row.port,
|
||||
path: row.path,
|
||||
expected_status: row.expected_status,
|
||||
timeout_ms: row.timeout_ms,
|
||||
verify_tls: Boolean(row.verify_tls),
|
||||
providers,
|
||||
aggregate: parseHealthAggregate(row.aggregate),
|
||||
provider: derivePrimaryProvider(providers)
|
||||
};
|
||||
});
|
||||
}
|
||||
function listDomainTags(db, domainId) {
|
||||
return db.select({ tag: domainTags.tag }).from(domainTags).where(eq3(domainTags.domain_id, domainId)).all().map((r) => r.tag);
|
||||
@@ -2329,7 +2480,7 @@ function listHealthProbeLogForService(db, serviceId, limit = 50) {
|
||||
`);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
provider: normalizeHealthProvider(row.provider),
|
||||
provider: parseHealthProviders(null, row.provider)[0] ?? "local",
|
||||
ok: Boolean(row.ok)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
ALTER TABLE service_bindings ADD COLUMN health_check_providers TEXT;
|
||||
ALTER TABLE service_bindings ADD COLUMN health_check_aggregate TEXT NOT NULL DEFAULT 'majority';
|
||||
UPDATE service_bindings
|
||||
SET health_check_providers = CASE
|
||||
WHEN health_check_provider = 'cloudflare' THEN '["cloudflare"]'
|
||||
ELSE '["local"]'
|
||||
END
|
||||
WHERE health_check_providers IS NULL;
|
||||
|
||||
ALTER TABLE service_groups ADD COLUMN health_check_providers TEXT;
|
||||
ALTER TABLE service_groups ADD COLUMN health_check_aggregate TEXT NOT NULL DEFAULT 'majority';
|
||||
UPDATE service_groups
|
||||
SET health_check_providers = CASE
|
||||
WHEN health_check_provider = 'cloudflare' THEN '["cloudflare"]'
|
||||
ELSE '["local"]'
|
||||
END
|
||||
WHERE health_check_providers IS NULL;
|
||||
|
||||
ALTER TABLE app_settings ADD COLUMN globalping_token TEXT;
|
||||
ALTER TABLE app_settings ADD COLUMN globalping_locations TEXT;
|
||||
ALTER TABLE app_settings ADD COLUMN globalping_limit INTEGER;
|
||||
@@ -0,0 +1,37 @@
|
||||
ALTER TABLE service_bindings ADD COLUMN cert_monitoring TEXT NOT NULL DEFAULT 'auto'
|
||||
CHECK (cert_monitoring IN ('auto', 'required', 'skipped'));
|
||||
|
||||
ALTER TABLE certificates ADD COLUMN service_id INTEGER REFERENCES services(id) ON DELETE SET NULL;
|
||||
|
||||
UPDATE service_bindings
|
||||
SET cert_monitoring = COALESCE(
|
||||
(
|
||||
SELECT d.cert_monitoring FROM domains d
|
||||
WHERE d.id = service_bindings.domain_id
|
||||
),
|
||||
'auto'
|
||||
)
|
||||
WHERE hostname = '@';
|
||||
|
||||
UPDATE service_bindings
|
||||
SET cert_monitoring = COALESCE(
|
||||
(
|
||||
SELECT s.cert_monitoring FROM subdomains s
|
||||
WHERE s.domain_id = service_bindings.domain_id
|
||||
AND s.name = service_bindings.hostname
|
||||
),
|
||||
'auto'
|
||||
)
|
||||
WHERE hostname != '@';
|
||||
|
||||
UPDATE certificates
|
||||
SET service_id = (
|
||||
SELECT sb.service_id
|
||||
FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
WHERE CASE
|
||||
WHEN sb.hostname = '@' THEN d.zone_name
|
||||
ELSE sb.hostname || '.' || d.zone_name
|
||||
END = certificates.hostname
|
||||
LIMIT 1
|
||||
);
|
||||
+214
-38
@@ -5,10 +5,12 @@ import type {
|
||||
DomainListItem,
|
||||
Group,
|
||||
GroupWithStats,
|
||||
HealthCheckAggregate,
|
||||
HealthCheckProvider,
|
||||
HealthCheckScope,
|
||||
HealthCheckTarget,
|
||||
HealthCheckType,
|
||||
HealthStatusProvider,
|
||||
IpHealthState,
|
||||
IpHealthStatus,
|
||||
LbMode,
|
||||
@@ -21,7 +23,15 @@ import type {
|
||||
Subdomain,
|
||||
SyncJob,
|
||||
} from "@cfdm/shared";
|
||||
import { dnsRecordNamesMatch, isIpLiteral } from "@cfdm/shared";
|
||||
import {
|
||||
derivePrimaryProvider,
|
||||
dnsRecordNamesMatch,
|
||||
isIpLiteral,
|
||||
parseHealthAggregate,
|
||||
parseHealthProviders,
|
||||
serializeHealthProviders,
|
||||
normalizeStatusProvider,
|
||||
} from "@cfdm/shared";
|
||||
import { and, asc, count, eq, isNull, like, notInArray, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "./client.js";
|
||||
import { ConflictError, NotFoundError } from "./errors.js";
|
||||
@@ -770,8 +780,83 @@ export function deleteService(db: Db, id: number): void {
|
||||
|
||||
// --- Service Groups ---
|
||||
|
||||
function normalizeHealthProvider(value: unknown): HealthCheckProvider {
|
||||
return value === "cloudflare" ? "cloudflare" : "local";
|
||||
function healthProviderColumns(patch: {
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}): {
|
||||
health_check_provider?: string;
|
||||
health_check_providers?: string;
|
||||
health_check_aggregate?: string;
|
||||
} {
|
||||
const out: {
|
||||
health_check_provider?: string;
|
||||
health_check_providers?: string;
|
||||
health_check_aggregate?: string;
|
||||
} = {};
|
||||
if (patch.health_check_providers !== undefined) {
|
||||
const list = parseHealthProviders(patch.health_check_providers);
|
||||
out.health_check_providers = serializeHealthProviders(list);
|
||||
out.health_check_provider = derivePrimaryProvider(list);
|
||||
} else if (patch.health_check_provider !== undefined) {
|
||||
const list = parseHealthProviders(null, patch.health_check_provider);
|
||||
out.health_check_providers = serializeHealthProviders(list);
|
||||
out.health_check_provider = derivePrimaryProvider(list);
|
||||
}
|
||||
if (patch.health_check_aggregate !== undefined) {
|
||||
out.health_check_aggregate = parseHealthAggregate(
|
||||
patch.health_check_aggregate,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mapHealthFields(row: {
|
||||
health_check_provider?: unknown;
|
||||
health_check_providers?: unknown;
|
||||
health_check_aggregate?: unknown;
|
||||
}): {
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
} {
|
||||
const providers = parseHealthProviders(
|
||||
row.health_check_providers,
|
||||
row.health_check_provider,
|
||||
);
|
||||
return {
|
||||
health_check_providers: providers,
|
||||
health_check_provider: derivePrimaryProvider(providers),
|
||||
health_check_aggregate: parseHealthAggregate(row.health_check_aggregate),
|
||||
};
|
||||
}
|
||||
|
||||
function mapServiceBinding(
|
||||
row: typeof serviceBindings.$inferSelect,
|
||||
): ServiceBinding {
|
||||
return {
|
||||
id: row.id,
|
||||
domain_id: row.domain_id,
|
||||
service_id: row.service_id,
|
||||
hostname: row.hostname,
|
||||
cname_target: row.cname_target,
|
||||
dns_record_id: row.dns_record_id,
|
||||
lb_mode: row.lb_mode as LbMode,
|
||||
health_check_enabled: Boolean(row.health_check_enabled),
|
||||
health_check_type: row.health_check_type as HealthCheckType,
|
||||
health_check_port: row.health_check_port,
|
||||
health_check_path: row.health_check_path,
|
||||
health_check_expected_status: row.health_check_expected_status,
|
||||
health_check_interval_sec: row.health_check_interval_sec,
|
||||
health_check_timeout_ms: row.health_check_timeout_ms,
|
||||
health_check_verify_tls: Boolean(row.health_check_verify_tls),
|
||||
...mapHealthFields(row),
|
||||
cert_monitoring: row.cert_monitoring ?? "auto",
|
||||
routing_strategy: row.routing_strategy as LbMode,
|
||||
operation_version: row.operation_version,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapServiceGroup(row: typeof serviceGroups.$inferSelect): ServiceGroup {
|
||||
@@ -791,7 +876,7 @@ function mapServiceGroup(row: typeof serviceGroups.$inferSelect): ServiceGroup {
|
||||
health_check_interval_sec: row.health_check_interval_sec,
|
||||
health_check_timeout_ms: row.health_check_timeout_ms,
|
||||
health_check_verify_tls: row.health_check_verify_tls,
|
||||
health_check_provider: normalizeHealthProvider(row.health_check_provider),
|
||||
...mapHealthFields(row),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
@@ -827,6 +912,8 @@ export interface ServiceGroupLbPatch {
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
export function createServiceGroup(
|
||||
@@ -853,7 +940,11 @@ export function createServiceGroup(
|
||||
health_check_interval_sec: lbPatch?.health_check_interval_sec ?? 30,
|
||||
health_check_timeout_ms: lbPatch?.health_check_timeout_ms ?? 3000,
|
||||
health_check_verify_tls: lbPatch?.health_check_verify_tls ?? false,
|
||||
health_check_provider: lbPatch?.health_check_provider ?? "local",
|
||||
...healthProviderColumns({
|
||||
health_check_provider: lbPatch?.health_check_provider ?? "local",
|
||||
health_check_providers: lbPatch?.health_check_providers,
|
||||
health_check_aggregate: lbPatch?.health_check_aggregate ?? "majority",
|
||||
}),
|
||||
})
|
||||
.returning({ id: serviceGroups.id })
|
||||
.get()!.id;
|
||||
@@ -894,8 +985,7 @@ export function updateServiceGroup(
|
||||
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
|
||||
if (lbPatch.health_check_verify_tls !== undefined)
|
||||
update.health_check_verify_tls = lbPatch.health_check_verify_tls;
|
||||
if (lbPatch.health_check_provider !== undefined)
|
||||
update.health_check_provider = lbPatch.health_check_provider;
|
||||
Object.assign(update, healthProviderColumns(lbPatch));
|
||||
}
|
||||
const result = db
|
||||
.update(serviceGroups)
|
||||
@@ -1439,6 +1529,9 @@ export interface BindingLbPatch {
|
||||
health_check_timeout_ms?: number;
|
||||
health_check_verify_tls?: boolean;
|
||||
health_check_provider?: HealthCheckProvider;
|
||||
health_check_providers?: HealthCheckProvider[];
|
||||
health_check_aggregate?: HealthCheckAggregate;
|
||||
cert_monitoring?: string;
|
||||
}
|
||||
|
||||
export function updateBindingLbConfig(
|
||||
@@ -1469,8 +1562,9 @@ export function updateBindingLbConfig(
|
||||
update.health_check_timeout_ms = patch.health_check_timeout_ms;
|
||||
if (patch.health_check_verify_tls !== undefined)
|
||||
update.health_check_verify_tls = patch.health_check_verify_tls;
|
||||
if (patch.health_check_provider !== undefined)
|
||||
update.health_check_provider = patch.health_check_provider;
|
||||
if (patch.cert_monitoring !== undefined)
|
||||
update.cert_monitoring = patch.cert_monitoring;
|
||||
Object.assign(update, healthProviderColumns(patch));
|
||||
db.update(serviceBindings)
|
||||
.set(update)
|
||||
.where(eq(serviceBindings.id, bindingId))
|
||||
@@ -1578,7 +1672,8 @@ function dnsRecordMatchesHostname(
|
||||
const SERVICE_BINDING_SELECT_COLUMNS = `sb.id, sb.domain_id, sb.service_id, sb.hostname, sb.dns_record_id,
|
||||
sb.lb_mode, sb.health_check_enabled, sb.health_check_type, sb.health_check_port,
|
||||
sb.health_check_path, sb.health_check_expected_status, sb.health_check_interval_sec,
|
||||
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider, sb.cname_target,
|
||||
sb.health_check_timeout_ms, sb.health_check_verify_tls, sb.health_check_provider,
|
||||
sb.health_check_providers, sb.health_check_aggregate, sb.cert_monitoring, sb.cname_target,
|
||||
d.zone_name, d.group_id, g.name AS group_name,
|
||||
s.name AS service_name, s.slug AS service_slug,
|
||||
dr.content AS target_ip, dr.sync_status,
|
||||
@@ -1642,6 +1737,8 @@ function enrichServiceBindingView(
|
||||
return {
|
||||
...row,
|
||||
cname_target: row.cname_target ?? null,
|
||||
cert_monitoring: row.cert_monitoring ?? "auto",
|
||||
...mapHealthFields(row),
|
||||
target_ips,
|
||||
target_ip: target_ips[0] ?? null,
|
||||
target_ip_weights,
|
||||
@@ -1680,11 +1777,15 @@ export function listBindingsByDomain(db: Db, domainId: number): ServiceBindingVi
|
||||
}
|
||||
|
||||
export function listBindingsByService(db: Db, serviceId: number): Array<ServiceBinding & { zone_name: string }> {
|
||||
return db.all(sql`
|
||||
const rows = db.all<typeof serviceBindings.$inferSelect & { zone_name: string }>(sql`
|
||||
SELECT sb.*, d.zone_name FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
WHERE sb.service_id = ${serviceId}
|
||||
`);
|
||||
return rows.map((row) => ({
|
||||
...mapServiceBinding(row),
|
||||
zone_name: row.zone_name,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getBinding(db: Db, id: number): ServiceBinding {
|
||||
@@ -1694,7 +1795,7 @@ export function getBinding(db: Db, id: number): ServiceBinding {
|
||||
.where(eq(serviceBindings.id, id))
|
||||
.get();
|
||||
if (!row) throw new NotFoundError(`service binding ${id}`);
|
||||
return row as ServiceBinding;
|
||||
return mapServiceBinding(row);
|
||||
}
|
||||
|
||||
export function getBindingView(db: Db, id: number): ServiceBindingView {
|
||||
@@ -1728,7 +1829,8 @@ export function findBinding(
|
||||
),
|
||||
)
|
||||
.get();
|
||||
return (row as ServiceBinding) ?? null;
|
||||
if (!row) return null;
|
||||
return mapServiceBinding(row);
|
||||
}
|
||||
|
||||
export function insertBinding(
|
||||
@@ -1792,7 +1894,8 @@ export function bindingsToRemove(
|
||||
.select()
|
||||
.from(serviceBindings)
|
||||
.where(eq(serviceBindings.service_id, serviceId))
|
||||
.all() as ServiceBinding[];
|
||||
.all()
|
||||
.map(mapServiceBinding);
|
||||
return all.filter((b) => !keepIds.includes(b.id));
|
||||
}
|
||||
|
||||
@@ -1820,26 +1923,69 @@ export function deleteBinding(db: Db, id: number): void {
|
||||
|
||||
// --- Certificates ---
|
||||
|
||||
const CERTIFICATE_SELECT = `c.id, c.domain_id, c.subdomain_id, c.service_id, c.hostname,
|
||||
c.expires_at, c.last_checked_at, c.last_error, c.status, c.created_at, c.updated_at,
|
||||
s.name AS service_name`;
|
||||
|
||||
type CertificateRow = {
|
||||
id: number;
|
||||
domain_id: number;
|
||||
subdomain_id: number | null;
|
||||
service_id: number | null;
|
||||
hostname: string;
|
||||
expires_at: string | null;
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
service_name: string | null;
|
||||
};
|
||||
|
||||
function mapCertificate(row: CertificateRow): Certificate {
|
||||
return {
|
||||
id: row.id,
|
||||
domain_id: row.domain_id,
|
||||
subdomain_id: row.subdomain_id,
|
||||
service_id: row.service_id ?? null,
|
||||
service_name: row.service_name ?? null,
|
||||
hostname: row.hostname,
|
||||
expires_at: row.expires_at,
|
||||
last_checked_at: row.last_checked_at,
|
||||
last_error: row.last_error,
|
||||
status: row.status,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function listCertificates(db: Db, status?: string): Certificate[] {
|
||||
if (status) {
|
||||
return db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.where(eq(certificates.status, status))
|
||||
.orderBy(asc(certificates.expires_at))
|
||||
.all() as Certificate[];
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(certificates)
|
||||
.orderBy(asc(certificates.expires_at))
|
||||
.all() as Certificate[];
|
||||
const rows = status
|
||||
? db.all<CertificateRow>(sql`
|
||||
SELECT ${sql.raw(CERTIFICATE_SELECT)}
|
||||
FROM certificates c
|
||||
LEFT JOIN services s ON s.id = c.service_id
|
||||
WHERE c.status = ${status}
|
||||
ORDER BY c.expires_at ASC
|
||||
`)
|
||||
: db.all<CertificateRow>(sql`
|
||||
SELECT ${sql.raw(CERTIFICATE_SELECT)}
|
||||
FROM certificates c
|
||||
LEFT JOIN services s ON s.id = c.service_id
|
||||
ORDER BY c.expires_at ASC
|
||||
`);
|
||||
return rows.map(mapCertificate);
|
||||
}
|
||||
|
||||
export function getCertificate(db: Db, id: number): Certificate {
|
||||
const row = db.select().from(certificates).where(eq(certificates.id, id)).get();
|
||||
const row = db.all<CertificateRow>(sql`
|
||||
SELECT ${sql.raw(CERTIFICATE_SELECT)}
|
||||
FROM certificates c
|
||||
LEFT JOIN services s ON s.id = c.service_id
|
||||
WHERE c.id = ${id}
|
||||
`)[0];
|
||||
if (!row) throw new NotFoundError(`certificate ${id}`);
|
||||
return row as Certificate;
|
||||
return mapCertificate(row);
|
||||
}
|
||||
|
||||
export function upsertCertificateCheck(
|
||||
@@ -1850,6 +1996,7 @@ export function upsertCertificateCheck(
|
||||
expiresAt: string | null,
|
||||
status: string,
|
||||
lastError: string | null,
|
||||
serviceId?: number | null,
|
||||
): Certificate {
|
||||
const existing = db
|
||||
.select()
|
||||
@@ -1862,6 +2009,7 @@ export function upsertCertificateCheck(
|
||||
.set({
|
||||
domain_id: domainId,
|
||||
subdomain_id: subdomainId,
|
||||
service_id: serviceId === undefined ? existing.service_id : serviceId,
|
||||
expires_at: expiresAt,
|
||||
last_checked_at: sql`datetime('now')`,
|
||||
last_error: lastError,
|
||||
@@ -1878,6 +2026,7 @@ export function upsertCertificateCheck(
|
||||
.values({
|
||||
domain_id: domainId,
|
||||
subdomain_id: subdomainId,
|
||||
service_id: serviceId ?? null,
|
||||
hostname,
|
||||
expires_at: expiresAt,
|
||||
last_checked_at: sql`datetime('now')`,
|
||||
@@ -2124,7 +2273,7 @@ export type ServiceIpHealthRow = {
|
||||
latency_ms: number | null;
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
provider: HealthCheckProvider;
|
||||
provider: HealthStatusProvider;
|
||||
colo: string | null;
|
||||
};
|
||||
|
||||
@@ -2175,7 +2324,7 @@ export function listIpHealthByServiceIds(
|
||||
latency_ms: parsed.health_latency_ms,
|
||||
last_checked_at: row.last_checked_at,
|
||||
last_error: row.last_error,
|
||||
provider: normalizeHealthProvider(row.provider),
|
||||
provider: normalizeStatusProvider(row.provider),
|
||||
colo: row.colo,
|
||||
});
|
||||
result.set(row.service_id, list);
|
||||
@@ -2243,7 +2392,7 @@ export function upsertIpHealthStatus(
|
||||
consecutiveFailures: number,
|
||||
lastError: string | null,
|
||||
consecutiveSuccesses = 0,
|
||||
extras?: { colo?: string | null; provider?: HealthCheckProvider },
|
||||
extras?: { colo?: string | null; provider?: HealthStatusProvider },
|
||||
): void {
|
||||
const colo = extras?.colo ?? null;
|
||||
const provider = extras?.provider ?? "local";
|
||||
@@ -2359,6 +2508,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
sb.health_check_expected_status AS expected_status,
|
||||
sb.health_check_timeout_ms AS timeout_ms,
|
||||
sb.health_check_verify_tls AS verify_tls,
|
||||
sb.health_check_providers AS providers_json,
|
||||
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sb.health_check_provider, 'local') AS provider
|
||||
FROM service_binding_ips sbi
|
||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||
@@ -2378,6 +2529,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
sg.health_check_expected_status AS expected_status,
|
||||
sg.health_check_timeout_ms AS timeout_ms,
|
||||
sg.health_check_verify_tls AS verify_tls,
|
||||
sg.health_check_providers AS providers_json,
|
||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||
FROM service_binding_ips sbi
|
||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||
@@ -2404,6 +2557,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
sg.health_check_expected_status AS expected_status,
|
||||
sg.health_check_timeout_ms AS timeout_ms,
|
||||
sg.health_check_verify_tls AS verify_tls,
|
||||
sg.health_check_providers AS providers_json,
|
||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||
FROM service_binding_ips sbi
|
||||
JOIN service_bindings sb ON sb.id = sbi.binding_id
|
||||
@@ -2427,6 +2582,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
sb.health_check_expected_status AS expected_status,
|
||||
sb.health_check_timeout_ms AS timeout_ms,
|
||||
sb.health_check_verify_tls AS verify_tls,
|
||||
sb.health_check_providers AS providers_json,
|
||||
COALESCE(sb.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sb.health_check_provider, 'local') AS provider
|
||||
FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
@@ -2448,6 +2605,8 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
sg.health_check_expected_status AS expected_status,
|
||||
sg.health_check_timeout_ms AS timeout_ms,
|
||||
sg.health_check_verify_tls AS verify_tls,
|
||||
sg.health_check_providers AS providers_json,
|
||||
COALESCE(sg.health_check_aggregate, 'majority') AS aggregate,
|
||||
COALESCE(sg.health_check_provider, 'local') AS provider
|
||||
FROM service_bindings sb
|
||||
JOIN domains d ON d.id = sb.domain_id
|
||||
@@ -2468,11 +2627,28 @@ export function listHealthCheckTargets(db: Db): HealthCheckTarget[] {
|
||||
...groupInheritedBindingTargets,
|
||||
...cnameBindingTargets,
|
||||
...groupInheritedCnameBindingTargets,
|
||||
].map((t) => ({
|
||||
...t,
|
||||
verify_tls: Boolean(t.verify_tls),
|
||||
provider: normalizeHealthProvider(t.provider),
|
||||
}));
|
||||
].map((t) => {
|
||||
const row = t as HealthCheckTarget & {
|
||||
providers_json?: string | null;
|
||||
aggregate?: string | null;
|
||||
};
|
||||
const providers = parseHealthProviders(row.providers_json, row.provider);
|
||||
return {
|
||||
scope: row.scope,
|
||||
ref_id: row.ref_id,
|
||||
ip: row.ip,
|
||||
hostname: row.hostname,
|
||||
type: row.type,
|
||||
port: row.port,
|
||||
path: row.path,
|
||||
expected_status: row.expected_status,
|
||||
timeout_ms: row.timeout_ms,
|
||||
verify_tls: Boolean(row.verify_tls),
|
||||
providers,
|
||||
aggregate: parseHealthAggregate(row.aggregate),
|
||||
provider: derivePrimaryProvider(providers),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- Domain tags ---
|
||||
@@ -2759,7 +2935,7 @@ export function listHealthProbeLogForService(
|
||||
`);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
provider: normalizeHealthProvider(row.provider),
|
||||
provider: parseHealthProviders(null, row.provider)[0] ?? "local",
|
||||
ok: Boolean(row.ok),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ export const serviceGroups = sqliteTable("service_groups", {
|
||||
.notNull()
|
||||
.default(false),
|
||||
health_check_provider: text("health_check_provider").notNull().default("local"),
|
||||
health_check_providers: text("health_check_providers").notNull().default('["local"]'),
|
||||
health_check_aggregate: text("health_check_aggregate").notNull().default("majority"),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
@@ -169,6 +171,13 @@ export const serviceBindings = sqliteTable(
|
||||
health_check_provider: text("health_check_provider")
|
||||
.notNull()
|
||||
.default("local"),
|
||||
health_check_providers: text("health_check_providers")
|
||||
.notNull()
|
||||
.default('["local"]'),
|
||||
health_check_aggregate: text("health_check_aggregate")
|
||||
.notNull()
|
||||
.default("majority"),
|
||||
cert_monitoring: text("cert_monitoring").notNull().default("auto"),
|
||||
routing_strategy: text("routing_strategy").notNull().default("round_robin"),
|
||||
operation_version: integer("operation_version").notNull().default(0),
|
||||
created_at: text("created_at")
|
||||
@@ -316,6 +325,9 @@ export const certificates = sqliteTable("certificates", {
|
||||
subdomain_id: integer("subdomain_id").references(() => subdomains.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
service_id: integer("service_id").references(() => services.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
hostname: text("hostname").notNull().unique(),
|
||||
expires_at: text("expires_at"),
|
||||
last_checked_at: text("last_checked_at"),
|
||||
@@ -398,6 +410,9 @@ export const appSettings = sqliteTable("app_settings", {
|
||||
health_worker_error: text("health_worker_error"),
|
||||
health_worker_deployed_at: text("health_worker_deployed_at"),
|
||||
health_worker_last_ingest_at: text("health_worker_last_ingest_at"),
|
||||
globalping_token: text("globalping_token"),
|
||||
globalping_locations: text("globalping_locations"),
|
||||
globalping_limit: integer("globalping_limit"),
|
||||
created_at: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`datetime('now')`),
|
||||
|
||||
@@ -28,6 +28,9 @@ export type AppSettingsDto = {
|
||||
healthWorkerError: string | null;
|
||||
healthWorkerDeployedAt: string | null;
|
||||
healthWorkerLastIngestAt: string | null;
|
||||
globalpingTokenSet: boolean;
|
||||
globalpingLocations: string;
|
||||
globalpingLimit: number;
|
||||
} & HealthEngineSettings;
|
||||
|
||||
export type AppSettingsPatch = {
|
||||
@@ -47,6 +50,9 @@ export type AppSettingsPatch = {
|
||||
healthWorkerError?: string | null;
|
||||
healthWorkerDeployedAt?: string | null;
|
||||
healthWorkerLastIngestAt?: string | null;
|
||||
globalpingToken?: string;
|
||||
globalpingLocations?: string;
|
||||
globalpingLimit?: number;
|
||||
};
|
||||
|
||||
export type HealthEngineFallbacks = HealthEngineSettings & {
|
||||
@@ -118,6 +124,14 @@ function toDto(
|
||||
healthWorkerDeployedAt: row.health_worker_deployed_at ?? null,
|
||||
healthWorkerLastIngestAt: row.health_worker_last_ingest_at ?? null,
|
||||
healthWorkerStatus: workerStatus(row, env.healthWorkerUrl),
|
||||
globalpingTokenSet: Boolean(row.globalping_token?.trim()),
|
||||
globalpingLocations: row.globalping_locations?.trim() || "World",
|
||||
globalpingLimit:
|
||||
row.globalping_limit == null ||
|
||||
Number.isNaN(row.globalping_limit) ||
|
||||
row.globalping_limit < 1
|
||||
? 3
|
||||
: Math.min(10, row.globalping_limit),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,12 +160,16 @@ export function getAppSettingsSecrets(db: Db): {
|
||||
vpsTrackerSyncEnabled: boolean;
|
||||
healthWorkerUrl: string;
|
||||
healthWorkerToken: string;
|
||||
globalpingToken: string;
|
||||
globalpingLocations: string;
|
||||
globalpingLimit: number;
|
||||
} {
|
||||
const row = db
|
||||
.select()
|
||||
.from(appSettings)
|
||||
.where(eq(appSettings.id, SETTINGS_ID))
|
||||
.get();
|
||||
const limit = row?.globalping_limit;
|
||||
return {
|
||||
vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "",
|
||||
vpsTrackerIntegrationToken:
|
||||
@@ -159,6 +177,12 @@ export function getAppSettingsSecrets(db: Db): {
|
||||
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled),
|
||||
healthWorkerUrl: row?.health_worker_url?.trim() ?? "",
|
||||
healthWorkerToken: row?.health_worker_token?.trim() ?? "",
|
||||
globalpingToken: row?.globalping_token?.trim() ?? "",
|
||||
globalpingLocations: row?.globalping_locations?.trim() || "World",
|
||||
globalpingLimit:
|
||||
limit == null || Number.isNaN(limit) || limit < 1
|
||||
? 3
|
||||
: Math.min(10, limit),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -249,6 +273,19 @@ export function updateAppSettings(
|
||||
patch.healthWorkerLastIngestAt !== undefined
|
||||
? patch.healthWorkerLastIngestAt
|
||||
: current.health_worker_last_ingest_at,
|
||||
globalping_token:
|
||||
patch.globalpingToken !== undefined &&
|
||||
patch.globalpingToken.trim() !== ""
|
||||
? patch.globalpingToken
|
||||
: current.globalping_token,
|
||||
globalping_locations:
|
||||
patch.globalpingLocations !== undefined
|
||||
? patch.globalpingLocations.trim() || "World"
|
||||
: current.globalping_locations,
|
||||
globalping_limit:
|
||||
patch.globalpingLimit !== undefined
|
||||
? Math.min(10, Math.max(1, patch.globalpingLimit))
|
||||
: current.globalping_limit,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(appSettings.id, SETTINGS_ID))
|
||||
|
||||
Vendored
+357
-30
@@ -15,6 +15,36 @@ declare const CERT_MONITOR_REQUIRED = "required";
|
||||
declare const CERT_MONITOR_SKIPPED = "skipped";
|
||||
declare const CERT_MONITORING_VALUES: readonly ["auto", "required", "skipped"];
|
||||
|
||||
declare const HEALTH_CHECK_PROVIDERS: readonly ["local", "cloudflare", "globalping"];
|
||||
type HealthCheckProvider = (typeof HEALTH_CHECK_PROVIDERS)[number];
|
||||
declare const HEALTH_STATUS_PROVIDERS: readonly ["local", "cloudflare", "globalping", "aggregate"];
|
||||
type HealthStatusProvider = (typeof HEALTH_STATUS_PROVIDERS)[number];
|
||||
declare const HEALTH_CHECK_AGGREGATES: readonly ["any", "all", "majority"];
|
||||
type HealthCheckAggregate = (typeof HEALTH_CHECK_AGGREGATES)[number];
|
||||
declare function normalizeProbeProvider(value: unknown): HealthCheckProvider;
|
||||
declare function normalizeStatusProvider(value: unknown): HealthStatusProvider;
|
||||
declare function uniqueHealthProviders(values: readonly unknown[]): HealthCheckProvider[];
|
||||
declare function parseHealthProviders(json: unknown, fallback?: unknown): HealthCheckProvider[];
|
||||
declare function serializeHealthProviders(providers: readonly HealthCheckProvider[]): string;
|
||||
declare function parseHealthAggregate(value: unknown): HealthCheckAggregate;
|
||||
declare function derivePrimaryProvider(providers: readonly HealthCheckProvider[]): HealthCheckProvider;
|
||||
declare function targetProviders(target: {
|
||||
providers?: readonly HealthCheckProvider[] | null;
|
||||
provider?: HealthCheckProvider | null;
|
||||
}): HealthCheckProvider[];
|
||||
declare function targetHasProvider(target: {
|
||||
providers?: readonly HealthCheckProvider[] | null;
|
||||
provider?: HealthCheckProvider | null;
|
||||
}, provider: HealthCheckProvider): boolean;
|
||||
/**
|
||||
* any — Down if at least one source is Down (ok only if all ok).
|
||||
* all — Down only if every source is Down (ok if any ok).
|
||||
* majority — Down if a strict majority of sources are Down (2 → both, 3 → ≥2).
|
||||
*/
|
||||
declare function aggregateHealthOk(oks: readonly boolean[], policy: HealthCheckAggregate): boolean;
|
||||
declare function clampGlobalpingLimit(value: unknown, fallback?: number): number;
|
||||
declare function parseGlobalpingLocations(value: unknown): string[];
|
||||
|
||||
interface ServiceGroup$1 {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -32,6 +62,8 @@ interface ServiceGroup$1 {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -62,6 +94,9 @@ interface ServiceBinding {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
cert_monitoring: string;
|
||||
routing_strategy: LbMode;
|
||||
operation_version: number;
|
||||
created_at: string;
|
||||
@@ -93,6 +128,9 @@ interface ServiceBindingView {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
cert_monitoring: string;
|
||||
sync_status: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -118,6 +156,9 @@ interface ServiceDomainBindingView {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
cert_monitoring: string;
|
||||
sync_status: string | null;
|
||||
}
|
||||
interface ServiceView$1 {
|
||||
@@ -138,6 +179,8 @@ interface ServiceView$1 {
|
||||
health_latency_ms: number | null;
|
||||
ip_health: ServiceIpHealth$1[];
|
||||
ip_enabled: Record<string, boolean>;
|
||||
lb_mode: LbMode;
|
||||
active_ips: string[];
|
||||
}
|
||||
interface SyncJob {
|
||||
id: string;
|
||||
@@ -189,7 +232,6 @@ type LbMode = "round_robin" | "failover" | "weighted";
|
||||
type HealthCheckType = "tcp" | "http" | "ping" | "dns";
|
||||
type IpHealthState = "up" | "down" | "degraded" | "unknown";
|
||||
type NodeHealthState = "unknown" | "checking" | "healthy" | "degraded" | "unhealthy" | "disabled";
|
||||
type HealthCheckProvider = "local" | "cloudflare";
|
||||
type HealthCheckScope = "binding" | "group";
|
||||
interface IpHealthStatus {
|
||||
scope: HealthCheckScope;
|
||||
@@ -202,7 +244,7 @@ interface IpHealthStatus {
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
colo?: string | null;
|
||||
provider?: HealthCheckProvider;
|
||||
provider?: HealthStatusProvider;
|
||||
}
|
||||
interface ServiceIpHealth$1 {
|
||||
ip: string;
|
||||
@@ -210,7 +252,7 @@ interface ServiceIpHealth$1 {
|
||||
latency_ms: number | null;
|
||||
last_checked_at?: string | null;
|
||||
last_error?: string | null;
|
||||
provider?: HealthCheckProvider;
|
||||
provider?: HealthStatusProvider;
|
||||
colo?: string | null;
|
||||
}
|
||||
interface ServiceNode {
|
||||
@@ -290,6 +332,8 @@ interface HealthCheckTarget {
|
||||
timeout_ms: number;
|
||||
verify_tls: boolean;
|
||||
provider: HealthCheckProvider;
|
||||
providers?: HealthCheckProvider[];
|
||||
aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
declare class ValidationError extends Error {
|
||||
@@ -370,7 +414,24 @@ declare const nodeHealthStateSchema: z.ZodEnum<{
|
||||
declare const healthCheckProviderSchema: z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>;
|
||||
declare const healthStatusProviderSchema: z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
aggregate: "aggregate";
|
||||
}>;
|
||||
declare const healthCheckAggregateSchema: z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>;
|
||||
declare const healthCheckProvidersSchema: z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>;
|
||||
declare const healthCheckScopeSchema: z.ZodEnum<{
|
||||
binding: "binding";
|
||||
group: "group";
|
||||
@@ -397,6 +458,8 @@ declare const ipHealthStatusSchema: z.ZodObject<{
|
||||
provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
aggregate: "aggregate";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
declare const serviceIpHealthSchema: z.ZodObject<{
|
||||
@@ -413,6 +476,8 @@ declare const serviceIpHealthSchema: z.ZodObject<{
|
||||
provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
aggregate: "aggregate";
|
||||
}>>;
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>;
|
||||
@@ -425,6 +490,7 @@ declare const healthProbeLogSchema: z.ZodObject<{
|
||||
provider: z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>;
|
||||
status: z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
@@ -455,21 +521,21 @@ declare const groupWithStatsSchema: z.ZodObject<{
|
||||
domain_count: z.ZodNumber;
|
||||
}, z.core.$strip>;
|
||||
declare const serviceGroupTypeSchema: z.ZodEnum<{
|
||||
custom: "custom";
|
||||
vpn: "vpn";
|
||||
network: "network";
|
||||
internet: "internet";
|
||||
bgp: "bgp";
|
||||
custom: "custom";
|
||||
}>;
|
||||
declare const serviceGroupSchema: z.ZodObject<{
|
||||
id: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
type: z.ZodCatch<z.ZodEnum<{
|
||||
custom: "custom";
|
||||
vpn: "vpn";
|
||||
network: "network";
|
||||
internet: "internet";
|
||||
bgp: "bgp";
|
||||
custom: "custom";
|
||||
}>>;
|
||||
icon: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
||||
domain: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
||||
@@ -495,6 +561,17 @@ declare const serviceGroupSchema: z.ZodObject<{
|
||||
health_check_provider: z.ZodCatch<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
created_at: z.ZodString;
|
||||
updated_at: z.ZodString;
|
||||
@@ -548,6 +625,22 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
|
||||
health_check_provider: z.ZodCatch<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
cert_monitoring: z.ZodDefault<z.ZodEnum<{
|
||||
auto: "auto";
|
||||
required: "required";
|
||||
skipped: "skipped";
|
||||
}>>;
|
||||
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>, z.ZodTransform<{
|
||||
@@ -570,7 +663,10 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
cert_monitoring: "auto" | "required" | "skipped";
|
||||
sync_status: string | null;
|
||||
target_ip?: string | null | undefined;
|
||||
}, {
|
||||
@@ -589,7 +685,10 @@ declare const serviceDomainBindingSchema: z.ZodPipe<z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
cert_monitoring: "auto" | "required" | "skipped";
|
||||
sync_status: string | null;
|
||||
target_ips?: string[] | undefined;
|
||||
target_ip?: string | null | undefined;
|
||||
@@ -646,6 +745,22 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
health_check_provider: z.ZodCatch<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
cert_monitoring: z.ZodDefault<z.ZodEnum<{
|
||||
auto: "auto";
|
||||
required: "required";
|
||||
skipped: "skipped";
|
||||
}>>;
|
||||
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>, z.ZodTransform<{
|
||||
@@ -668,7 +783,10 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
cert_monitoring: "auto" | "required" | "skipped";
|
||||
sync_status: string | null;
|
||||
target_ip?: string | null | undefined;
|
||||
}, {
|
||||
@@ -687,7 +805,10 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
cert_monitoring: "auto" | "required" | "skipped";
|
||||
sync_status: string | null;
|
||||
target_ips?: string[] | undefined;
|
||||
target_ip?: string | null | undefined;
|
||||
@@ -716,20 +837,28 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
aggregate: "aggregate";
|
||||
}>>;
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||
round_robin: "round_robin";
|
||||
failover: "failover";
|
||||
weighted: "weighted";
|
||||
}>>;
|
||||
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>;
|
||||
declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
id: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
type: z.ZodCatch<z.ZodEnum<{
|
||||
custom: "custom";
|
||||
vpn: "vpn";
|
||||
network: "network";
|
||||
internet: "internet";
|
||||
bgp: "bgp";
|
||||
custom: "custom";
|
||||
}>>;
|
||||
icon: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
||||
domain: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
||||
@@ -755,6 +884,17 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
health_check_provider: z.ZodCatch<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
created_at: z.ZodString;
|
||||
updated_at: z.ZodString;
|
||||
@@ -807,6 +947,22 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
health_check_provider: z.ZodCatch<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
cert_monitoring: z.ZodDefault<z.ZodEnum<{
|
||||
auto: "auto";
|
||||
required: "required";
|
||||
skipped: "skipped";
|
||||
}>>;
|
||||
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>, z.ZodTransform<{
|
||||
@@ -829,7 +985,10 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
cert_monitoring: "auto" | "required" | "skipped";
|
||||
sync_status: string | null;
|
||||
target_ip?: string | null | undefined;
|
||||
}, {
|
||||
@@ -848,7 +1007,10 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
cert_monitoring: "auto" | "required" | "skipped";
|
||||
sync_status: string | null;
|
||||
target_ips?: string[] | undefined;
|
||||
target_ip?: string | null | undefined;
|
||||
@@ -877,10 +1039,18 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
aggregate: "aggregate";
|
||||
}>>;
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||
round_robin: "round_robin";
|
||||
failover: "failover";
|
||||
weighted: "weighted";
|
||||
}>>;
|
||||
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
health_status: z.ZodDefault<z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
@@ -895,11 +1065,11 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
id: z.ZodNumber;
|
||||
name: z.ZodString;
|
||||
type: z.ZodCatch<z.ZodEnum<{
|
||||
custom: "custom";
|
||||
vpn: "vpn";
|
||||
network: "network";
|
||||
internet: "internet";
|
||||
bgp: "bgp";
|
||||
custom: "custom";
|
||||
}>>;
|
||||
icon: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
||||
domain: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
||||
@@ -925,6 +1095,17 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_provider: z.ZodCatch<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
created_at: z.ZodString;
|
||||
updated_at: z.ZodString;
|
||||
@@ -977,6 +1158,22 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_provider: z.ZodCatch<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
cert_monitoring: z.ZodDefault<z.ZodEnum<{
|
||||
auto: "auto";
|
||||
required: "required";
|
||||
skipped: "skipped";
|
||||
}>>;
|
||||
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>, z.ZodTransform<{
|
||||
@@ -999,7 +1196,10 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
cert_monitoring: "auto" | "required" | "skipped";
|
||||
sync_status: string | null;
|
||||
target_ip?: string | null | undefined;
|
||||
}, {
|
||||
@@ -1018,7 +1218,10 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
cert_monitoring: "auto" | "required" | "skipped";
|
||||
sync_status: string | null;
|
||||
target_ips?: string[] | undefined;
|
||||
target_ip?: string | null | undefined;
|
||||
@@ -1047,10 +1250,18 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
aggregate: "aggregate";
|
||||
}>>;
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||
round_robin: "round_robin";
|
||||
failover: "failover";
|
||||
weighted: "weighted";
|
||||
}>>;
|
||||
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
health_status: z.ZodDefault<z.ZodEnum<{
|
||||
unknown: "unknown";
|
||||
@@ -1109,6 +1320,22 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_provider: z.ZodCatch<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodCatch<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodCatch<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
cert_monitoring: z.ZodDefault<z.ZodEnum<{
|
||||
auto: "auto";
|
||||
required: "required";
|
||||
skipped: "skipped";
|
||||
}>>;
|
||||
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>, z.ZodTransform<{
|
||||
@@ -1131,7 +1358,10 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
cert_monitoring: "auto" | "required" | "skipped";
|
||||
sync_status: string | null;
|
||||
target_ip?: string | null | undefined;
|
||||
}, {
|
||||
@@ -1150,7 +1380,10 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: "local" | "cloudflare";
|
||||
health_check_provider: "local" | "cloudflare" | "globalping";
|
||||
health_check_providers: ("local" | "cloudflare" | "globalping")[] | readonly ["local"];
|
||||
health_check_aggregate: "any" | "all" | "majority";
|
||||
cert_monitoring: "auto" | "required" | "skipped";
|
||||
sync_status: string | null;
|
||||
target_ips?: string[] | undefined;
|
||||
target_ip?: string | null | undefined;
|
||||
@@ -1179,10 +1412,18 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
aggregate: "aggregate";
|
||||
}>>;
|
||||
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||
lb_mode: z.ZodCatch<z.ZodEnum<{
|
||||
round_robin: "round_robin";
|
||||
failover: "failover";
|
||||
weighted: "weighted";
|
||||
}>>;
|
||||
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
||||
}, z.core.$strip>>>;
|
||||
}, z.core.$strip>;
|
||||
declare const domainSchema: z.ZodObject<{
|
||||
@@ -1268,6 +1509,11 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
|
||||
health_check_interval_sec: z.ZodDefault<z.ZodNumber>;
|
||||
health_check_timeout_ms: z.ZodDefault<z.ZodNumber>;
|
||||
health_check_verify_tls: z.ZodDefault<z.ZodCoercedBoolean<unknown>>;
|
||||
cert_monitoring: z.ZodDefault<z.ZodEnum<{
|
||||
auto: "auto";
|
||||
required: "required";
|
||||
skipped: "skipped";
|
||||
}>>;
|
||||
sync_status: z.ZodDefault<z.ZodNullable<z.ZodString>>;
|
||||
created_at: z.ZodString;
|
||||
updated_at: z.ZodString;
|
||||
@@ -1295,6 +1541,7 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
cert_monitoring: "auto" | "required" | "skipped";
|
||||
sync_status: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -1319,6 +1566,7 @@ declare const serviceBindingSchema: z.ZodPipe<z.ZodObject<{
|
||||
health_check_interval_sec: number;
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
cert_monitoring: "auto" | "required" | "skipped";
|
||||
sync_status: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -1346,6 +1594,8 @@ declare const certificateSchema: z.ZodObject<{
|
||||
id: z.ZodNumber;
|
||||
domain_id: z.ZodNumber;
|
||||
subdomain_id: z.ZodNullable<z.ZodNumber>;
|
||||
service_id: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodNumber>>>;
|
||||
service_name: z.ZodDefault<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
|
||||
hostname: z.ZodString;
|
||||
expires_at: z.ZodNullable<z.ZodString>;
|
||||
last_checked_at: z.ZodNullable<z.ZodString>;
|
||||
@@ -1354,6 +1604,22 @@ declare const certificateSchema: z.ZodObject<{
|
||||
created_at: z.ZodString;
|
||||
updated_at: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
declare const serviceCertificateRowSchema: z.ZodObject<{
|
||||
binding_id: z.ZodNumber;
|
||||
domain_id: z.ZodNumber;
|
||||
service_id: z.ZodNumber;
|
||||
hostname: z.ZodString;
|
||||
cert_monitoring: z.ZodEnum<{
|
||||
auto: "auto";
|
||||
required: "required";
|
||||
skipped: "skipped";
|
||||
}>;
|
||||
id: z.ZodNullable<z.ZodNumber>;
|
||||
status: z.ZodString;
|
||||
expires_at: z.ZodNullable<z.ZodString>;
|
||||
last_checked_at: z.ZodNullable<z.ZodString>;
|
||||
last_error: z.ZodNullable<z.ZodString>;
|
||||
}, z.core.$strip>;
|
||||
type Group = z.infer<typeof groupSchema>;
|
||||
type GroupWithStats = z.infer<typeof groupWithStatsSchema>;
|
||||
type Service = z.infer<typeof serviceSchema>;
|
||||
@@ -1366,12 +1632,13 @@ type Domain = z.infer<typeof domainSchema>;
|
||||
type DomainListItem = z.infer<typeof domainListItemSchema>;
|
||||
type DnsRecord = z.infer<typeof dnsRecordSchema>;
|
||||
type Certificate = z.infer<typeof certificateSchema>;
|
||||
type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>;
|
||||
declare const createGroupSchema: z.ZodObject<{
|
||||
name: z.ZodString;
|
||||
slug: z.ZodString;
|
||||
}, z.core.$strip>;
|
||||
declare const healthCheckConfigSchema: z.ZodObject<{
|
||||
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_type: z.ZodOptional<z.ZodEnum<{
|
||||
tcp: "tcp";
|
||||
http: "http";
|
||||
@@ -1383,10 +1650,21 @@ declare const healthCheckConfigSchema: z.ZodObject<{
|
||||
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
type HealthCheckConfig = z.infer<typeof healthCheckConfigSchema>;
|
||||
@@ -1402,7 +1680,7 @@ declare const createServiceWithConfigSchema: z.ZodObject<{
|
||||
lb_weight: z.ZodOptional<z.ZodNumber>;
|
||||
lb_priority: z.ZodOptional<z.ZodNumber>;
|
||||
domains: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_type: z.ZodOptional<z.ZodEnum<{
|
||||
tcp: "tcp";
|
||||
http: "http";
|
||||
@@ -1414,10 +1692,21 @@ declare const createServiceWithConfigSchema: z.ZodObject<{
|
||||
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
fqdn: z.ZodString;
|
||||
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
@@ -1594,7 +1883,7 @@ declare const updateServiceConfigSchema: z.ZodObject<{
|
||||
lb_weight: z.ZodOptional<z.ZodNumber>;
|
||||
lb_priority: z.ZodOptional<z.ZodNumber>;
|
||||
domains: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
||||
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_type: z.ZodOptional<z.ZodEnum<{
|
||||
tcp: "tcp";
|
||||
http: "http";
|
||||
@@ -1606,10 +1895,21 @@ declare const updateServiceConfigSchema: z.ZodObject<{
|
||||
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
fqdn: z.ZodString;
|
||||
target_ips: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
||||
@@ -1625,7 +1925,7 @@ declare const updateServiceConfigSchema: z.ZodObject<{
|
||||
}, z.core.$strip>;
|
||||
type UpdateServiceConfigInput = z.infer<typeof updateServiceConfigSchema>;
|
||||
declare const createServiceGroupSchema: z.ZodObject<{
|
||||
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_type: z.ZodOptional<z.ZodEnum<{
|
||||
tcp: "tcp";
|
||||
http: "http";
|
||||
@@ -1637,18 +1937,29 @@ declare const createServiceGroupSchema: z.ZodObject<{
|
||||
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
name: z.ZodString;
|
||||
type: z.ZodDefault<z.ZodEnum<{
|
||||
custom: "custom";
|
||||
vpn: "vpn";
|
||||
network: "network";
|
||||
internet: "internet";
|
||||
bgp: "bgp";
|
||||
custom: "custom";
|
||||
}>>;
|
||||
icon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
domain: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
@@ -1659,7 +1970,7 @@ declare const createServiceGroupSchema: z.ZodObject<{
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
declare const updateServiceGroupSchema: z.ZodObject<{
|
||||
health_check_enabled: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_enabled: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_type: z.ZodOptional<z.ZodEnum<{
|
||||
tcp: "tcp";
|
||||
http: "http";
|
||||
@@ -1671,18 +1982,29 @@ declare const updateServiceGroupSchema: z.ZodObject<{
|
||||
health_check_expected_status: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
||||
health_check_interval_sec: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_timeout_ms: z.ZodOptional<z.ZodNumber>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodBoolean>;
|
||||
health_check_verify_tls: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
|
||||
health_check_provider: z.ZodOptional<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>;
|
||||
health_check_providers: z.ZodOptional<z.ZodPreprocess<z.ZodPipe<z.ZodArray<z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>>, z.ZodTransform<("local" | "cloudflare" | "globalping")[] | readonly ["local"], ("local" | "cloudflare" | "globalping")[]>>>>;
|
||||
health_check_aggregate: z.ZodOptional<z.ZodEnum<{
|
||||
any: "any";
|
||||
all: "all";
|
||||
majority: "majority";
|
||||
}>>;
|
||||
name: z.ZodOptional<z.ZodString>;
|
||||
type: z.ZodOptional<z.ZodEnum<{
|
||||
custom: "custom";
|
||||
vpn: "vpn";
|
||||
network: "network";
|
||||
internet: "internet";
|
||||
bgp: "bgp";
|
||||
custom: "custom";
|
||||
}>>;
|
||||
icon: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
domain: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
@@ -1776,6 +2098,7 @@ declare const originHealthCheckSchema: z.ZodObject<{
|
||||
provider: z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>;
|
||||
cf_healthcheck_id: z.ZodNullable<z.ZodString>;
|
||||
cf_zone_id: z.ZodNullable<z.ZodString>;
|
||||
@@ -1797,6 +2120,7 @@ declare const createOriginHealthCheckSchema: z.ZodObject<{
|
||||
provider: z.ZodEnum<{
|
||||
local: "local";
|
||||
cloudflare: "cloudflare";
|
||||
globalping: "globalping";
|
||||
}>;
|
||||
name: z.ZodString;
|
||||
cf_zone_id: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
||||
@@ -1924,6 +2248,9 @@ declare const appSettingsPatchSchema: z.ZodObject<{
|
||||
healthSuccessRecoveries: z.ZodOptional<z.ZodNumber>;
|
||||
healthWorkerUrl: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodLiteral<"">]>>;
|
||||
healthWorkerToken: z.ZodOptional<z.ZodString>;
|
||||
globalpingToken: z.ZodOptional<z.ZodString>;
|
||||
globalpingLocations: z.ZodOptional<z.ZodString>;
|
||||
globalpingLimit: z.ZodOptional<z.ZodNumber>;
|
||||
}, z.core.$strip>;
|
||||
type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
|
||||
declare const vpsTrackerEventSchema: z.ZodObject<{
|
||||
@@ -2085,4 +2412,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{
|
||||
}, z.core.$strip>;
|
||||
type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>;
|
||||
|
||||
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, HEALTH_KV_CURSOR_KEY, HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, HEALTH_PROBE_BATCH, HEALTH_PROBE_CONCURRENCY, HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthProbeLog, type HealthProbeResultItem, type HealthProbeResultsDoc, type HealthProbeTargetItem, type HealthProbeTargetsDoc, type HealthStatusQuery, type HealthWorkerStatus, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, notificationLogSchema, originHealthCheckSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, toggleServiceIpSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
|
||||
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, HEALTH_CHECK_AGGREGATES, HEALTH_CHECK_PROVIDERS, HEALTH_KV_CURSOR_KEY, HEALTH_KV_RESULTS_KEY, HEALTH_KV_TARGETS_KEY, HEALTH_PROBE_BATCH, HEALTH_PROBE_CONCURRENCY, HEALTH_PROBE_KV_TITLE, HEALTH_PROBE_SCRIPT_NAME, HEALTH_STATUS_PROVIDERS, type HealthCheckAggregate, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthProbeLog, type HealthProbeResultItem, type HealthProbeResultsDoc, type HealthProbeTargetItem, type HealthProbeTargetsDoc, type HealthStatusProvider, type HealthStatusQuery, type HealthWorkerStatus, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceCertificateRow, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, aggregateHealthOk, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, clampGlobalpingLimit, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, derivePrimaryProvider, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckAggregateSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckProvidersSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthProbeLogSchema, healthStatusProviderSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, normalizeProbeProvider, normalizeStatusProvider, notificationLogSchema, originHealthCheckSchema, parseFqdn, parseGlobalpingLocations, parseHealthAggregate, parseHealthProviders, reorderServicesSchema, serializeHealthProviders, serviceBindingSchema, serviceCertificateRowSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, targetHasProvider, targetProviders, toggleEnabledSchema, toggleServiceIpSchema, uniqueHealthProviders, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
|
||||
|
||||
Vendored
+160
-8
@@ -165,6 +165,100 @@ function bindingToFqdn(binding) {
|
||||
|
||||
// src/schemas.ts
|
||||
import { z } from "zod";
|
||||
|
||||
// src/health-providers.ts
|
||||
var HEALTH_CHECK_PROVIDERS = [
|
||||
"local",
|
||||
"cloudflare",
|
||||
"globalping"
|
||||
];
|
||||
var HEALTH_STATUS_PROVIDERS = [
|
||||
...HEALTH_CHECK_PROVIDERS,
|
||||
"aggregate"
|
||||
];
|
||||
var HEALTH_CHECK_AGGREGATES = ["any", "all", "majority"];
|
||||
var PROVIDER_SET = new Set(HEALTH_CHECK_PROVIDERS);
|
||||
var STATUS_SET = new Set(HEALTH_STATUS_PROVIDERS);
|
||||
var AGGREGATE_SET = new Set(HEALTH_CHECK_AGGREGATES);
|
||||
function normalizeProbeProvider(value) {
|
||||
return value === "cloudflare" || value === "globalping" || value === "local" ? value : "local";
|
||||
}
|
||||
function normalizeStatusProvider(value) {
|
||||
if (typeof value === "string" && STATUS_SET.has(value)) {
|
||||
return value;
|
||||
}
|
||||
return "local";
|
||||
}
|
||||
function uniqueHealthProviders(values) {
|
||||
const out = [];
|
||||
for (const value of values) {
|
||||
if (!PROVIDER_SET.has(String(value))) continue;
|
||||
const next = value;
|
||||
if (!out.includes(next)) out.push(next);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function parseHealthProviders(json, fallback) {
|
||||
if (Array.isArray(json)) {
|
||||
const parsed = uniqueHealthProviders(json);
|
||||
if (parsed.length > 0) return parsed;
|
||||
}
|
||||
if (typeof json === "string" && json.trim()) {
|
||||
const trimmed = json.trim();
|
||||
if (trimmed.startsWith("[")) {
|
||||
try {
|
||||
const parsed = uniqueHealthProviders(JSON.parse(trimmed));
|
||||
if (parsed.length > 0) return parsed;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
const one = uniqueHealthProviders(trimmed.split(","));
|
||||
if (one.length > 0) return one;
|
||||
}
|
||||
return [normalizeProbeProvider(fallback)];
|
||||
}
|
||||
function serializeHealthProviders(providers) {
|
||||
const unique = uniqueHealthProviders(providers);
|
||||
return JSON.stringify(unique.length > 0 ? unique : ["local"]);
|
||||
}
|
||||
function parseHealthAggregate(value) {
|
||||
if (typeof value === "string" && AGGREGATE_SET.has(value)) {
|
||||
return value;
|
||||
}
|
||||
return "majority";
|
||||
}
|
||||
function derivePrimaryProvider(providers) {
|
||||
return uniqueHealthProviders(providers)[0] ?? "local";
|
||||
}
|
||||
function targetProviders(target) {
|
||||
if (target.providers && target.providers.length > 0) {
|
||||
return uniqueHealthProviders(target.providers);
|
||||
}
|
||||
return [normalizeProbeProvider(target.provider)];
|
||||
}
|
||||
function targetHasProvider(target, provider) {
|
||||
return targetProviders(target).includes(provider);
|
||||
}
|
||||
function aggregateHealthOk(oks, policy) {
|
||||
const n = oks.length;
|
||||
if (n === 0) return false;
|
||||
const down = oks.filter((ok) => !ok).length;
|
||||
if (policy === "any") return down === 0;
|
||||
if (policy === "all") return down < n;
|
||||
return down < Math.floor(n / 2) + 1;
|
||||
}
|
||||
function clampGlobalpingLimit(value, fallback = 3) {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(10, Math.max(1, Math.trunc(n)));
|
||||
}
|
||||
function parseGlobalpingLocations(value) {
|
||||
const raw = typeof value === "string" ? value : "";
|
||||
const parts = raw.split(",").map((part) => part.trim()).filter(Boolean);
|
||||
return parts.length > 0 ? parts : ["World"];
|
||||
}
|
||||
|
||||
// src/schemas.ts
|
||||
var certMonitoringSchema = z.enum(["auto", "required", "skipped"]);
|
||||
var lbModeSchema = z.enum(["round_robin", "failover", "weighted"]);
|
||||
var healthCheckTypeSchema = z.enum(["tcp", "http", "ping", "dns"]);
|
||||
@@ -179,7 +273,19 @@ var nodeHealthStateSchema = z.enum([
|
||||
"unhealthy",
|
||||
"disabled"
|
||||
]);
|
||||
var healthCheckProviderSchema = z.enum(["local", "cloudflare"]);
|
||||
var healthCheckProviderSchema = z.enum(HEALTH_CHECK_PROVIDERS);
|
||||
var healthStatusProviderSchema = z.enum(HEALTH_STATUS_PROVIDERS);
|
||||
var healthCheckAggregateSchema = z.enum(HEALTH_CHECK_AGGREGATES);
|
||||
var healthCheckProvidersSchema = z.preprocess(
|
||||
(value) => {
|
||||
if (value === void 0) return void 0;
|
||||
return Array.isArray(value) ? value : parseHealthProviders(value);
|
||||
},
|
||||
z.array(healthCheckProviderSchema).min(1).transform((arr) => {
|
||||
const unique = uniqueHealthProviders(arr);
|
||||
return unique.length > 0 ? unique : ["local"];
|
||||
})
|
||||
);
|
||||
var healthCheckScopeSchema = z.enum(["binding", "group"]);
|
||||
var ipHealthStatusSchema = z.object({
|
||||
scope: healthCheckScopeSchema,
|
||||
@@ -192,7 +298,7 @@ var ipHealthStatusSchema = z.object({
|
||||
last_checked_at: z.string().nullable(),
|
||||
last_error: z.string().nullable(),
|
||||
colo: z.string().nullable().optional(),
|
||||
provider: healthCheckProviderSchema.optional()
|
||||
provider: healthStatusProviderSchema.optional()
|
||||
});
|
||||
var serviceIpHealthSchema = z.object({
|
||||
ip: z.string(),
|
||||
@@ -200,7 +306,7 @@ var serviceIpHealthSchema = z.object({
|
||||
latency_ms: z.number().nullable(),
|
||||
last_checked_at: z.string().nullable().optional(),
|
||||
last_error: z.string().nullable().optional(),
|
||||
provider: healthCheckProviderSchema.optional(),
|
||||
provider: healthStatusProviderSchema.optional(),
|
||||
colo: z.string().nullable().optional()
|
||||
});
|
||||
var healthProbeLogSchema = z.object({
|
||||
@@ -250,6 +356,8 @@ var serviceGroupSchema = z.object({
|
||||
health_check_timeout_ms: z.number().default(3e3),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: healthCheckProviderSchema.catch("local"),
|
||||
health_check_providers: healthCheckProvidersSchema.catch(["local"]),
|
||||
health_check_aggregate: healthCheckAggregateSchema.catch("majority"),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string()
|
||||
});
|
||||
@@ -288,6 +396,9 @@ var serviceDomainBindingSchema = z.object({
|
||||
health_check_timeout_ms: z.number().default(3e3),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: healthCheckProviderSchema.catch("local"),
|
||||
health_check_providers: healthCheckProvidersSchema.catch(["local"]),
|
||||
health_check_aggregate: healthCheckAggregateSchema.catch("majority"),
|
||||
cert_monitoring: certMonitoringSchema.default("auto"),
|
||||
sync_status: z.string().nullable().default(null)
|
||||
}).transform((binding) => ({
|
||||
...binding,
|
||||
@@ -305,7 +416,9 @@ var serviceViewSchema = serviceSchema.extend({
|
||||
health_status: ipHealthStateSchema.default("unknown"),
|
||||
health_latency_ms: z.number().nullable().default(null),
|
||||
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||
ip_enabled: z.record(z.string(), z.boolean()).default({})
|
||||
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
||||
lb_mode: lbModeSchema.catch("round_robin"),
|
||||
active_ips: z.array(z.string()).default([])
|
||||
});
|
||||
var serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||
services: z.array(serviceViewSchema).default([]),
|
||||
@@ -359,6 +472,7 @@ var serviceBindingSchema = z.object({
|
||||
health_check_interval_sec: z.number().default(30),
|
||||
health_check_timeout_ms: z.number().default(3e3),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
cert_monitoring: certMonitoringSchema.default("auto"),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string()
|
||||
@@ -388,6 +502,8 @@ var certificateSchema = z.object({
|
||||
id: z.number(),
|
||||
domain_id: z.number(),
|
||||
subdomain_id: z.number().nullable(),
|
||||
service_id: z.number().nullable().optional().default(null),
|
||||
service_name: z.string().nullable().optional().default(null),
|
||||
hostname: z.string(),
|
||||
expires_at: z.string().nullable(),
|
||||
last_checked_at: z.string().nullable(),
|
||||
@@ -396,6 +512,18 @@ var certificateSchema = z.object({
|
||||
created_at: z.string(),
|
||||
updated_at: z.string()
|
||||
});
|
||||
var serviceCertificateRowSchema = z.object({
|
||||
binding_id: z.number(),
|
||||
domain_id: z.number(),
|
||||
service_id: z.number(),
|
||||
hostname: z.string(),
|
||||
cert_monitoring: certMonitoringSchema,
|
||||
id: z.number().nullable(),
|
||||
status: z.string(),
|
||||
expires_at: z.string().nullable(),
|
||||
last_checked_at: z.string().nullable(),
|
||||
last_error: z.string().nullable()
|
||||
});
|
||||
var createGroupSchema = z.object({
|
||||
name: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 \u043D\u0430\u0437\u0432\u0430\u043D\u0438\u0435"),
|
||||
slug: z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 slug")
|
||||
@@ -406,15 +534,17 @@ var ipv4Schema = z.string().regex(
|
||||
);
|
||||
var nodeAddressSchema = z.string().min(1, "\u0423\u043A\u0430\u0436\u0438\u0442\u0435 IP \u0438\u043B\u0438 hostname").max(255);
|
||||
var healthCheckConfigFields = {
|
||||
health_check_enabled: z.boolean().optional(),
|
||||
health_check_enabled: z.coerce.boolean().optional(),
|
||||
health_check_type: healthCheckTypeSchema.optional(),
|
||||
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
|
||||
health_check_path: z.string().nullable().optional(),
|
||||
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
|
||||
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
|
||||
health_check_timeout_ms: z.number().int().min(100).max(3e4).optional(),
|
||||
health_check_verify_tls: z.boolean().optional(),
|
||||
health_check_provider: healthCheckProviderSchema.optional()
|
||||
health_check_verify_tls: z.coerce.boolean().optional(),
|
||||
health_check_provider: healthCheckProviderSchema.optional(),
|
||||
health_check_providers: healthCheckProvidersSchema.optional(),
|
||||
health_check_aggregate: healthCheckAggregateSchema.optional()
|
||||
};
|
||||
var healthCheckConfigSchema = z.object(healthCheckConfigFields);
|
||||
var serviceDomainInputSchema = z.object({
|
||||
@@ -727,7 +857,10 @@ var appSettingsPatchSchema = z3.object({
|
||||
healthLatencyWarnMs: z3.number().int().min(50).max(6e4).optional(),
|
||||
healthSuccessRecoveries: z3.number().int().min(1).max(20).optional(),
|
||||
healthWorkerUrl: z3.string().url().or(z3.literal("")).optional(),
|
||||
healthWorkerToken: z3.string().optional()
|
||||
healthWorkerToken: z3.string().optional(),
|
||||
globalpingToken: z3.string().optional(),
|
||||
globalpingLocations: z3.string().trim().max(200).optional(),
|
||||
globalpingLimit: z3.number().int().min(1).max(10).optional()
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.healthDegradedFailures != null && data.healthDownFailures != null && data.healthDownFailures < data.healthDegradedFailures) {
|
||||
ctx.addIssue({
|
||||
@@ -829,6 +962,8 @@ export {
|
||||
CERT_OK,
|
||||
CERT_UNKNOWN,
|
||||
CERT_WARNING,
|
||||
HEALTH_CHECK_AGGREGATES,
|
||||
HEALTH_CHECK_PROVIDERS,
|
||||
HEALTH_KV_CURSOR_KEY,
|
||||
HEALTH_KV_RESULTS_KEY,
|
||||
HEALTH_KV_TARGETS_KEY,
|
||||
@@ -836,12 +971,14 @@ export {
|
||||
HEALTH_PROBE_CONCURRENCY,
|
||||
HEALTH_PROBE_KV_TITLE,
|
||||
HEALTH_PROBE_SCRIPT_NAME,
|
||||
HEALTH_STATUS_PROVIDERS,
|
||||
SYNC_CONFLICT,
|
||||
SYNC_ERROR,
|
||||
SYNC_PENDING_DELETE,
|
||||
SYNC_PENDING_PUSH,
|
||||
SYNC_SYNCED,
|
||||
ValidationError,
|
||||
aggregateHealthOk,
|
||||
appSettingsPatchSchema,
|
||||
appSwitcherConfigSchema,
|
||||
appSwitcherEntrySchema,
|
||||
@@ -860,6 +997,7 @@ export {
|
||||
cfdmSyncBindingsBodySchema,
|
||||
changeDomainSchema,
|
||||
changeIpSchema,
|
||||
clampGlobalpingLimit,
|
||||
createDnsRecordSchema,
|
||||
createDomainMonitorSchema,
|
||||
createDomainSchema,
|
||||
@@ -871,6 +1009,7 @@ export {
|
||||
createServiceSchema,
|
||||
createServiceWithConfigSchema,
|
||||
createSubdomainSchema,
|
||||
derivePrimaryProvider,
|
||||
dnsNameToSubdomainLabel,
|
||||
dnsRecordNamesMatch,
|
||||
dnsRecordSchema,
|
||||
@@ -883,11 +1022,14 @@ export {
|
||||
fqdnToDisplay,
|
||||
groupSchema,
|
||||
groupWithStatsSchema,
|
||||
healthCheckAggregateSchema,
|
||||
healthCheckConfigSchema,
|
||||
healthCheckProviderSchema,
|
||||
healthCheckProvidersSchema,
|
||||
healthCheckScopeSchema,
|
||||
healthCheckTypeSchema,
|
||||
healthProbeLogSchema,
|
||||
healthStatusProviderSchema,
|
||||
healthStatusQuerySchema,
|
||||
ingestAuditEventSchema,
|
||||
ipHealthStateSchema,
|
||||
@@ -898,11 +1040,18 @@ export {
|
||||
loginSchema,
|
||||
nodeHealthStateSchema,
|
||||
normalizeDnsRecordName,
|
||||
normalizeProbeProvider,
|
||||
normalizeStatusProvider,
|
||||
notificationLogSchema,
|
||||
originHealthCheckSchema,
|
||||
parseFqdn,
|
||||
parseGlobalpingLocations,
|
||||
parseHealthAggregate,
|
||||
parseHealthProviders,
|
||||
reorderServicesSchema,
|
||||
serializeHealthProviders,
|
||||
serviceBindingSchema,
|
||||
serviceCertificateRowSchema,
|
||||
serviceDomainBindingSchema,
|
||||
serviceGroupSchema,
|
||||
serviceGroupTypeSchema,
|
||||
@@ -915,8 +1064,11 @@ export {
|
||||
shouldMonitorService,
|
||||
subdomainLabelToFqdn,
|
||||
subdomainSchema,
|
||||
targetHasProvider,
|
||||
targetProviders,
|
||||
toggleEnabledSchema,
|
||||
toggleServiceIpSchema,
|
||||
uniqueHealthProviders,
|
||||
updateDomainGroupSchema,
|
||||
updateDomainSchema,
|
||||
updateServiceConfigSchema,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
export const HEALTH_CHECK_PROVIDERS = [
|
||||
"local",
|
||||
"cloudflare",
|
||||
"globalping",
|
||||
] as const;
|
||||
|
||||
export type HealthCheckProvider = (typeof HEALTH_CHECK_PROVIDERS)[number];
|
||||
|
||||
export const HEALTH_STATUS_PROVIDERS = [
|
||||
...HEALTH_CHECK_PROVIDERS,
|
||||
"aggregate",
|
||||
] as const;
|
||||
|
||||
export type HealthStatusProvider = (typeof HEALTH_STATUS_PROVIDERS)[number];
|
||||
|
||||
export const HEALTH_CHECK_AGGREGATES = ["any", "all", "majority"] as const;
|
||||
|
||||
export type HealthCheckAggregate = (typeof HEALTH_CHECK_AGGREGATES)[number];
|
||||
|
||||
const PROVIDER_SET = new Set<string>(HEALTH_CHECK_PROVIDERS);
|
||||
const STATUS_SET = new Set<string>(HEALTH_STATUS_PROVIDERS);
|
||||
const AGGREGATE_SET = new Set<string>(HEALTH_CHECK_AGGREGATES);
|
||||
|
||||
export function normalizeProbeProvider(value: unknown): HealthCheckProvider {
|
||||
return value === "cloudflare" || value === "globalping" || value === "local"
|
||||
? value
|
||||
: "local";
|
||||
}
|
||||
|
||||
export function normalizeStatusProvider(value: unknown): HealthStatusProvider {
|
||||
if (typeof value === "string" && STATUS_SET.has(value)) {
|
||||
return value as HealthStatusProvider;
|
||||
}
|
||||
return "local";
|
||||
}
|
||||
|
||||
export function uniqueHealthProviders(
|
||||
values: readonly unknown[],
|
||||
): HealthCheckProvider[] {
|
||||
const out: HealthCheckProvider[] = [];
|
||||
for (const value of values) {
|
||||
if (!PROVIDER_SET.has(String(value))) continue;
|
||||
const next = value as HealthCheckProvider;
|
||||
if (!out.includes(next)) out.push(next);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseHealthProviders(
|
||||
json: unknown,
|
||||
fallback?: unknown,
|
||||
): HealthCheckProvider[] {
|
||||
if (Array.isArray(json)) {
|
||||
const parsed = uniqueHealthProviders(json);
|
||||
if (parsed.length > 0) return parsed;
|
||||
}
|
||||
if (typeof json === "string" && json.trim()) {
|
||||
const trimmed = json.trim();
|
||||
if (trimmed.startsWith("[")) {
|
||||
try {
|
||||
const parsed = uniqueHealthProviders(JSON.parse(trimmed) as unknown[]);
|
||||
if (parsed.length > 0) return parsed;
|
||||
} catch {
|
||||
// fall through to single-provider
|
||||
}
|
||||
}
|
||||
const one = uniqueHealthProviders(trimmed.split(","));
|
||||
if (one.length > 0) return one;
|
||||
}
|
||||
return [normalizeProbeProvider(fallback)];
|
||||
}
|
||||
|
||||
export function serializeHealthProviders(
|
||||
providers: readonly HealthCheckProvider[],
|
||||
): string {
|
||||
const unique = uniqueHealthProviders(providers);
|
||||
return JSON.stringify(unique.length > 0 ? unique : ["local"]);
|
||||
}
|
||||
|
||||
export function parseHealthAggregate(value: unknown): HealthCheckAggregate {
|
||||
if (typeof value === "string" && AGGREGATE_SET.has(value)) {
|
||||
return value as HealthCheckAggregate;
|
||||
}
|
||||
return "majority";
|
||||
}
|
||||
|
||||
export function derivePrimaryProvider(
|
||||
providers: readonly HealthCheckProvider[],
|
||||
): HealthCheckProvider {
|
||||
return uniqueHealthProviders(providers)[0] ?? "local";
|
||||
}
|
||||
|
||||
export function targetProviders(target: {
|
||||
providers?: readonly HealthCheckProvider[] | null;
|
||||
provider?: HealthCheckProvider | null;
|
||||
}): HealthCheckProvider[] {
|
||||
if (target.providers && target.providers.length > 0) {
|
||||
return uniqueHealthProviders(target.providers);
|
||||
}
|
||||
return [normalizeProbeProvider(target.provider)];
|
||||
}
|
||||
|
||||
export function targetHasProvider(
|
||||
target: {
|
||||
providers?: readonly HealthCheckProvider[] | null;
|
||||
provider?: HealthCheckProvider | null;
|
||||
},
|
||||
provider: HealthCheckProvider,
|
||||
): boolean {
|
||||
return targetProviders(target).includes(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* any — Down if at least one source is Down (ok only if all ok).
|
||||
* all — Down only if every source is Down (ok if any ok).
|
||||
* majority — Down if a strict majority of sources are Down (2 → both, 3 → ≥2).
|
||||
*/
|
||||
export function aggregateHealthOk(
|
||||
oks: readonly boolean[],
|
||||
policy: HealthCheckAggregate,
|
||||
): boolean {
|
||||
const n = oks.length;
|
||||
if (n === 0) return false;
|
||||
const down = oks.filter((ok) => !ok).length;
|
||||
if (policy === "any") return down === 0;
|
||||
if (policy === "all") return down < n;
|
||||
return down < Math.floor(n / 2) + 1;
|
||||
}
|
||||
|
||||
export function clampGlobalpingLimit(value: unknown, fallback = 3): number {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
return Math.min(10, Math.max(1, Math.trunc(n)));
|
||||
}
|
||||
|
||||
export function parseGlobalpingLocations(value: unknown): string[] {
|
||||
const raw = typeof value === "string" ? value : "";
|
||||
const parts = raw
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? parts : ["World"];
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export * from "./schemas.js";
|
||||
export * from "./app-switcher.js";
|
||||
export * from "./integration-vps-tracker.js";
|
||||
export * from "./health-probe-mailbox.js";
|
||||
export * from "./health-providers.js";
|
||||
export * from "./audit.js";
|
||||
export type {
|
||||
CfZone,
|
||||
@@ -23,7 +24,6 @@ export type {
|
||||
HealthCheckType,
|
||||
IpHealthState,
|
||||
NodeHealthState,
|
||||
HealthCheckProvider,
|
||||
HealthCheckScope,
|
||||
IpHealthStatus,
|
||||
HealthCheckTarget,
|
||||
|
||||
@@ -37,6 +37,9 @@ export const appSettingsPatchSchema = z.object({
|
||||
healthSuccessRecoveries: z.number().int().min(1).max(20).optional(),
|
||||
healthWorkerUrl: z.string().url().or(z.literal("")).optional(),
|
||||
healthWorkerToken: z.string().optional(),
|
||||
globalpingToken: z.string().optional(),
|
||||
globalpingLocations: z.string().trim().max(200).optional(),
|
||||
globalpingLimit: z.number().int().min(1).max(10).optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (
|
||||
data.healthDegradedFailures != null &&
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
HEALTH_CHECK_AGGREGATES,
|
||||
HEALTH_CHECK_PROVIDERS,
|
||||
HEALTH_STATUS_PROVIDERS,
|
||||
parseHealthProviders,
|
||||
uniqueHealthProviders,
|
||||
} from './health-providers.js'
|
||||
|
||||
export const certMonitoringSchema = z.enum(['auto', 'required', 'skipped'])
|
||||
|
||||
@@ -29,8 +36,22 @@ export const nodeHealthStateSchema = z.enum([
|
||||
])
|
||||
export type NodeHealthState = z.infer<typeof nodeHealthStateSchema>
|
||||
|
||||
export const healthCheckProviderSchema = z.enum(['local', 'cloudflare'])
|
||||
export type HealthCheckProvider = z.infer<typeof healthCheckProviderSchema>
|
||||
export const healthCheckProviderSchema = z.enum(HEALTH_CHECK_PROVIDERS)
|
||||
|
||||
export const healthStatusProviderSchema = z.enum(HEALTH_STATUS_PROVIDERS)
|
||||
|
||||
export const healthCheckAggregateSchema = z.enum(HEALTH_CHECK_AGGREGATES)
|
||||
|
||||
export const healthCheckProvidersSchema = z.preprocess(
|
||||
(value) => {
|
||||
if (value === undefined) return undefined
|
||||
return Array.isArray(value) ? value : parseHealthProviders(value)
|
||||
},
|
||||
z.array(healthCheckProviderSchema).min(1).transform((arr) => {
|
||||
const unique = uniqueHealthProviders(arr)
|
||||
return unique.length > 0 ? unique : (['local'] as const)
|
||||
}),
|
||||
)
|
||||
|
||||
export const healthCheckScopeSchema = z.enum(['binding', 'group'])
|
||||
export type HealthCheckScope = z.infer<typeof healthCheckScopeSchema>
|
||||
@@ -46,7 +67,7 @@ export const ipHealthStatusSchema = z.object({
|
||||
last_checked_at: z.string().nullable(),
|
||||
last_error: z.string().nullable(),
|
||||
colo: z.string().nullable().optional(),
|
||||
provider: healthCheckProviderSchema.optional(),
|
||||
provider: healthStatusProviderSchema.optional(),
|
||||
})
|
||||
|
||||
export type IpHealthStatus = z.infer<typeof ipHealthStatusSchema>
|
||||
@@ -57,7 +78,7 @@ export const serviceIpHealthSchema = z.object({
|
||||
latency_ms: z.number().nullable(),
|
||||
last_checked_at: z.string().nullable().optional(),
|
||||
last_error: z.string().nullable().optional(),
|
||||
provider: healthCheckProviderSchema.optional(),
|
||||
provider: healthStatusProviderSchema.optional(),
|
||||
colo: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
@@ -116,6 +137,8 @@ export const serviceGroupSchema = z.object({
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: healthCheckProviderSchema.catch('local'),
|
||||
health_check_providers: healthCheckProvidersSchema.catch(['local']),
|
||||
health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
@@ -157,6 +180,9 @@ export const serviceDomainBindingSchema = z
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
health_check_provider: healthCheckProviderSchema.catch('local'),
|
||||
health_check_providers: healthCheckProvidersSchema.catch(['local']),
|
||||
health_check_aggregate: healthCheckAggregateSchema.catch('majority'),
|
||||
cert_monitoring: certMonitoringSchema.default('auto'),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
})
|
||||
.transform((binding) => ({
|
||||
@@ -184,6 +210,8 @@ export const serviceViewSchema = serviceSchema.extend({
|
||||
health_latency_ms: z.number().nullable().default(null),
|
||||
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
||||
lb_mode: lbModeSchema.catch('round_robin'),
|
||||
active_ips: z.array(z.string()).default([]),
|
||||
})
|
||||
|
||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||
@@ -243,6 +271,7 @@ export const serviceBindingSchema = z
|
||||
health_check_interval_sec: z.number().default(30),
|
||||
health_check_timeout_ms: z.number().default(3000),
|
||||
health_check_verify_tls: z.coerce.boolean().default(false),
|
||||
cert_monitoring: certMonitoringSchema.default('auto'),
|
||||
sync_status: z.string().nullable().default(null),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
@@ -280,6 +309,8 @@ export const certificateSchema = z.object({
|
||||
id: z.number(),
|
||||
domain_id: z.number(),
|
||||
subdomain_id: z.number().nullable(),
|
||||
service_id: z.number().nullable().optional().default(null),
|
||||
service_name: z.string().nullable().optional().default(null),
|
||||
hostname: z.string(),
|
||||
expires_at: z.string().nullable(),
|
||||
last_checked_at: z.string().nullable(),
|
||||
@@ -289,6 +320,19 @@ export const certificateSchema = z.object({
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const serviceCertificateRowSchema = z.object({
|
||||
binding_id: z.number(),
|
||||
domain_id: z.number(),
|
||||
service_id: z.number(),
|
||||
hostname: z.string(),
|
||||
cert_monitoring: certMonitoringSchema,
|
||||
id: z.number().nullable(),
|
||||
status: z.string(),
|
||||
expires_at: z.string().nullable(),
|
||||
last_checked_at: z.string().nullable(),
|
||||
last_error: z.string().nullable(),
|
||||
})
|
||||
|
||||
export type Group = z.infer<typeof groupSchema>
|
||||
export type GroupWithStats = z.infer<typeof groupWithStatsSchema>
|
||||
export type Service = z.infer<typeof serviceSchema>
|
||||
@@ -301,6 +345,7 @@ export type Domain = z.infer<typeof domainSchema>
|
||||
export type DomainListItem = z.infer<typeof domainListItemSchema>
|
||||
export type DnsRecord = z.infer<typeof dnsRecordSchema>
|
||||
export type Certificate = z.infer<typeof certificateSchema>
|
||||
export type ServiceCertificateRow = z.infer<typeof serviceCertificateRowSchema>
|
||||
|
||||
export const createGroupSchema = z.object({
|
||||
name: z.string().min(1, 'Укажите название'),
|
||||
@@ -320,15 +365,17 @@ const nodeAddressSchema = z
|
||||
.max(255)
|
||||
|
||||
const healthCheckConfigFields = {
|
||||
health_check_enabled: z.boolean().optional(),
|
||||
health_check_enabled: z.coerce.boolean().optional(),
|
||||
health_check_type: healthCheckTypeSchema.optional(),
|
||||
health_check_port: z.number().int().min(1).max(65535).nullable().optional(),
|
||||
health_check_path: z.string().nullable().optional(),
|
||||
health_check_expected_status: z.number().int().min(100).max(599).nullable().optional(),
|
||||
health_check_interval_sec: z.number().int().min(5).max(3600).optional(),
|
||||
health_check_timeout_ms: z.number().int().min(100).max(30000).optional(),
|
||||
health_check_verify_tls: z.boolean().optional(),
|
||||
health_check_verify_tls: z.coerce.boolean().optional(),
|
||||
health_check_provider: healthCheckProviderSchema.optional(),
|
||||
health_check_providers: healthCheckProvidersSchema.optional(),
|
||||
health_check_aggregate: healthCheckAggregateSchema.optional(),
|
||||
}
|
||||
|
||||
export const healthCheckConfigSchema = z.object(healthCheckConfigFields)
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import type {
|
||||
HealthCheckProvider,
|
||||
HealthCheckAggregate,
|
||||
HealthStatusProvider,
|
||||
} from "./health-providers.js";
|
||||
|
||||
export type {
|
||||
HealthCheckProvider,
|
||||
HealthCheckAggregate,
|
||||
HealthStatusProvider,
|
||||
} from "./health-providers.js";
|
||||
|
||||
export interface Group {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -23,6 +35,8 @@ export interface ServiceGroup {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -97,6 +111,8 @@ export interface Certificate {
|
||||
id: number;
|
||||
domain_id: number;
|
||||
subdomain_id: number | null;
|
||||
service_id: number | null;
|
||||
service_name: string | null;
|
||||
hostname: string;
|
||||
expires_at: string | null;
|
||||
last_checked_at: string | null;
|
||||
@@ -123,6 +139,9 @@ export interface ServiceBinding {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
cert_monitoring: string;
|
||||
routing_strategy: LbMode;
|
||||
operation_version: number;
|
||||
created_at: string;
|
||||
@@ -155,6 +174,9 @@ export interface ServiceBindingView {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
cert_monitoring: string;
|
||||
sync_status: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -181,6 +203,9 @@ export interface ServiceDomainBindingView {
|
||||
health_check_timeout_ms: number;
|
||||
health_check_verify_tls: boolean;
|
||||
health_check_provider: HealthCheckProvider;
|
||||
health_check_providers: HealthCheckProvider[];
|
||||
health_check_aggregate: HealthCheckAggregate;
|
||||
cert_monitoring: string;
|
||||
sync_status: string | null;
|
||||
}
|
||||
|
||||
@@ -202,6 +227,8 @@ export interface ServiceView {
|
||||
health_latency_ms: number | null;
|
||||
ip_health: ServiceIpHealth[];
|
||||
ip_enabled: Record<string, boolean>;
|
||||
lb_mode: LbMode;
|
||||
active_ips: string[];
|
||||
}
|
||||
|
||||
export interface GroupWithStats extends Group {
|
||||
@@ -284,8 +311,6 @@ export type NodeHealthState =
|
||||
| "unhealthy"
|
||||
| "disabled";
|
||||
|
||||
export type HealthCheckProvider = "local" | "cloudflare";
|
||||
|
||||
export type HealthCheckScope = "binding" | "group";
|
||||
|
||||
export interface IpHealthStatus {
|
||||
@@ -299,7 +324,7 @@ export interface IpHealthStatus {
|
||||
last_checked_at: string | null;
|
||||
last_error: string | null;
|
||||
colo?: string | null;
|
||||
provider?: HealthCheckProvider;
|
||||
provider?: HealthStatusProvider;
|
||||
}
|
||||
|
||||
export interface ServiceIpHealth {
|
||||
@@ -308,7 +333,7 @@ export interface ServiceIpHealth {
|
||||
latency_ms: number | null;
|
||||
last_checked_at?: string | null;
|
||||
last_error?: string | null;
|
||||
provider?: HealthCheckProvider;
|
||||
provider?: HealthStatusProvider;
|
||||
colo?: string | null;
|
||||
}
|
||||
|
||||
@@ -394,4 +419,6 @@ export interface HealthCheckTarget {
|
||||
timeout_ms: number;
|
||||
verify_tls: boolean;
|
||||
provider: HealthCheckProvider;
|
||||
providers?: HealthCheckProvider[];
|
||||
aggregate?: HealthCheckAggregate;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
import { toggleVariants } from "@cfdm/ui/components/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 2,
|
||||
orientation: "horizontal",
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 2,
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
...props
|
||||
}: ToggleGroupPrimitive.Props &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
data-orientation={orientation}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider
|
||||
value={{ variant, size, spacing, orientation }}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TogglePrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@cfdm/ui/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-muted",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
Reference in New Issue
Block a user