Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d063323402 | ||
|
|
6bced71037 | ||
|
|
44d0eb0114 | ||
|
|
9f00dfcf84 | ||
|
|
9b9dcc3b12 | ||
|
|
6008cd763a |
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
@@ -330,6 +338,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],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -123,11 +123,12 @@ 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();
|
||||
});
|
||||
|
||||
@@ -18,9 +18,9 @@ import {
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { Switch } from '@cfdm/ui/components/switch'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
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 {
|
||||
HealthAggregateTiles,
|
||||
@@ -123,9 +123,6 @@ export function HealthCheckConfigFields({
|
||||
const aggregate = value.aggregate ?? 'majority'
|
||||
const isHttp = value.type === 'http'
|
||||
const rowClass = 'gap-3 px-0 py-3'
|
||||
const hasCloudflare = providers.includes('cloudflare')
|
||||
const hasGlobalping = providers.includes('globalping')
|
||||
const hasLocal = providers.includes('local')
|
||||
|
||||
return (
|
||||
<FieldGroup className={cn('gap-0', className)}>
|
||||
@@ -163,6 +160,7 @@ export function HealthCheckConfigFields({
|
||||
compact
|
||||
stacked
|
||||
className={rowClass}
|
||||
contentClassName="min-w-0"
|
||||
>
|
||||
<HealthSourceTiles
|
||||
value={providers}
|
||||
@@ -176,46 +174,6 @@ export function HealthCheckConfigFields({
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{hasCloudflare ? (
|
||||
<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>
|
||||
) : null}
|
||||
{hasGlobalping ? (
|
||||
<Alert>
|
||||
<AlertTitle>Globalping</AlertTitle>
|
||||
<AlertDescription>
|
||||
Пробы из сети globalping.io (TCP ping / HTTP). Токен, локации и лимит —
|
||||
в{' '}
|
||||
<Link to="/settings/health" className="text-foreground underline">
|
||||
Настройках → Health-check
|
||||
</Link>
|
||||
. Без токена или при 429 этот источник = fail, без fallback на Local.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{hasLocal ? (
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
{providers.length > 1 ? (
|
||||
<SettingRow
|
||||
title="Агрегация"
|
||||
@@ -223,6 +181,7 @@ export function HealthCheckConfigFields({
|
||||
compact
|
||||
stacked
|
||||
className={rowClass}
|
||||
contentClassName="min-w-0"
|
||||
>
|
||||
<HealthAggregateTiles
|
||||
value={aggregate}
|
||||
@@ -258,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`}>
|
||||
<ToggleGroup
|
||||
id={`${idPrefix}-type`}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
value={[value.type]}
|
||||
onValueChange={(next) => {
|
||||
const picked = next[0]
|
||||
if (picked === 'tcp' || picked === 'http') {
|
||||
patch({ type: picked })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value="tcp" className="flex-1">
|
||||
<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
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="http" className="flex-1">
|
||||
</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
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</FormFieldSimple>
|
||||
|
||||
<FormFieldSimple label="Порт" htmlFor={`${idPrefix}-port`}>
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import type { KeyboardEvent, ReactNode } from 'react'
|
||||
import { CheckIcon, GlobeIcon, ServerIcon, CloudIcon, LayersIcon, ShieldAlertIcon, ScaleIcon } from 'lucide-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 { cn } from '@cfdm/ui/lib/utils'
|
||||
import type { HealthCheckAggregate, HealthCheckProvider } from '@cfdm/shared'
|
||||
|
||||
@@ -11,6 +20,34 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
const PROVIDER_ITEMS: Array<{
|
||||
id: HealthProvider
|
||||
title: string
|
||||
@@ -29,14 +66,14 @@ const PROVIDER_ITEMS: Array<{
|
||||
id: 'cloudflare',
|
||||
title: 'Cloudflare',
|
||||
description: 'Worker на edge, KV mailbox',
|
||||
icon: <CloudIcon />,
|
||||
icon: <CloudflareMark />,
|
||||
iconClassName: 'text-warning [&_svg]:text-current',
|
||||
},
|
||||
{
|
||||
id: 'globalping',
|
||||
title: 'Globalping',
|
||||
description: 'Пробы из сети globalping.io',
|
||||
icon: <GlobeIcon />,
|
||||
icon: <GlobalpingMark />,
|
||||
iconClassName: 'text-success [&_svg]:text-current',
|
||||
},
|
||||
]
|
||||
@@ -74,7 +111,7 @@ function handleTileKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivE
|
||||
}
|
||||
}
|
||||
|
||||
function TilePanel({
|
||||
function ChoicePanel({
|
||||
selected,
|
||||
title,
|
||||
description,
|
||||
@@ -93,43 +130,56 @@ function TilePanel({
|
||||
}) {
|
||||
return (
|
||||
<FramePanel
|
||||
fit
|
||||
role={role}
|
||||
aria-checked={selected}
|
||||
aria-pressed={selected}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'relative isolate flex h-full 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',
|
||||
selected && 'ring-ring ring-1',
|
||||
selected && 'bg-muted/40',
|
||||
)}
|
||||
onClick={onActivate}
|
||||
onKeyDown={(event) => handleTileKeyDown(onActivate, event)}
|
||||
>
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
{icon}
|
||||
</IconTile>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-foreground text-sm font-medium">{title}</span>
|
||||
{selected ? (
|
||||
<CheckIcon className="text-foreground size-4 shrink-0" aria-hidden />
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
{selected ? (
|
||||
<ItemActions className="shrink-0">
|
||||
<Badge variant="outline" size="sm">
|
||||
Выбрано
|
||||
</Badge>
|
||||
</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/card-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
|
||||
*/
|
||||
export function HealthSourceTiles({
|
||||
@@ -151,28 +201,26 @@ export function HealthSourceTiles({
|
||||
}
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm" className="@container w-full">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{PROVIDER_ITEMS.map((item) => (
|
||||
<TilePanel
|
||||
key={item.id}
|
||||
selected={selected.includes(item.id)}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
icon={item.icon}
|
||||
iconClassName={item.iconClassName}
|
||||
role="checkbox"
|
||||
onActivate={() => toggle(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
<ChoiceFrame>
|
||||
{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"
|
||||
onActivate={() => toggle(item.id)}
|
||||
/>
|
||||
))}
|
||||
</ChoiceFrame>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Правило агрегации (ровно одно): 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({
|
||||
value,
|
||||
@@ -183,20 +231,18 @@ export function HealthAggregateTiles({
|
||||
}) {
|
||||
const selected = value || 'majority'
|
||||
return (
|
||||
<Frame dense spacing="sm" className="@container w-full">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{AGGREGATE_ITEMS.map((item) => (
|
||||
<TilePanel
|
||||
key={item.id}
|
||||
selected={selected === item.id}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
icon={item.icon}
|
||||
role="radio"
|
||||
onActivate={() => onChange(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
<ChoiceFrame>
|
||||
{AGGREGATE_ITEMS.map((item) => (
|
||||
<ChoicePanel
|
||||
key={item.id}
|
||||
selected={selected === item.id}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
icon={item.icon}
|
||||
role="radio"
|
||||
onActivate={() => onChange(item.id)}
|
||||
/>
|
||||
))}
|
||||
</ChoiceFrame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,62 @@ 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
|
||||
*/
|
||||
|
||||
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 {
|
||||
service: ServiceView
|
||||
@@ -55,27 +123,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>
|
||||
<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 +181,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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -133,6 +133,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({
|
||||
|
||||
Vendored
+26
@@ -176,6 +176,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;
|
||||
@@ -824,6 +826,12 @@ declare const serviceViewSchema: z.ZodObject<{
|
||||
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;
|
||||
@@ -1013,6 +1021,12 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
||||
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";
|
||||
@@ -1211,6 +1225,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
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";
|
||||
@@ -1360,6 +1380,12 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
||||
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<{
|
||||
|
||||
Vendored
+3
-1
@@ -409,7 +409,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([]),
|
||||
|
||||
@@ -205,6 +205,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({
|
||||
|
||||
@@ -222,6 +222,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 {
|
||||
|
||||
Reference in New Issue
Block a user