Compare commits

...
5 Commits
Author SHA1 Message Date
Denozordec d063323402 feat(services): add load balancing mode and active IPs to service view
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 7s
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m15s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 2m17s
CD / publish (push) Successful in 1m41s
- Introduced `lb_mode` to specify the load balancing strategy for services, supporting options like round robin, failover, and weighted.
- Added `active_ips` to track currently active IPs for each service, enhancing visibility into service health and configuration.
- Updated relevant components to display load balancing mode and active IPs, improving user experience and service management capabilities.
2026-08-19 23:00:21 +07:00
Denozordec 6bced71037 feat(ui): refactor ServiceIpList and ServiceUnitCard components for improved layout and functionality
quality / commitlint (push) Skipped
quality / changes (push) Successful in 10s
quality / api (push) Skipped
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 4s
quality / web (push) Successful in 53s
CD / quality (push) Successful in 1m9s
CD / publish (push) Successful in 1m37s
- Replaced div elements with Item components in ServiceIpList for better alignment and structure.
- Introduced alignWithMenu prop to align IP Switch with the card menu.
- Updated ServiceUnitCard to utilize Item components, enhancing the visual consistency and responsiveness of the service card layout.
- Improved overall user experience by streamlining component structure and ensuring proper alignment of UI elements.
2026-08-19 22:37:54 +07:00
DenozordecandCursor 44d0eb0114 ci: учитывать несколько коммитов в одном пуше при detect paths
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 51s
quality / api (push) Successful in 45s
CD / quality (push) Successful in 1m47s
CD / publish (push) Successful in 1m37s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 22:09:14 +07:00
DenozordecandCursor 9f00dfcf84 chore(reui): обновить ReUI agent skill до актуальной версии
CD / update-wiki (push) Successful in 4s
quality / commitlint (push) Skipped
quality / changes (push) Failing after 7s
quality / web (push) Skipped
quality / api (push) Skipped
quality / docker-check (push) Skipped
CD / quality (push) Failing after 9s
CD / publish (push) Skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 20:04:19 +07:00
DenozordecandCursor 9b9dcc3b12 fix(ui): выровнять health-check плитки и тип TCP/HTTP под ReUI
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 20:04:09 +07:00
21 changed files with 568 additions and 224 deletions

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
--- ---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components # ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters ## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange` **Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:** **Shape:**
```tsx ```tsx
const [filters, setFilters] = useState<Filter[]>([ const fields: FilterField[] = [
createFilter("priority", "is_any_of", ["low"]), { id: "title", label: "Title", type: "text" },
]) {
const fields: FilterFieldConfig[] = [ id: "status",
{ key: "priority", label: "Priority", type: "multiselect", label: "Status",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] }, type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
] ]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} /> <Filters fields={fields} query={query} onQueryChange={setQuery} />
``` ```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`. **Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector ## date-selector
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
--- ---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components # ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters ## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange` **Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:** **Shape:**
```tsx ```tsx
const [filters, setFilters] = useState<Filter[]>([ const fields: FilterField[] = [
createFilter("priority", "is_any_of", ["low"]), { id: "title", label: "Title", type: "text" },
]) {
const fields: FilterFieldConfig[] = [ id: "status",
{ key: "priority", label: "Priority", type: "multiselect", label: "Status",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] }, type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
] ]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} /> <Filters fields={fields} query={query} onQueryChange={setQuery} />
``` ```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`. **Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector ## date-selector
+4 -4
View File
@@ -6,18 +6,18 @@ alwaysApply: false
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
--- ---
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
--- ---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components # ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters ## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange` **Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:** **Shape:**
```tsx ```tsx
const [filters, setFilters] = useState<Filter[]>([ const fields: FilterField[] = [
createFilter("priority", "is_any_of", ["low"]), { id: "title", label: "Title", type: "text" },
]) {
const fields: FilterFieldConfig[] = [ id: "status",
{ key: "priority", label: "Priority", type: "multiselect", label: "Status",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] }, type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
] ]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} /> <Filters fields={fields} query={query} onQueryChange={setQuery} />
``` ```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`. **Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector ## date-selector
+8 -7
View File
@@ -38,14 +38,11 @@ jobs:
api: ${{ steps.detect.outputs.api }} api: ${{ steps.detect.outputs.api }}
docker: ${{ steps.detect.outputs.docker }} docker: ${{ steps.detect.outputs.docker }}
steps: steps:
- if: ${{ inputs.is_pull_request }} - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
# Push may contain several commits; github.event.before is then
# more than one parent away. fetch-depth: 2 only has HEAD~1.
fetch-depth: 0 fetch-depth: 0
- if: ${{ inputs.is_pull_request == false }}
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 2
- id: detect - id: detect
name: Detect changed paths per module name: Detect changed paths per module
env: env:
@@ -72,12 +69,16 @@ jobs:
done done
} }
has_commit() {
git cat-file -e "${1}^{commit}" 2>/dev/null
}
if [ "$IS_PR" = "true" ]; then if [ "$IS_PR" = "true" ]; then
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")" FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
else else
after="${HEAD_SHA:-$(git rev-parse HEAD)}" after="${HEAD_SHA:-$(git rev-parse HEAD)}"
before="$BEFORE_SHA" before="$BEFORE_SHA"
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ] && has_commit "$before"; then
FILES="$(git diff --name-only "$before" "$after")" FILES="$(git diff --name-only "$before" "$after")"
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
FILES="$(git diff --name-only HEAD~1 HEAD)" FILES="$(git diff --name-only HEAD~1 HEAD)"
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
--- ---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components # ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters ## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange` **Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:** **Shape:**
```tsx ```tsx
const [filters, setFilters] = useState<Filter[]>([ const fields: FilterField[] = [
createFilter("priority", "is_any_of", ["low"]), { id: "title", label: "Title", type: "text" },
]) {
const fields: FilterFieldConfig[] = [ id: "status",
{ key: "priority", label: "Priority", type: "multiselect", label: "Status",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] }, type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
] ]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} /> <Filters fields={fields} query={query} onQueryChange={setQuery} />
``` ```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`. **Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector ## date-selector
@@ -312,6 +312,14 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
}; };
}); });
const activeIps = new Set<string>();
for (const binding of bindings) {
const { config, rows } = getBindingLbState(db, binding.id);
for (const ip of selectActiveIpsByMode(config, rows)) {
activeIps.add(ip);
}
}
return { return {
id: service.id, id: service.id,
name: service.name, name: service.name,
@@ -330,6 +338,8 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
health_status: "unknown", health_status: "unknown",
health_latency_ms: null, health_latency_ms: null,
ip_health: [], ip_health: [],
lb_mode: bindings[0]?.lb_mode ?? "round_robin",
active_ips: [...activeIps],
}; };
} }
+6 -5
View File
@@ -123,11 +123,12 @@ describe("create service then list groups", () => {
expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe( expect(httpParsed.success, JSON.stringify(httpParsed.error?.issues)).toBe(
true, true,
); );
expect( const listed = httpParsed.data!.groups
httpParsed.data!.groups .find((g) => g.id === group.id)
.find((g) => g.id === group.id) ?.services.find((s) => s.id === created.id);
?.services.some((s) => s.id === created.id), expect(listed).toBeDefined();
).toBe(true); expect(listed?.lb_mode).toBe("round_robin");
expect(listed?.active_ips).toEqual(["1.2.3.4"]);
await app.close(); await app.close();
}); });
@@ -18,7 +18,9 @@ import {
} from '@cfdm/ui/components/select' } from '@cfdm/ui/components/select'
import { Switch } from '@cfdm/ui/components/switch' import { Switch } from '@cfdm/ui/components/switch'
import { FieldGroup } from '@cfdm/ui/components/field' import { FieldGroup } from '@cfdm/ui/components/field'
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group' import { Button } from '@cfdm/ui/components/button'
import { ButtonGroup } from '@cfdm/ui/components/button-group'
import { CableIcon, GlobeIcon } from 'lucide-react'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
import { import {
HealthAggregateTiles, HealthAggregateTiles,
@@ -215,28 +217,30 @@ export function HealthCheckConfigFields({
<div className="flex flex-col gap-3 pt-1 pb-1"> <div className="flex flex-col gap-3 pt-1 pb-1">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}> <FormFieldSimple label="Тип" htmlFor={`${idPrefix}-type`}>
<ToggleGroup <ButtonGroup id={`${idPrefix}-type`} className="w-full min-w-0">
id={`${idPrefix}-type`} <Button
variant="outline" type="button"
size="sm" size="sm"
spacing={0} variant={value.type === 'tcp' ? 'secondary' : 'outline'}
multiple={false} className="flex-1"
className="w-full min-w-0" aria-pressed={value.type === 'tcp'}
value={[value.type]} onClick={() => patch({ type: 'tcp' })}
onValueChange={(next) => { >
const picked = next[0] <CableIcon data-icon="inline-start" />
if (picked === 'tcp' || picked === 'http') {
patch({ type: picked })
}
}}
>
<ToggleGroupItem value="tcp" className="flex-1">
TCP TCP
</ToggleGroupItem> </Button>
<ToggleGroupItem value="http" className="flex-1"> <Button
type="button"
size="sm"
variant={value.type === 'http' ? 'secondary' : 'outline'}
className="flex-1"
aria-pressed={value.type === 'http'}
onClick={() => patch({ type: 'http' })}
>
<GlobeIcon data-icon="inline-start" />
HTTP HTTP
</ToggleGroupItem> </Button>
</ToggleGroup> </ButtonGroup>
</FormFieldSimple> </FormFieldSimple>
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}> <FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
@@ -1,11 +1,19 @@
import type { KeyboardEvent, ReactNode } from 'react' import type { KeyboardEvent, ReactNode } from 'react'
import { CheckIcon, ServerIcon, LayersIcon, ShieldAlertIcon, ScaleIcon } from 'lucide-react' import { ServerIcon, LayersIcon, ShieldAlertIcon, ScaleIcon } from 'lucide-react'
import { Frame, FramePanel } from '@/components/reui/frame' import { Frame, FramePanel } from '@/components/reui/frame'
import { IconTile } from '@/components/reui/icon-tile' import { IconTile } from '@/components/reui/icon-tile'
import { Badge } from '@/components/reui/badge'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from '@cfdm/ui/components/item'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared' import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
import { kpiCols } from './kpi-cols'
export type HealthProvider = HealthCheckProvider export type HealthProvider = HealthCheckProvider
export type HealthAggregate = HealthCheckAggregate export type HealthAggregate = HealthCheckAggregate
@@ -103,7 +111,7 @@ function handleTileKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivE
} }
} }
function TilePanel({ function ChoicePanel({
selected, selected,
title, title,
description, description,
@@ -122,53 +130,56 @@ function TilePanel({
}) { }) {
return ( return (
<FramePanel <FramePanel
fit
role={role} role={role}
aria-checked={selected} aria-checked={selected}
aria-pressed={selected} aria-pressed={selected}
tabIndex={0} tabIndex={0}
className={cn( className={cn(
'relative isolate flex min-w-0 cursor-pointer flex-col p-3 transition-colors', 'min-w-0 cursor-pointer transition-colors',
'hover:bg-muted/40 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none', 'hover:bg-muted/40 focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
selected && 'ring-ring ring-1', selected && 'bg-muted/40',
)} )}
onClick={onActivate} onClick={onActivate}
onKeyDown={(event) => handleTileKeyDown(onActivate, event)} onKeyDown={(event) => handleTileKeyDown(onActivate, event)}
> >
<div className="relative z-10 flex min-w-0 items-start gap-3"> <Item size="sm" className="w-full min-w-0 border-0 p-0">
<IconTile <ItemMedia>
variant="elevated" <IconTile
aria-hidden="true" variant="elevated"
className={cn('size-10.5 shrink-0', iconClassName ?? DEFAULT_ICON_CLASS)} aria-hidden="true"
> className={cn('size-10.5', iconClassName ?? DEFAULT_ICON_CLASS)}
{icon} >
</IconTile> {icon}
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> </IconTile>
<div className="flex min-w-0 items-start justify-between gap-2"> </ItemMedia>
<span className="text-foreground truncate text-sm font-medium">{title}</span> <ItemContent className="min-w-0 gap-0.5">
{selected ? ( <ItemTitle className="w-full min-w-0">{title}</ItemTitle>
<CheckIcon className="text-foreground size-4 shrink-0" aria-hidden /> <ItemDescription>{description}</ItemDescription>
) : null} </ItemContent>
</div> {selected ? (
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed"> <ItemActions className="shrink-0">
{description} <Badge variant="outline" size="sm">
</p> Выбрано
</div> </Badge>
</div> </ItemActions>
) : null}
</Item>
</FramePanel> </FramePanel>
) )
} }
function TileGrid({ children, count }: { children: ReactNode; count: number }) { function ChoiceFrame({ children }: { children: ReactNode }) {
return ( return (
<Frame dense spacing="sm" className="@container w-full min-w-0 overflow-hidden"> <Frame stacked spacing="sm" className="w-full min-w-0">
<div className={cn('grid min-w-0 gap-2', kpiCols(count))}>{children}</div> {children}
</Frame> </Frame>
) )
} }
/** /**
* Мультивыбор источников проб (Local / Cloudflare / Globalping). * Мультивыбор источников проб (Local / Cloudflare / Globalping).
* Preview: https://reui.io/preview/base/card-12 · https://reui.io/preview/base/stats-12 * Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/stats-12
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile * Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/icon-tile
*/ */
export function HealthSourceTiles({ export function HealthSourceTiles({
@@ -190,9 +201,9 @@ export function HealthSourceTiles({
} }
return ( return (
<TileGrid count={PROVIDER_ITEMS.length}> <ChoiceFrame>
{PROVIDER_ITEMS.map((item) => ( {PROVIDER_ITEMS.map((item) => (
<TilePanel <ChoicePanel
key={item.id} key={item.id}
selected={selected.includes(item.id)} selected={selected.includes(item.id)}
title={item.title} title={item.title}
@@ -203,13 +214,13 @@ export function HealthSourceTiles({
onActivate={() => toggle(item.id)} onActivate={() => toggle(item.id)}
/> />
))} ))}
</TileGrid> </ChoiceFrame>
) )
} }
/** /**
* Правило агрегации (ровно одно): any / all / majority. * Правило агрегации (ровно одно): any / all / majority.
* Preview: https://reui.io/preview/base/card-12 · https://reui.io/preview/base/settings-5 * Preview: https://reui.io/preview/base/list-9 · https://reui.io/preview/base/settings-5
*/ */
export function HealthAggregateTiles({ export function HealthAggregateTiles({
value, value,
@@ -220,9 +231,9 @@ export function HealthAggregateTiles({
}) { }) {
const selected = value || 'majority' const selected = value || 'majority'
return ( return (
<TileGrid count={AGGREGATE_ITEMS.length}> <ChoiceFrame>
{AGGREGATE_ITEMS.map((item) => ( {AGGREGATE_ITEMS.map((item) => (
<TilePanel <ChoicePanel
key={item.id} key={item.id}
selected={selected === item.id} selected={selected === item.id}
title={item.title} title={item.title}
@@ -232,6 +243,6 @@ export function HealthAggregateTiles({
onActivate={() => onChange(item.id)} onActivate={() => onChange(item.id)}
/> />
))} ))}
</TileGrid> </ChoiceFrame>
) )
} }
@@ -2,12 +2,20 @@ import { CheckIcon, CopyIcon } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { HealthCheckBadge } from '@/components/health-check-badge' import { HealthCheckBadge } from '@/components/health-check-badge'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { TruncatedText } from '@/components/truncated-text' import { TruncatedText } from '@/components/truncated-text'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { serviceDisplayFqdns } from '@/lib/service-utils' import { serviceDisplayFqdns } from '@/lib/service-utils'
import type { ServiceView } from '@/lib/schemas' import type { ServiceView } from '@/lib/schemas'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import {
Item,
ItemActions,
ItemContent,
ItemGroup,
ItemMedia,
} from '@cfdm/ui/components/item'
import { Switch } from '@cfdm/ui/components/switch' import { Switch } from '@cfdm/ui/components/switch'
import { import {
Tooltip, Tooltip,
@@ -17,6 +25,9 @@ import {
} from '@cfdm/ui/components/tooltip' } from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
/** Matches `Button size="icon-sm"` so Switch columns align with the card menu. */
const MENU_SLOT_CLASS = 'size-7 shrink-0'
export function CopyFqdnButton({ export function CopyFqdnButton({
value, value,
className, className,
@@ -126,6 +137,11 @@ interface ServiceIpListProps {
togglingIp?: string | null togglingIp?: string | null
ipToggleDisabled?: boolean ipToggleDisabled?: boolean
onToggleIp?: (ip: string, enabled: boolean) => void onToggleIp?: (ip: string, enabled: boolean) => void
/** Invisible icon-sm slot so IP Switch lines up with the card overflow menu. */
alignWithMenu?: boolean
lbMode?: ServiceView['lb_mode']
activeIps?: string[]
ipWeights?: Record<string, number>
className?: string className?: string
emptyLabel?: string emptyLabel?: string
copyable?: boolean copyable?: boolean
@@ -139,6 +155,10 @@ export function ServiceIpList({
togglingIp = null, togglingIp = null,
ipToggleDisabled = false, ipToggleDisabled = false,
onToggleIp, onToggleIp,
alignWithMenu = false,
lbMode,
activeIps = [],
ipWeights = {},
className, className,
emptyLabel = 'Нет IP', emptyLabel = 'Нет IP',
copyable = false, copyable = false,
@@ -155,49 +175,83 @@ export function ServiceIpList({
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row])) const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT) const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
const extraCount = ips.length - visible.length const extraCount = ips.length - visible.length
const showMenuSlot = Boolean(onToggleIp && alignWithMenu)
const markActive = lbMode === 'failover' || lbMode === 'weighted'
const activeSet = new Set(activeIps)
return ( return (
<div className={cn('flex min-w-0 flex-col gap-1', className)}> <ItemGroup className={cn('gap-1', className)}>
{visible.map((ip) => { {visible.map((ip) => {
const health = healthByIp.get(ip) const health = healthByIp.get(ip)
const enabled = ipEnabled[ip] !== false const enabled = ipEnabled[ip] !== false
return ( return (
<div key={ip} className="flex min-w-0 items-center gap-1.5"> <Item
<HealthCheckBadge key={ip}
status={health?.status ?? 'unknown'} size="sm"
latencyMs={health?.latency_ms} className="w-full min-w-0 flex-nowrap border-0 p-0"
lastCheckedAt={health?.last_checked_at} >
lastError={health?.last_error} <ItemMedia>
colo={health?.colo} <HealthCheckBadge
provider={health?.provider} status={health?.status ?? 'unknown'}
size="xs" latencyMs={health?.latency_ms}
/> lastCheckedAt={health?.last_checked_at}
<TruncatedText lastError={health?.last_error}
className={cn( colo={health?.colo}
'min-w-0 font-mono text-xs', provider={health?.provider}
enabled ? 'text-muted-foreground' : 'text-muted-foreground/60', size="xs"
textClassName,
)}
>
{ip}
</TruncatedText>
{copyable ? <CopyFqdnButton value={ip} /> : null}
{onToggleIp ? (
<Switch
size="sm"
className="ml-auto shrink-0"
checked={enabled}
disabled={ipToggleDisabled || togglingIp === ip}
onClick={(event) => {
event.stopPropagation()
}}
onCheckedChange={(checked) => onToggleIp(ip, Boolean(checked))}
aria-label={
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
}
/> />
</ItemMedia>
<ItemContent className="min-w-0 gap-0">
<div className="flex min-w-0 items-center gap-1.5">
<TruncatedText
className={cn(
'min-w-0 font-mono text-xs',
enabled
? 'text-muted-foreground'
: 'text-muted-foreground/60',
textClassName,
)}
>
{ip}
</TruncatedText>
{copyable ? <CopyFqdnButton value={ip} /> : null}
{markActive && activeSet.has(ip) ? (
<StatusBadge status="active" className="shrink-0" />
) : null}
{lbMode === 'weighted' ? (
<Badge
variant="outline"
size="xs"
className="shrink-0 tabular-nums"
>
w{ipWeights[ip] ?? 1}
</Badge>
) : null}
</div>
</ItemContent>
{onToggleIp ? (
<ItemActions className="ml-auto shrink-0 gap-1">
<Switch
size="sm"
className="shrink-0"
checked={enabled}
disabled={ipToggleDisabled || togglingIp === ip}
onClick={(event) => {
event.stopPropagation()
}}
onCheckedChange={(checked) =>
onToggleIp(ip, Boolean(checked))
}
aria-label={
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
}
/>
{showMenuSlot ? (
<span className={MENU_SLOT_CLASS} aria-hidden="true" />
) : null}
</ItemActions>
) : null} ) : null}
</div> </Item>
) )
})} })}
{extraCount > 0 ? ( {extraCount > 0 ? (
@@ -224,6 +278,6 @@ export function ServiceIpList({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
) : null} ) : null}
</div> </ItemGroup>
) )
} }
@@ -1,11 +1,17 @@
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react' import {
GitForkIcon,
MoreHorizontalIcon,
Repeat2Icon,
ScaleIcon,
ServerIcon,
type LucideIcon,
} from 'lucide-react'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { import {
Frame, Frame,
FrameDescription, FrameDescription,
FrameHeader,
FramePanel, FramePanel,
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
@@ -23,6 +29,12 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu' } from '@cfdm/ui/components/dropdown-menu'
import {
Item,
ItemActions,
ItemContent,
ItemMedia,
} from '@cfdm/ui/components/item'
import { Switch } from '@cfdm/ui/components/switch' import { Switch } from '@cfdm/ui/components/switch'
import { import {
Tooltip, Tooltip,
@@ -30,6 +42,62 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@cfdm/ui/components/tooltip' } from '@cfdm/ui/components/tooltip'
import { cn } from '@cfdm/ui/lib/utils'
/**
* Compact service card — settings-8 DNA (Badge + copy + Switch + menu).
* Preview: https://reui.io/preview/base/settings-8
* Frame: https://reui.io/docs/components/base/frame
* IconTile: https://reui.io/docs/components/base/icon-tile
*/
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 (веса)',
},
}
function LbModeTile({ mode }: { mode: LbMode }) {
const meta = LB_MODE_META[mode]
const Icon = meta.icon
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<IconTile
variant="elevated"
size="xs"
className={cn('shrink-0', meta.className)}
aria-label={meta.label}
/>
}
>
<Icon aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>{meta.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
interface ServiceUnitCardProps { interface ServiceUnitCardProps {
service: ServiceView service: ServiceView
@@ -55,27 +123,32 @@ export function ServiceUnitCard({
const extraCount = Math.max(0, fqdns.length - 1) const extraCount = Math.max(0, fqdns.length - 1)
return ( return (
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden"> <Frame stacked spacing="sm" className="h-full min-w-0">
<FrameHeader className="flex-row items-center justify-between gap-2"> <FramePanel fit>
<div className="flex min-w-0 items-center gap-2"> <Item size="sm" className="w-full min-w-0 flex-nowrap border-0 p-0">
<IconTile <ItemMedia>
variant="elevated" <IconTile
size="sm" variant="elevated"
className="text-muted-foreground" size="sm"
aria-hidden="true" className="text-foreground"
> aria-hidden="true"
<ServerIcon /> >
</IconTile> <ServerIcon />
<div className="flex min-w-0 flex-col gap-px"> </IconTile>
<FrameTitle className="min-w-0 truncate text-sm"> </ItemMedia>
<Link <ItemContent className="min-w-0 gap-px">
to="/services/$serviceId" <div className="flex min-w-0 items-center gap-1.5">
params={{ serviceId: String(service.id) }} <FrameTitle className="min-w-0 truncate text-base font-semibold">
className="hover:underline" <Link
> to="/services/$serviceId"
{service.name} params={{ serviceId: String(service.id) }}
</Link> className="hover:underline"
</FrameTitle> >
{service.name}
</Link>
</FrameTitle>
<LbModeTile mode={service.lb_mode} />
</div>
<div className="flex min-w-0 items-center gap-1"> <div className="flex min-w-0 items-center gap-1">
<FrameDescription className="min-w-0 truncate font-mono text-xs"> <FrameDescription className="min-w-0 truncate font-mono text-xs">
{primaryDomain} {primaryDomain}
@@ -108,66 +181,70 @@ export function ServiceUnitCard({
<CopyFqdnButton value={fqdns.join('\n')} /> <CopyFqdnButton value={fqdns.join('\n')} />
) : null} ) : null}
</div> </div>
</div> </ItemContent>
</div> <ItemActions className="ml-auto shrink-0 gap-1">
<div className="flex shrink-0 items-center gap-1"> <Switch
<Switch size="sm"
size="sm" checked={service.enabled}
checked={service.enabled} disabled={togglingId === service.id}
disabled={togglingId === service.id} onCheckedChange={(checked) =>
onCheckedChange={(checked) => onToggleService(service.id, Boolean(checked))
onToggleService(service.id, Boolean(checked))
}
aria-label={
service.enabled ? 'Выключить сервис' : 'Включить сервис'
}
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Действия ${service.name}`}
/>
} }
> aria-label={
<MoreHorizontalIcon aria-hidden /> service.enabled ? 'Выключить сервис' : 'Включить сервис'
</DropdownMenuTrigger> }
<DropdownMenuContent align="end"> />
<DropdownMenuItem <DropdownMenu>
<DropdownMenuTrigger
render={ render={
<Link <Button
to="/services/$serviceId" type="button"
params={{ serviceId: String(service.id) }} variant="ghost"
size="icon-sm"
aria-label={`Действия ${service.name}`}
/> />
} }
> >
Обзор <MoreHorizontalIcon aria-hidden />
</DropdownMenuItem> </DropdownMenuTrigger>
<DropdownMenuItem onClick={() => onEditService(service)}> <DropdownMenuContent align="end">
Изменить <DropdownMenuItem
</DropdownMenuItem> render={
<DropdownMenuItem <Link
variant="destructive" to="/services/$serviceId"
onClick={() => onDeleteService(service)} params={{ serviceId: String(service.id) }}
> />
Удалить }
</DropdownMenuItem> >
</DropdownMenuContent> Обзор
</DropdownMenu> </DropdownMenuItem>
</div> <DropdownMenuItem onClick={() => onEditService(service)}>
</FrameHeader> Изменить
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => onDeleteService(service)}
>
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ItemActions>
</Item>
</FramePanel>
<FramePanel className="flex min-w-0 flex-col gap-1 pt-0 shadow-none!"> <FramePanel className="flex min-w-0 flex-col">
<ServiceIpList <ServiceIpList
copyable copyable
alignWithMenu
ips={service.ips ?? []} ips={service.ips ?? []}
ipHealth={service.ip_health ?? []} ipHealth={service.ip_health ?? []}
ipEnabled={service.ip_enabled ?? {}} ipEnabled={service.ip_enabled ?? {}}
ipToggleDisabled={togglingId === service.id} ipToggleDisabled={togglingId === service.id}
togglingIp={togglingIp} togglingIp={togglingIp}
lbMode={service.lb_mode}
activeIps={service.active_ips}
ipWeights={service.domains[0]?.target_ip_weights}
onToggleIp={(ip, enabled) => onToggleIp={(ip, enabled) =>
onToggleServiceIp(service.id, ip, enabled) onToggleServiceIp(service.id, ip, enabled)
} }
+2
View File
@@ -133,6 +133,8 @@ export const serviceViewSchema = serviceSchema.extend({
health_latency_ms: z.number().nullable().default(null), health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]), ip_health: z.array(serviceIpHealthSchema).default([]),
ip_enabled: z.record(z.string(), z.boolean()).default({}), ip_enabled: z.record(z.string(), z.boolean()).default({}),
lb_mode: z.enum(['round_robin', 'failover', 'weighted']).catch('round_robin'),
active_ips: z.array(z.string()).default([]),
}) })
export const serviceGroupViewSchema = serviceGroupSchema.extend({ export const serviceGroupViewSchema = serviceGroupSchema.extend({
+26
View File
@@ -176,6 +176,8 @@ interface ServiceView$1 {
health_latency_ms: number | null; health_latency_ms: number | null;
ip_health: ServiceIpHealth$1[]; ip_health: ServiceIpHealth$1[];
ip_enabled: Record<string, boolean>; ip_enabled: Record<string, boolean>;
lb_mode: LbMode;
active_ips: string[];
} }
interface SyncJob { interface SyncJob {
id: string; id: string;
@@ -824,6 +826,12 @@ declare const serviceViewSchema: z.ZodObject<{
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>; colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>; ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>; }, z.core.$strip>;
declare const serviceGroupViewSchema: z.ZodObject<{ declare const serviceGroupViewSchema: z.ZodObject<{
id: z.ZodNumber; id: z.ZodNumber;
@@ -1013,6 +1021,12 @@ declare const serviceGroupViewSchema: z.ZodObject<{
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>; colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>; ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
health_status: z.ZodDefault<z.ZodEnum<{ health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown"; unknown: "unknown";
@@ -1211,6 +1225,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>; colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>; ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
health_status: z.ZodDefault<z.ZodEnum<{ health_status: z.ZodDefault<z.ZodEnum<{
unknown: "unknown"; unknown: "unknown";
@@ -1360,6 +1380,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
colo: z.ZodOptional<z.ZodNullable<z.ZodString>>; colo: z.ZodOptional<z.ZodNullable<z.ZodString>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>; ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
lb_mode: z.ZodCatch<z.ZodEnum<{
round_robin: "round_robin";
failover: "failover";
weighted: "weighted";
}>>;
active_ips: z.ZodDefault<z.ZodArray<z.ZodString>>;
}, z.core.$strip>>>; }, z.core.$strip>>>;
}, z.core.$strip>; }, z.core.$strip>;
declare const domainSchema: z.ZodObject<{ declare const domainSchema: z.ZodObject<{
+3 -1
View File
@@ -409,7 +409,9 @@ var serviceViewSchema = serviceSchema.extend({
health_status: ipHealthStateSchema.default("unknown"), health_status: ipHealthStateSchema.default("unknown"),
health_latency_ms: z.number().nullable().default(null), health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]), ip_health: z.array(serviceIpHealthSchema).default([]),
ip_enabled: z.record(z.string(), z.boolean()).default({}) ip_enabled: z.record(z.string(), z.boolean()).default({}),
lb_mode: lbModeSchema.catch("round_robin"),
active_ips: z.array(z.string()).default([])
}); });
var serviceGroupViewSchema = serviceGroupSchema.extend({ var serviceGroupViewSchema = serviceGroupSchema.extend({
services: z.array(serviceViewSchema).default([]), services: z.array(serviceViewSchema).default([]),
+2
View File
@@ -205,6 +205,8 @@ export const serviceViewSchema = serviceSchema.extend({
health_latency_ms: z.number().nullable().default(null), health_latency_ms: z.number().nullable().default(null),
ip_health: z.array(serviceIpHealthSchema).default([]), ip_health: z.array(serviceIpHealthSchema).default([]),
ip_enabled: z.record(z.string(), z.boolean()).default({}), ip_enabled: z.record(z.string(), z.boolean()).default({}),
lb_mode: lbModeSchema.catch('round_robin'),
active_ips: z.array(z.string()).default([]),
}) })
export const serviceGroupViewSchema = serviceGroupSchema.extend({ export const serviceGroupViewSchema = serviceGroupSchema.extend({
+2
View File
@@ -222,6 +222,8 @@ export interface ServiceView {
health_latency_ms: number | null; health_latency_ms: number | null;
ip_health: ServiceIpHealth[]; ip_health: ServiceIpHealth[];
ip_enabled: Record<string, boolean>; ip_enabled: Record<string, boolean>;
lb_mode: LbMode;
active_ips: string[];
} }
export interface GroupWithStats extends Group { export interface GroupWithStats extends Group {