Compare commits
36
Commits
6c379f339b
...
v1.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c00b268dc | ||
|
|
a185b3a2cb | ||
|
|
ed4d6f8a46 | ||
|
|
597017b625 | ||
|
|
7cd86fcda1 | ||
|
|
ce8393e0c3 | ||
|
|
3fb8480f8a | ||
|
|
721332e767 | ||
|
|
ec70104085 | ||
|
|
c01c75116a | ||
|
|
19620c0803 | ||
|
|
60da2b9032 | ||
|
|
3e4cacab42 | ||
|
|
859ad23006 | ||
|
|
30447d8380 | ||
|
|
c9eee6fdcb | ||
|
|
2b3c48cdde | ||
|
|
32248d88a4 | ||
|
|
278a6b64b4 | ||
|
|
56f2ed78e5 | ||
|
|
1d497d01c2 | ||
|
|
9783974949 | ||
|
|
a58d91ff5e | ||
|
|
9d8c2aff86 | ||
|
|
f1767b8a1f | ||
|
|
0a4293b735 | ||
|
|
e748c875bc | ||
|
|
6a6cb34eeb | ||
|
|
60e15ca40a | ||
|
|
1893778e3a | ||
|
|
76a58beb7e | ||
|
|
13c079c969 | ||
|
|
cd616c7a03 | ||
|
|
733c041482 | ||
|
|
d8340e8a0b | ||
|
|
704624cbe7 |
No files matched your search
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
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 `0e224b0281`.** 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 `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 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
|
||||||
@@ -58,7 +58,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)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
@@ -1,24 +1,23 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
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.
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
## data-grid (the flagship - read its API every time)
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
// add sorting/pagination/selection models per the API
|
|
||||||
})
|
})
|
||||||
|
|
||||||
<DataGrid table={table} recordCount={data.length}>
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
@@ -28,9 +27,37 @@ const table = useReactTable({
|
|||||||
|
|
||||||
Common mistakes:
|
Common mistakes:
|
||||||
|
|
||||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
## kanban
|
## kanban
|
||||||
|
|
||||||
@@ -284,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
|||||||
|
|
||||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
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 `0e224b0281`.** 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 `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 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
|
||||||
@@ -58,7 +58,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)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
@@ -1,24 +1,23 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
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.
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
## data-grid (the flagship - read its API every time)
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
// add sorting/pagination/selection models per the API
|
|
||||||
})
|
})
|
||||||
|
|
||||||
<DataGrid table={table} recordCount={data.length}>
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
@@ -28,9 +27,37 @@ const table = useReactTable({
|
|||||||
|
|
||||||
Common mistakes:
|
Common mistakes:
|
||||||
|
|
||||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
## kanban
|
## kanban
|
||||||
|
|
||||||
@@ -284,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
|||||||
|
|
||||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"pid": 47044,
|
|
||||||
"version": "0.9.9",
|
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-7bcc7a2b16d00925",
|
|
||||||
"startedAt": 1784215894915
|
|
||||||
}
|
|
||||||
File renamed without changes.
@@ -20,13 +20,14 @@ Fastify backend: `apps/api/`. Контракты — `@cfdm/shared` (Zod). Front
|
|||||||
- Дублирование Zod schemas в `apps/web` — только re-export из `@cfdm/shared`
|
- Дублирование Zod schemas в `apps/web` — только re-export из `@cfdm/shared`
|
||||||
- Самописные формы без `Field` + RHF + Zod
|
- Самописные формы без `Field` + RHF + Zod
|
||||||
|
|
||||||
## shadcn-паттерны
|
## UI-паттерны (ReUI Frame)
|
||||||
|
|
||||||
| API-данные | UI |
|
| API-данные | UI |
|
||||||
|------------|-----|
|
|------------|-----|
|
||||||
| Список | `Table` / `DataTableCard` |
|
| Список | `ResourcePage` (Frame + DataGrid + Filters) |
|
||||||
| Создание | `Card` + `FieldGroup` + RHF |
|
| Создание / редактирование | `FormSheet` + `FieldGroup` + RHF |
|
||||||
| Статус | `Badge` variants |
|
| Settings | `SettingsShell` + Frame + `SettingRow` |
|
||||||
|
| Статус | ReUI `Badge` / `StatusBadge` |
|
||||||
| Ошибка | `sonner` `toast.error` |
|
| Ошибка | `sonner` `toast.error` |
|
||||||
|
|
||||||
## Согласованность
|
## Согласованность
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Monorepo — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). ReUI — [`reui-m
|
|||||||
|-----------|------|
|
|-----------|------|
|
||||||
| `PageShell` | `page-shell.tsx` |
|
| `PageShell` | `page-shell.tsx` |
|
||||||
| `ResourcePage` | `reui-kit/resource-page.tsx` |
|
| `ResourcePage` | `reui-kit/resource-page.tsx` |
|
||||||
| `OpsDashboard` / `KpiStatGrid` | `reui-kit/ops-dashboard.tsx`, `kpi-stat-grid.tsx` |
|
| `OpsDashboard` / `KpiStatGrid` / `QuickActionGrid` | `reui-kit/ops-dashboard.tsx`, `kpi-stat-grid.tsx`, `quick-action-grid.tsx` |
|
||||||
| `KanbanBoard` | `reui-kit/kanban-board.tsx` |
|
| `KanbanBoard` | `reui-kit/kanban-board.tsx` |
|
||||||
| `DetailPanel` | `reui-kit/detail-panel.tsx` |
|
| `DetailPanel` | `reui-kit/detail-panel.tsx` |
|
||||||
| `SettingsShell` | `reui-kit/settings-shell.tsx` |
|
| `SettingsShell` | `reui-kit/settings-shell.tsx` |
|
||||||
@@ -58,7 +58,7 @@ Monorepo — [`frontend-monorepo.mdc`](frontend-monorepo.mdc). ReUI — [`reui-m
|
|||||||
cd apps/web
|
cd apps/web
|
||||||
pnpm dlx shadcn@latest add button field input ...
|
pnpm dlx shadcn@latest add button field input ...
|
||||||
pnpm dlx shadcn@latest add @reui/frame @reui/data-grid @reui/filters
|
pnpm dlx shadcn@latest add @reui/frame @reui/data-grid @reui/filters
|
||||||
pnpm dlx shadcn@latest add @reui/card-35 @reui/settings-16 @reui/auth-13
|
pnpm dlx shadcn@latest add @reui/stats-12 @reui/card-35 @reui/settings-16 @reui/auth-13
|
||||||
pnpm dlx shadcn@latest apply b2fA --only theme -y
|
pnpm dlx shadcn@latest apply b2fA --only theme -y
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ pnpm dlx shadcn@latest apply b2fA --only theme -y
|
|||||||
| `<table>`, raw select | DataGrid / `Select` |
|
| `<table>`, raw select | DataGrid / `Select` |
|
||||||
| `bg-emerald-*` | semantic / ReUI `variant` |
|
| `bg-emerald-*` | semantic / ReUI `variant` |
|
||||||
| Card как ops-shell | ReUI `Frame` |
|
| Card как ops-shell | ReUI `Frame` |
|
||||||
| Hand-roll KPI | `KpiStatGrid` / [card-35](https://reui.io/preview/base/card-35) |
|
| Hand-roll KPI | `KpiStatGrid` / [stats-12](https://reui.io/preview/base/stats-12) |
|
||||||
| `space-y-*` | `flex` + `gap-*` |
|
| `space-y-*` | `flex` + `gap-*` |
|
||||||
| `@/components/ui/*` | `@cfdm/ui/components/*` |
|
| `@/components/ui/*` | `@cfdm/ui/components/*` |
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ pnpm dlx shadcn@latest apply b2fA --only theme -y
|
|||||||
|------|------|---------|
|
|------|------|---------|
|
||||||
| Shell | `layout/app-shell.tsx` | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
|
| Shell | `layout/app-shell.tsx` | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
|
||||||
| Login | `routes/login.tsx` | [auth-13](https://reui.io/preview/base/auth-13) |
|
| Login | `routes/login.tsx` | [auth-13](https://reui.io/preview/base/auth-13) |
|
||||||
| Dashboard | `routes/_auth/index.tsx` | [card-35](https://reui.io/preview/base/card-35) / dashboard-1 |
|
| Dashboard | `routes/_auth/index.tsx` | [stats-12](https://reui.io/preview/base/stats-12) / dashboard-1 |
|
||||||
| Lists | `ResourcePage` | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
|
| Lists | `ResourcePage` | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
|
||||||
| Settings | `settings/integrations.tsx` | [settings-16](https://reui.io/preview/base/settings-16) |
|
| Settings | `settings/integrations.tsx` | [settings-16](https://reui.io/preview/base/settings-16) |
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ alwaysApply: false
|
|||||||
|
|
||||||
| Зона | Preview |
|
| Зона | Preview |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| KPI | [card-35](https://reui.io/preview/base/card-35) |
|
| KPI | [stats-12](https://reui.io/preview/base/stats-12) — EvoBGP hybrid SoT |
|
||||||
|
| Quick Actions | [stats-12](https://reui.io/preview/base/stats-12) · [card-12](https://reui.io/preview/base/card-12) → `QuickActionGrid` |
|
||||||
| List | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
|
| List | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
|
||||||
| Settings | [settings-16](https://reui.io/preview/base/settings-16), [Application Settings](https://reui.io/blocks/application/settings) |
|
| Settings | [settings-16](https://reui.io/preview/base/settings-16), [Application Settings](https://reui.io/blocks/application/settings) |
|
||||||
| Settings rows / Health-check | [settings-2](https://reui.io/preview/base/settings-2), [settings-3](https://reui.io/preview/base/settings-3) |
|
| Settings rows / Health-check | [settings-2](https://reui.io/preview/base/settings-2), [settings-3](https://reui.io/preview/base/settings-3) |
|
||||||
@@ -36,7 +37,7 @@ alwaysApply: false
|
|||||||
| Forms | [form-7](https://reui.io/preview/base/form-7) |
|
| Forms | [form-7](https://reui.io/preview/base/form-7) |
|
||||||
| Shell | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
|
| Shell | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
|
||||||
|
|
||||||
**SettingRow:** `FieldSeparator` только между соседними rows; не между toggle-row и nested fields (Health-check).
|
**SettingRow:** `FieldSeparator` opt-in (`separated`); не между toggle-row и nested fields (Health-check). Settings-секции — отдельные Frame + `gap`, без hairline под PageHeader.
|
||||||
|
|
||||||
## Иерархия компонентов
|
## Иерархия компонентов
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ apps/web/src/components/ ← shared + domain + layout
|
|||||||
kanban-board.tsx ← kanban + KanbanBoardSkeleton
|
kanban-board.tsx ← kanban + KanbanBoardSkeleton
|
||||||
detail-panel.tsx ← detail: Frame header/metrics
|
detail-panel.tsx ← detail: Frame header/metrics
|
||||||
settings-shell.tsx
|
settings-shell.tsx
|
||||||
ops-dashboard.tsx ← KPI card-35 + charts
|
ops-dashboard.tsx ← KPI stats-12 + charts
|
||||||
catalog-board-toggle.tsx
|
catalog-board-toggle.tsx
|
||||||
empty-state.tsx
|
empty-state.tsx
|
||||||
query-state.tsx
|
query-state.tsx
|
||||||
@@ -73,7 +74,8 @@ apps/web/src/components/ ← shared + domain + layout
|
|||||||
| Kanban | `KanbanBoard` / `KanbanBoardSkeleton` | ReUI `kanban` + `Frame` |
|
| Kanban | `KanbanBoard` / `KanbanBoardSkeleton` | ReUI `kanban` + `Frame` |
|
||||||
| Detail | `DetailPanel` | ReUI `Frame` |
|
| Detail | `DetailPanel` | ReUI `Frame` |
|
||||||
| Settings | `SettingsShell` | — |
|
| Settings | `SettingsShell` | — |
|
||||||
| Dashboard KPI | `OpsDashboard` / `KpiStatGrid` | ReUI Frame [card-35](https://reui.io/preview/base/card-35) |
|
| Dashboard KPI | `OpsDashboard` / `KpiStatGrid` | ReUI Frame [stats-12](https://reui.io/preview/base/stats-12) hybrid |
|
||||||
|
| Quick Actions | `QuickActionGrid` | Frame tiles + Badge «Перейти» |
|
||||||
| Empty | `EmptyState` | `Empty` |
|
| Empty | `EmptyState` | `Empty` |
|
||||||
| Loading / Error | `QueryState` / kit skeletons | `Skeleton`, `Alert` |
|
| Loading / Error | `QueryState` / kit skeletons | `Skeleton`, `Alert` |
|
||||||
| Status | `StatusBadge` | ReUI `Badge` (`success`/`info`/`warning`) |
|
| Status | `StatusBadge` | ReUI `Badge` (`success`/`info`/`warning`) |
|
||||||
@@ -127,7 +129,7 @@ Toggle «Доска» / «К каталогу» — всегда `outline` в `p
|
|||||||
|
|
||||||
## Dashboard KPI
|
## Dashboard KPI
|
||||||
|
|
||||||
Эталон: [card-35](https://reui.io/preview/base/card-35) — muted **label** + крупный **value** + optional **hint**; клик по карточке через `to` (`Link`), без отдельных hint-links.
|
Эталон: [stats-12](https://reui.io/preview/base/stats-12) — icon tile + value + label + optional badge/footer; клик через `to` (`Link`) / `onSelect`. Compact strip: [card-35](https://reui.io/preview/base/card-35).
|
||||||
|
|
||||||
## Sidebar
|
## Sidebar
|
||||||
|
|
||||||
@@ -149,7 +151,7 @@ Toggle «Доска» / «К каталогу» — всегда `outline` в `p
|
|||||||
| Shell | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
|
| Shell | [app-shell-12](https://reui.io/preview/base/app-shell-12) |
|
||||||
| List + tabs + filters | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
|
| List + tabs + filters | [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) |
|
||||||
| Line tabs | [c-tabs-2](https://reui.io/preview/base/components/c-tabs-2) |
|
| Line tabs | [c-tabs-2](https://reui.io/preview/base/components/c-tabs-2) |
|
||||||
| KPI | [card-35](https://reui.io/preview/base/card-35) — на dashboard, certificates, domains, groups, services |
|
| KPI | [stats-12](https://reui.io/preview/base/stats-12) — на dashboard, certificates, domains, groups, services |
|
||||||
| Login | [auth-13](https://reui.io/preview/base/auth-13) |
|
| Login | [auth-13](https://reui.io/preview/base/auth-13) |
|
||||||
| Settings | [settings-16](https://reui.io/preview/base/settings-16) / [settings-6](https://reui.io/preview/base/settings-6) |
|
| Settings | [settings-16](https://reui.io/preview/base/settings-16) / [settings-6](https://reui.io/preview/base/settings-6) |
|
||||||
| Empty | [empty-state-12](https://reui.io/preview/base/empty-state-12) |
|
| Empty | [empty-state-12](https://reui.io/preview/base/empty-state-12) |
|
||||||
@@ -158,6 +160,7 @@ Toggle «Доска» / «К каталогу» — всегда `outline` в `p
|
|||||||
|
|
||||||
```
|
```
|
||||||
AppShell main: gap-4 md:gap-6, px-4 md:px-6, py-4 md:py-5
|
AppShell main: gap-4 md:gap-6, px-4 md:px-6, py-4 md:py-5
|
||||||
|
(--sidebar-width: 240px; header h-12; AppSwitcher + AppsMenu — Shared App Shell chrome)
|
||||||
PageShell: gap-4 md:gap-6
|
PageShell: gap-4 md:gap-6
|
||||||
Title/desc: gap-px
|
Title/desc: gap-px
|
||||||
Card/Frame grid: gap-4 (dashboard denser: gap-2 md:gap-3)
|
Card/Frame grid: gap-4 (dashboard denser: gap-2 md:gap-3)
|
||||||
@@ -167,6 +170,8 @@ Toolbar / header actions: gap-2
|
|||||||
Tabs list (line): gap-5
|
Tabs list (line): gap-5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Shared chrome (vps-tracker / CFDM / EvoBGP): см. [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md) — секция **Shared App Shell chrome**. Preview: [app-shell-12](https://reui.io/preview/base/app-shell-12).
|
||||||
|
|
||||||
**Запрещено:** `space-y-*`, raw colors (`bg-emerald-*`), custom empty divs, page-level Spinner / plain «Загрузка…» без Skeleton.
|
**Запрещено:** `space-y-*`, raw colors (`bg-emerald-*`), custom empty divs, page-level Spinner / plain «Загрузка…» без Skeleton.
|
||||||
|
|
||||||
## UX/UI (состояния данных)
|
## UX/UI (состояния данных)
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll.
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# KPI hybrid — только kit (stats-12 + IconTile)
|
||||||
|
|
||||||
|
Preview: [stats-12](https://reui.io/preview/base/stats-12). Primitive: [icon-tile](https://reui.io/docs/components/base/icon-tile). SoT DNA = EvoBGP. Markup: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
|
||||||
|
|
||||||
|
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
|
||||||
|
|
||||||
|
## MUST
|
||||||
|
|
||||||
|
| Зона | Компонент / DNA |
|
||||||
|
|------|-----------------|
|
||||||
|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
|
||||||
|
| Markup | horizontal compact hybrid: icon left `IconTile` `variant="elevated"` `className="size-10.5"` + label/Badge + value ± `variant` |
|
||||||
|
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на IconTile elevated |
|
||||||
|
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
|
||||||
|
|
||||||
|
Импорты UI: `@cfdm/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
|
||||||
|
|
||||||
|
## NEVER
|
||||||
|
|
||||||
|
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
|
||||||
|
- `Item` `size-10.5` `bg-muted` hybrid вместо IconTile
|
||||||
|
- Другой size / radius / solid brand fill вместо `elevated`
|
||||||
|
- `card-35` как замена stats-12 hybrid KPI
|
||||||
|
- Копипаст ReUI block в route — adapt через `reui-kit/`
|
||||||
|
- Голый lucide `size-4` в name-cell без IconTile
|
||||||
@@ -11,10 +11,14 @@ alwaysApply: true
|
|||||||
|
|
||||||
| Документ | URL |
|
| Документ | URL |
|
||||||
|----------|-----|
|
|----------|-----|
|
||||||
|
| **Introduction** | https://reui.io/docs |
|
||||||
| **llms.txt** | https://reui.io/llms.txt |
|
| **llms.txt** | https://reui.io/llms.txt |
|
||||||
| **Get Started** | https://reui.io/docs/get-started |
|
| **Get Started** | https://reui.io/docs/get-started |
|
||||||
| **Styling** | https://reui.io/docs/styling |
|
| **Styling** | https://reui.io/docs/styling |
|
||||||
|
| **Registry** | https://reui.io/docs/registry |
|
||||||
| **MCP** | https://reui.io/docs/mcp |
|
| **MCP** | https://reui.io/docs/mcp |
|
||||||
|
| **Agent Skills** | https://reui.io/docs/agent-skills |
|
||||||
|
| **Cursor MCP** | https://reui.io/docs/cursor |
|
||||||
| **Blocks** | https://reui.io/blocks |
|
| **Blocks** | https://reui.io/blocks |
|
||||||
| **Settings blocks** | https://reui.io/blocks/application/settings |
|
| **Settings blocks** | https://reui.io/blocks/application/settings |
|
||||||
| **License** | https://reui.io/docs/license-setup |
|
| **License** | https://reui.io/docs/license-setup |
|
||||||
@@ -27,6 +31,10 @@ alwaysApply: true
|
|||||||
|
|
||||||
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
|
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
|
||||||
|
|
||||||
|
**Registry (актуально):** 20 free components — `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` ([docs](https://reui.io/docs), [MCP](https://reui.io/docs/mcp)). Skill: `.claude/skills/reui` (v `668fb463eb`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
|
||||||
|
|
||||||
|
**Важно:** skill описывает текущий registry (в т.ч. data-grid на TanStack Table v9). Установленный в проекте `@reui/data-grid` может оставаться на v8 до явного CLI upgrade — не ломать kit без миграции.
|
||||||
|
|
||||||
## Когда ReUI vs shadcn
|
## Когда ReUI vs shadcn
|
||||||
|
|
||||||
| Задача | Registry | Импорт |
|
| Задача | Registry | Импорт |
|
||||||
@@ -36,11 +44,14 @@ alwaysApply: true
|
|||||||
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
|
| Data Grid | `@reui` | `@/components/reui/data-grid/*` → `ResourcePage` |
|
||||||
| Filters | `@reui` | `@/components/reui/filters` |
|
| Filters | `@reui` | `@/components/reui/filters` |
|
||||||
| Frame surface | `@reui` | `@/components/reui/frame` |
|
| Frame surface | `@reui` | `@/components/reui/frame` |
|
||||||
| KPI | block [card-35](https://reui.io/preview/base/card-35) | `reui-kit/KpiStatGrid` |
|
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) |
|
||||||
|
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
|
||||||
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
|
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
|
||||||
| Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` |
|
| Number / date / autocomplete / color / kanban / timeline / stepper / tree | `@reui` | `@/components/reui/*` |
|
||||||
|
| Event Calendar / Gantt / Icon Tile (registry, 20 free) | `@reui` | CLI `@reui/<name>` → `@/components/reui/*` при установке |
|
||||||
|
|
||||||
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не устаревший DataGridCard.
|
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не DataGridCard.
|
||||||
|
**Quick Actions** — только `QuickActionGrid` (не Card / Button grid).
|
||||||
|
|
||||||
## MCP workflow
|
## MCP workflow
|
||||||
|
|
||||||
@@ -64,11 +75,11 @@ alwaysApply: true
|
|||||||
|
|
||||||
## Установленные ReUI (apps/web)
|
## Установленные ReUI (apps/web)
|
||||||
|
|
||||||
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
|
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`, `icon-tile`
|
||||||
|
|
||||||
**Kit:** `ResourcePage`, `KpiStatGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
|
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `KanbanBoard`, `DetailPanel`, `SettingsShell`
|
||||||
|
|
||||||
**Blocks (reference):** `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
|
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -83,7 +94,7 @@ REUI_LICENSE_KEY=
|
|||||||
|
|
||||||
| Зона | Preview |
|
| Зона | Preview |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| KPI | https://reui.io/preview/base/card-35 |
|
| KPI / Quick Actions | https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12 |
|
||||||
| List | https://reui.io/preview/base/data-grid-filtering-2 |
|
| List | https://reui.io/preview/base/data-grid-filtering-2 |
|
||||||
| Settings | https://reui.io/preview/base/settings-16 |
|
| Settings | https://reui.io/preview/base/settings-16 |
|
||||||
| Auth | https://reui.io/preview/base/auth-13 |
|
| Auth | https://reui.io/preview/base/auth-13 |
|
||||||
@@ -96,8 +107,9 @@ REUI_LICENSE_KEY=
|
|||||||
- ReUI в `packages/ui` / импорт как `@cfdm/ui`
|
- ReUI в `packages/ui` / импорт как `@cfdm/ui`
|
||||||
- Radix-варианты docs — только Base UI
|
- Radix-варианты docs — только Base UI
|
||||||
- Raw `bg-emerald-*` вместо ReUI `variant`
|
- Raw `bg-emerald-*` вместо ReUI `variant`
|
||||||
- Hand-roll data-grid/filters/KPI/settings rows при наличии `@reui` / kit
|
- Hand-roll data-grid/filters/KPI/Quick Actions/settings rows при наличии `@reui` / kit
|
||||||
- Смешивать Card и Frame на ops-экране
|
- Смешивать Card и Frame на ops-экране
|
||||||
|
- Ставить shadcn/ui blocks выше ReUI PRO
|
||||||
|
|
||||||
## Чеклист
|
## Чеклист
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,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 `0e224b0281`.** 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 `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 for Agents
|
# ReUI for Agents
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +1,44 @@
|
|||||||
---
|
---
|
||||||
description: ВСЕГДА использовать MCP-плагин shadcn UI перед любым UI-кодом
|
description: shadcn MCP — primitives secondary; pages/blocks → user-reui first
|
||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# shadcn MCP — обязательно
|
# shadcn MCP — primitives (secondary)
|
||||||
|
|
||||||
Перед **любой** задачей с UI (новый экран, компонент, стили, рефакторинг внешнего вида) — **сначала MCP** `plugin-shadcn-shadcn`, не память и не веб-поиск.
|
Перед UI-задачей: **сначала** MCP `user-reui` для pages / KPI / lists / settings / Frame ([`reui-mcp.mdc`](reui-mcp.mdc)).
|
||||||
|
MCP `plugin-shadcn-shadcn` — для **primitives** `@shadcn` и registry sync, не вместо PRO blocks.
|
||||||
|
|
||||||
CLI и docs — **после** MCP, по команде из `get_add_command_for_items`.
|
CLI и docs — **после** MCP, по команде из `get_add_command_for_items`.
|
||||||
|
|
||||||
## Порядок (строго)
|
## Порядок (primitives)
|
||||||
|
|
||||||
0. **Codegraph** `codegraph_explore` — найти существующие реализации и shared-обёртки (один вызов перед правками)
|
0. Поиск существующих shared / `reui-kit`
|
||||||
1. **`get_project_registries`** — какие registry доступны в проекте
|
1. **`get_project_registries`**
|
||||||
2. **`search_items_in_registries`** — компонент, block, example (`query`: `"card"`, `"tabs demo"`, `"dashboard"`, `"scroll-area"`)
|
2. **`search_items_in_registries`** — primitive / example
|
||||||
3. **`get_item_examples_from_registries`** — полный код примера перед написанием JSX
|
3. **`get_item_examples_from_registries`**
|
||||||
4. **`get_add_command_for_items`** — точная CLI-команда `pnpm dlx shadcn@latest add ...`
|
4. **`get_add_command_for_items`** → CLI из `apps/web`
|
||||||
5. Выполнить add из `apps/web` (см. [`frontend-monorepo.mdc`](frontend-monorepo.mdc))
|
5. `pnpm dlx shadcn@latest docs <component>` — сверить API
|
||||||
6. **CLI docs (обязательно):** `cd apps/web && pnpm dlx shadcn@latest docs <component>` — сверить API/props с [ui.shadcn.com/docs/components](https://ui.shadcn.com/docs/components)
|
6. Адаптировать под TanStack → `apps/web/src/`
|
||||||
7. Сверить examples из MCP с API из docs CLI — реализовать только после совпадения
|
7. Context7 — только TanStack / Recharts / не-shadcn
|
||||||
8. Адаптировать пример под TanStack Router / Query → `apps/web/src/`
|
8. **`get_audit_checklist`** — перед merge
|
||||||
9. **Context7** — только TanStack / Recharts / не-shadcn (не заменяет шаги 1–8 для примитивов)
|
|
||||||
10. **`get_audit_checklist`** — перед merge PR
|
|
||||||
11. **Codegraph** `codegraph_status` — Pending sync пустой после правок
|
|
||||||
|
|
||||||
## Когда вызывать MCP
|
## Когда
|
||||||
|
|
||||||
| Задача | MCP |
|
| Задача | MCP |
|
||||||
|--------|-----|
|
|--------|-----|
|
||||||
| Новая страница / layout | `search` → `types: ["block"]` → examples → add block |
|
| Новая страница / KPI / list / settings | **`user-reui`** ([`reui-mcp.mdc`](reui-mcp.mdc)) |
|
||||||
| Нет примитива в `@cfdm/ui` | `search` → `get_add_command_for_items` → add |
|
| Нет примитива в `@cfdm/ui` | `plugin-shadcn-shadcn` → add |
|
||||||
| Сомнение в API/props | `get_item_examples_from_registries` |
|
| Сомнение в props примитива | examples + docs CLI |
|
||||||
| Ревью UI перед сдачей | `get_audit_checklist` |
|
|
||||||
|
|
||||||
## Запрещено
|
## Запрещено
|
||||||
|
|
||||||
- Писать UI по памяти, не проверив MCP
|
- Писать UI по памяти
|
||||||
- Копипаст с ui.shadcn.com без examples/add из MCP
|
- Ставить ui.shadcn.com/blocks выше ReUI PRO
|
||||||
- Самописные примитивы, если есть item в registry
|
- Самописные примитивы при наличии registry item
|
||||||
- Пропускать MCP «потому что компонент простой»
|
|
||||||
|
|
||||||
## Сервер и инструменты
|
## Сервер
|
||||||
|
|
||||||
- **MCP server:** `plugin-shadcn-shadcn`
|
- **MCP:** `plugin-shadcn-shadcn` (+ `user-reui` primary)
|
||||||
- **Инструменты:** `get_project_registries`, `search_items_in_registries`, `get_item_examples_from_registries`, `get_add_command_for_items`, `view_items_in_registries`, `list_items_in_registries`, `get_audit_checklist`
|
- Schema: `mcps/plugin-shadcn-shadcn/tools/`
|
||||||
- Перед вызовом — прочитать schema в `mcps/plugin-shadcn-shadcn/tools/`
|
|
||||||
|
|
||||||
Связанные правила: [`shadcn-ui-production.mdc`](shadcn-ui-production.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc).
|
Связанные: [`shadcn-ui-production.mdc`](shadcn-ui-production.mdc), [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-shadcn.mdc`](frontend-shadcn.mdc).
|
||||||
@@ -1,26 +1,33 @@
|
|||||||
---
|
---
|
||||||
description: shadcn/ui — глобальные UI-принципы проекта; frontend см. frontend-shadcn.mdc
|
description: shadcn/ui + ReUI PRO — глобальные UI-принципы; ReUI PRO выше shadcn
|
||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# shadcn/ui — правила проекта
|
# shadcn/ui + ReUI PRO — правила проекта
|
||||||
|
|
||||||
UI строится **исключительно** по [shadcn/ui](https://ui.shadcn.com/docs/installation): [Components](https://ui.shadcn.com/docs/components), [Blocks](https://ui.shadcn.com/blocks), [Monorepo](https://ui.shadcn.com/docs/monorepo).
|
UI строится по [shadcn/ui](https://ui.shadcn.com/docs/installation) + **ReUI PRO** (`@reui`): [Components](https://ui.shadcn.com/docs/components), [Blocks](https://ui.shadcn.com/blocks), [ReUI Get Started](https://reui.io/docs/get-started), [llms.txt](https://reui.io/llms.txt).
|
||||||
|
|
||||||
**Первый шаг любой UI-задачи — MCP `plugin-shadcn-shadcn`** (см. [`shadcn-mcp.mdc`](shadcn-mcp.mdc)): search → examples → add command → CLI.
|
**Иерархия:** ReUI PRO (`user-reui`) **выше** базового shadcn. Pages / KPI / lists / settings / shell → ReUI; shadcn — primitives.
|
||||||
|
|
||||||
## Разработка frontend
|
**Первый шаг UI-задачи:**
|
||||||
|
1. MCP **`user-reui`** для pages / KPI / data-grid / settings / Frame / Quick Actions (`surface: frame`)
|
||||||
|
2. MCP **`plugin-shadcn-shadcn`** для primitives `@shadcn`
|
||||||
|
|
||||||
**Все правила frontend** — в [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc) и [`frontend-monorepo.mdc`](frontend-monorepo.mdc) (globs: `apps/web/**`, `packages/ui/**`).
|
См. [`shadcn-mcp.mdc`](shadcn-mcp.mdc), [`reui-mcp.mdc`](reui-mcp.mdc). Contract: [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md).
|
||||||
|
|
||||||
Кратко: docs → CLI из `apps/web` → Block → композиция → `pnpm --filter web build`. Кастомный CSS и самописные примитивы **запрещены**.
|
## Frontend
|
||||||
|
|
||||||
|
Детали: [`frontend-shadcn.mdc`](frontend-shadcn.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc), [`frontend-monorepo.mdc`](frontend-monorepo.mdc).
|
||||||
|
|
||||||
|
Кратко: MCP → CLI из `apps/web` → Block → kit → `pnpm --filter web build`. Кастомный CSS и самописные примитивы **запрещены**.
|
||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
- Monorepo: `apps/web` + `packages/ui` (`@cfdm/ui`), pnpm workspaces
|
- Monorepo: `apps/web` + `packages/ui` (`@cfdm/ui`), pnpm workspaces
|
||||||
- Vite + TanStack Router/Query + shadcn **base-nova**
|
- Vite + TanStack Router/Query + shadcn **base-nova** + ReUI **@reui**
|
||||||
- Конфиг: [`apps/web/components.json`](apps/web/components.json), [`packages/ui/components.json`](packages/ui/components.json)
|
- Конфиг: [`apps/web/components.json`](apps/web/components.json), [`packages/ui/components.json`](packages/ui/components.json)
|
||||||
- Тема: `pnpm dlx shadcn@latest apply b2fA --only theme -y` — единственный способ менять базовую тему `globals.css`; ReUI semantic tokens — [Styling](https://reui.io/docs/styling)
|
- Тема: `pnpm dlx shadcn@latest apply b2fA --only theme -y`; ReUI tokens — [Styling](https://reui.io/docs/styling)
|
||||||
|
- License: `REUI_LICENSE_KEY` в `apps/web/.env.local`
|
||||||
|
|
||||||
## Backend → UI
|
## Backend → UI
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
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 `0e224b0281`.** 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 `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 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
|
||||||
@@ -58,7 +58,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)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
@@ -1,24 +1,23 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
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.
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
## data-grid (the flagship - read its API every time)
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
// add sorting/pagination/selection models per the API
|
|
||||||
})
|
})
|
||||||
|
|
||||||
<DataGrid table={table} recordCount={data.length}>
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
@@ -28,9 +27,37 @@ const table = useReactTable({
|
|||||||
|
|
||||||
Common mistakes:
|
Common mistakes:
|
||||||
|
|
||||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
## kanban
|
## kanban
|
||||||
|
|
||||||
@@ -284,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
|||||||
|
|
||||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
|||||||
+48
-11
@@ -1,15 +1,52 @@
|
|||||||
|
# Build context = repository root (deploy/docker/docker-bake.hcl).
|
||||||
.git
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
.cursor
|
.cursor
|
||||||
data
|
.claude
|
||||||
**/target
|
.codegraph
|
||||||
|
.agents
|
||||||
|
.opencode
|
||||||
|
memory-bank
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
**/.DS_Store
|
||||||
|
**/Thumbs.db
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
!**/.env.*.example
|
||||||
|
|
||||||
|
node_modules
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/.turbo
|
|
||||||
**/dist
|
**/dist
|
||||||
**/*.md
|
**/.turbo
|
||||||
**/.git*
|
# Генерируется `tsr generate` в `web` build (tsc идёт до Vite-плагина).
|
||||||
frontend
|
apps/web/src/routeTree.gen.ts
|
||||||
*.db
|
apps/web/playwright-report
|
||||||
*.db-shm
|
apps/web/test-results
|
||||||
*.db-wal
|
|
||||||
.env
|
*.md
|
||||||
.env.*
|
AGENTS.md
|
||||||
|
CONTRIBUTING.md
|
||||||
|
LICENSE
|
||||||
|
docs
|
||||||
|
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
.releaserc.json
|
||||||
|
.commitlintrc.*
|
||||||
|
commitlint.config.cjs
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
data
|
||||||
|
*.exe
|
||||||
|
**/*.test.ts
|
||||||
|
**/*.spec.ts
|
||||||
|
coverage
|
||||||
|
.coverage
|
||||||
|
*.out
|
||||||
|
.release-version
|
||||||
|
CHANGELOG.md
|
||||||
|
deploy/docker/docker-bake.override.hcl
|
||||||
|
deploy/compose/runtime-logs
|
||||||
+15
-4
@@ -4,15 +4,26 @@ CLOUDFLARE_API_TOKEN=
|
|||||||
# Database
|
# Database
|
||||||
DATABASE_URL=sqlite:data/app.db
|
DATABASE_URL=sqlite:data/app.db
|
||||||
|
|
||||||
# Auth
|
# Auth — portal SSO (prod) или локальный admin (dev)
|
||||||
JWT_SECRET=dev-secret-change-me
|
# AUTH_REQUIRED=true → JWT от auth-portal, apps включает cfdm
|
||||||
|
AUTH_REQUIRED=false
|
||||||
|
AUTH_JWT_SECRET=dev-secret-change-me
|
||||||
|
# alias: JWT_SECRET=
|
||||||
|
AUTH_ISSUER=https://auth.shnt.top
|
||||||
|
AUTH_PORTAL_URL=http://localhost:5175
|
||||||
|
# Shared with auth-portal AUDIT_INGEST_SECRET — dual-write audit to portal
|
||||||
|
AUTH_AUDIT_INGEST_SECRET=dev-audit-ingest-secret
|
||||||
JWT_TTL_HOURS=24
|
JWT_TTL_HOURS=24
|
||||||
|
|
||||||
|
# Legacy local login (только при AUTH_REQUIRED=false)
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
# Leave empty for dev default password "admin"
|
# Leave empty for dev default password "admin"
|
||||||
ADMIN_PASSWORD_HASH=
|
ADMIN_PASSWORD_HASH=
|
||||||
|
|
||||||
# Frontend (Vite)
|
# Frontend (Vite) — apps/web/.env.local
|
||||||
# Публичные URL приложений и integration token настраиваются в UI: Настройки → Интеграции
|
# VITE_AUTH_ENABLED=true
|
||||||
|
# VITE_AUTH_PORTAL_URL=http://localhost:5175
|
||||||
|
# Публичные URL приложений и integration token: Настройки → Интеграции
|
||||||
|
|
||||||
# ReUI PRO (apps/web/components.json → @reui Authorization)
|
# ReUI PRO (apps/web/components.json → @reui Authorization)
|
||||||
# Ключ: https://reui.io/docs/license-setup — класть в .env.local (gitignored)
|
# Ключ: https://reui.io/docs/license-setup — класть в .env.local (gitignored)
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Gitea Actions
|
||||||
|
|
||||||
|
| Workflow | Когда | Что |
|
||||||
|
|----------|--------|-----|
|
||||||
|
| [workflows/ci.yaml](workflows/ci.yaml) | pull request в main/master | quality gates + commitlint |
|
||||||
|
| [workflows/cd.yaml](workflows/cd.yaml) | push в main/master | quality gates + semantic-release + docker push; wiki параллельно |
|
||||||
|
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, web, api, commitlint, docker-check |
|
||||||
|
|
||||||
|
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||||
|
|
||||||
|
## CI (quality gates)
|
||||||
|
|
||||||
|
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, корневой `package.json` / `pnpm-lock.yaml` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` quality jobs не запускают (wiki — отдельный job в CD).
|
||||||
|
|
||||||
|
На **pull request** — **commitlint**. При изменении `deploy/docker/**` / `.dockerignore` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
|
||||||
|
|
||||||
|
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
|
||||||
|
|
||||||
|
Кэшируется целиком: pnpm store + `node_modules` + corepack. При hit: `pnpm install --offline`. `HUSKY=0` в `scripts/ci/pnpm-ci.sh` (у корня есть `prepare: husky`).
|
||||||
|
|
||||||
|
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
|
||||||
|
|
||||||
|
Runner: `ubuntu-latest`, Docker для **docker-check** (PR) и **publish** (CD).
|
||||||
|
|
||||||
|
## CD (job publish)
|
||||||
|
|
||||||
|
После успешных quality gates на **push в main** job **publish**:
|
||||||
|
|
||||||
|
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||||
|
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
||||||
|
3. Зеркало base-образов в `cfdm-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
|
||||||
|
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `cfdm` (`cleanup: false`).
|
||||||
|
|
||||||
|
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||||
|
|
||||||
|
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
||||||
|
|
||||||
|
Job **update-wiki** идёт **параллельно** publish (не блокирует образы): при diff `docs/Home.md` копирует файл в wiki-репозиторий. Clone/push идут с `Authorization: Basic oauth2:<token>` — после clone git вырезает токен из `origin`, без header Gitea отвечает `Repository not found` (часто на внутреннем `GITEA_INSTANCE_URL` раннера). Секрет: **`GITEA_TOKEN`**, fallback **`ACTIONS_PAT`**.
|
||||||
|
|
||||||
|
### Секреты
|
||||||
|
|
||||||
|
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `gitea.token`. Push OCI — **только PAT** (у job token Gitea нет права packages).
|
||||||
|
|
||||||
|
**`GITEA_TOKEN`**: clone/push wiki.
|
||||||
|
|
||||||
|
### Теги образов
|
||||||
|
|
||||||
|
```text
|
||||||
|
git.shx.one/<owner>/cfdm:latest
|
||||||
|
git.shx.one/<owner>/cfdm:v1.2.3
|
||||||
|
git.shx.one/<owner>/cfdm:1.2.3
|
||||||
|
git.shx.one/<owner>/cfdm:<short-sha>
|
||||||
|
git.shx.one/<owner>/cfdm:sha-<full-sha>
|
||||||
|
```
|
||||||
|
|
||||||
|
Тот же манифест публикуется как `cloudflare-domain-manager` (drop-in для текущего `docker pull`).
|
||||||
|
|
||||||
|
Кэш сборки: `cfdm-buildcache:node-buildcache` и `cfdm-buildcache:base-*`.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull git.shx.one/denozord/cfdm:latest
|
||||||
|
docker pull git.shx.one/denozord/cloudflare-domain-manager:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
См. [deploy/docker/README.md](../deploy/docker/README.md).
|
||||||
|
|
||||||
|
## act_runner: cache server
|
||||||
|
|
||||||
|
`actions/cache` ходит в **встроенный cache server** runner (не GitHub `type=gha`). Кэш локален для этого runner.
|
||||||
|
|
||||||
|
В `config.yaml` runner:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
dir: "" # по умолчанию $HOME/.cache/actcache
|
||||||
|
host: "" # IP, доступный из job-контейнера (не 0.0.0.0)
|
||||||
|
port: 8088
|
||||||
|
```
|
||||||
|
|
||||||
|
Если runner в Docker, а jobs — отдельные контейнеры: пробросьте порт и задайте `host` (LAN IP хоста) или `external_server: "http://<host>:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова.
|
||||||
|
|
||||||
|
Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет.
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
name: CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
uses: ./.gitea/workflows/quality.yaml
|
||||||
|
with:
|
||||||
|
is_pull_request: false
|
||||||
|
before_sha: ${{ github.event.before }}
|
||||||
|
head_sha: ${{ github.sha }}
|
||||||
|
allow_registry_login: false
|
||||||
|
secrets:
|
||||||
|
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
|
||||||
|
update-wiki:
|
||||||
|
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout main repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
- name: Check for changes in documentation
|
||||||
|
id: check_changes
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if ! git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||||
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
elif git diff HEAD~1 HEAD --quiet -- docs/Home.md; then
|
||||||
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
- name: Update and push Wiki content
|
||||||
|
if: steps.check_changes.outputs.changed == 'true'
|
||||||
|
env:
|
||||||
|
WIKI_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.ACTIONS_PAT }}
|
||||||
|
SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${WIKI_TOKEN:-}" ]; then
|
||||||
|
echo "GITEA_TOKEN / ACTIONS_PAT is empty — cannot push wiki"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# runner GITEA_INSTANCE_URL часто внутренний (http://192.168.x.x:3000).
|
||||||
|
# git после clone вырезает userinfo из origin → push без токена даёт 404
|
||||||
|
# «Repository not found». Authorization header переживает insteadOf/sanitize.
|
||||||
|
WIKI_URL="${SERVER_URL}/${REPO}.wiki.git"
|
||||||
|
AUTH_HEADER="Authorization: Basic $(printf '%s' "oauth2:${WIKI_TOKEN}" | base64 | tr -d '\n')"
|
||||||
|
git -c http.extraHeader="${AUTH_HEADER}" clone "${WIKI_URL}" cfdm.wiki
|
||||||
|
cp docs/Home.md cfdm.wiki/Home.md
|
||||||
|
cd cfdm.wiki
|
||||||
|
git config user.name "Gitea Actions"
|
||||||
|
git config user.email "actions@gitea"
|
||||||
|
git add Home.md
|
||||||
|
if git diff --staged --quiet; then
|
||||||
|
echo "Wiki Home.md already up to date"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git commit -m "docs: Update Wiki from main repository"
|
||||||
|
git -c http.extraHeader="${AUTH_HEADER}" push origin HEAD
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: [quality]
|
||||||
|
if: >-
|
||||||
|
always() &&
|
||||||
|
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||||
|
needs.quality.result == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
releases: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||||
|
persist-credentials: true
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
apps/api/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
packages/shared/node_modules
|
||||||
|
packages/db/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: Install release tooling
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: sh scripts/ci/pnpm-ci.sh
|
||||||
|
- name: Verify releasable commit messages
|
||||||
|
run: pnpm exec node scripts/commit/verify-release-commits.mjs
|
||||||
|
- name: Semantic release
|
||||||
|
run: pnpm exec semantic-release
|
||||||
|
env:
|
||||||
|
GITEA_URL: https://git.shx.one
|
||||||
|
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||||
|
- name: Detect new release
|
||||||
|
id: rel
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version=""
|
||||||
|
if [ -f .release-version ]; then
|
||||||
|
version="$(tr -d '[:space:]' < .release-version)"
|
||||||
|
echo "New release from semantic-release: $version"
|
||||||
|
else
|
||||||
|
git fetch --tags --force origin || true
|
||||||
|
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
|
||||||
|
if [ -n "${tag:-}" ]; then
|
||||||
|
version="${tag#v}"
|
||||||
|
echo "Reuse existing tag $tag on HEAD (release retry)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -n "${version:-}" ]; then
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No releasable commits — skipping image publish"
|
||||||
|
fi
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
with:
|
||||||
|
name: cfdm
|
||||||
|
driver: docker-container
|
||||||
|
cleanup: false
|
||||||
|
- name: Prepare image metadata
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||||
|
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||||
|
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||||
|
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
registry: git.shx.one
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
- name: Mirror base images into buildcache
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||||
|
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||||
|
run: sh deploy/docker/mirror-base-images.sh
|
||||||
|
- name: Build and push images (bake)
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||||
|
IMAGE_TAG: latest
|
||||||
|
VERSION: ${{ steps.meta.outputs.version }}
|
||||||
|
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||||
|
SHA_FULL: ${{ github.sha }}
|
||||||
|
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||||
|
CACHE_REF_NODE: git.shx.one/${{ steps.meta.outputs.owner_lc }}/cfdm-buildcache:node-buildcache
|
||||||
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
|
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||||
|
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||||
|
working-directory: deploy/docker
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
if [ -f "${MIRROR_ENV_FILE}" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "${MIRROR_ENV_FILE}"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||||
|
-f docker-bake.hcl default --push
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
uses: ./.gitea/workflows/quality.yaml
|
||||||
|
with:
|
||||||
|
is_pull_request: true
|
||||||
|
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||||
|
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||||
|
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
|
secrets:
|
||||||
|
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
name: Build, Test, and Push CFDM Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, develop, 'feature/**', 'release/**', 'hotfix/**']
|
|
||||||
tags: ['v*']
|
|
||||||
paths: ['**']
|
|
||||||
pull_request:
|
|
||||||
branches: [main, develop]
|
|
||||||
paths: ['**']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 10.12.1
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Lint web
|
|
||||||
run: pnpm --filter web lint
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Build test stage
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile.test
|
|
||||||
load: true
|
|
||||||
tags: cfdm:test
|
|
||||||
provenance: false
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: docker run --rm cfdm:test
|
|
||||||
|
|
||||||
build-and-push:
|
|
||||||
needs: test
|
|
||||||
if: startsWith(gitea.ref, 'refs/tags/v') || (gitea.ref_name == 'main' && gitea.event_name == 'push')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to Gitea Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.shts.su
|
|
||||||
username: ${{ gitea.actor }}
|
|
||||||
password: ${{ secrets.ACTIONS_PAT }}
|
|
||||||
|
|
||||||
- name: Create version file
|
|
||||||
run: |
|
|
||||||
VERSION=$(cat VERSION)
|
|
||||||
BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
|
||||||
BRANCH="${{ gitea.ref_name }}"
|
|
||||||
echo "APP_VERSION=${VERSION}" > ./version.txt
|
|
||||||
echo "BUILD_DATE=${BUILD_DATE}" >> ./version.txt
|
|
||||||
echo "GIT_BRANCH=${BRANCH}" >> ./version.txt
|
|
||||||
echo "GIT_COMMIT=${{ gitea.sha }}" >> ./version.txt
|
|
||||||
echo "GIT_COMMIT_SHORT=$(echo ${{ gitea.sha }} | cut -c1-7)" >> ./version.txt
|
|
||||||
echo "BUILD_TIMESTAMP=$(date -u +%s)" >> ./version.txt
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: git.shts.su/${{ gitea.repository }}
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=raw,value=latest,enable=${{ gitea.ref_name == 'main' }}
|
|
||||||
type=sha,prefix={{date 'YYYYMMDD'}}-,enable=${{ gitea.ref_name == 'main' }}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
build-args: |
|
|
||||||
NODE_VERSION=22.23.0-bookworm-slim
|
|
||||||
cache-from: type=registry,ref=git.shts.su/${{ gitea.repository }}:buildcache
|
|
||||||
cache-to: type=registry,ref=git.shts.su/${{ gitea.repository }}:buildcache,mode=max
|
|
||||||
provenance: true
|
|
||||||
|
|
||||||
create-release:
|
|
||||||
needs: build-and-push
|
|
||||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Generate changelog
|
|
||||||
id: changelog
|
|
||||||
run: |
|
|
||||||
LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
|
||||||
if [[ -n "$LAST_TAG" ]]; then
|
|
||||||
CHANGELOG=$(git log --pretty=format:"- **%h** %s (%an, %ar)" --no-merges ${LAST_TAG}..HEAD 2>/dev/null || echo "")
|
|
||||||
else
|
|
||||||
CHANGELOG=$(git log --pretty=format:"- **%h** %s (%an, %ar)" --no-merges -10 2>/dev/null || echo "")
|
|
||||||
fi
|
|
||||||
if [[ -z "$CHANGELOG" ]]; then CHANGELOG="- No changes detected"; fi
|
|
||||||
echo "CHANGELOG<<EOF" >> $GITEA_OUTPUT
|
|
||||||
echo "$CHANGELOG" >> $GITEA_OUTPUT
|
|
||||||
echo "EOF" >> $GITEA_OUTPUT
|
|
||||||
|
|
||||||
- name: Create Release
|
|
||||||
run: |
|
|
||||||
VERSION="${{ gitea.ref_name }}"
|
|
||||||
RELEASE_DATA=$(cat <<EOF
|
|
||||||
{
|
|
||||||
"tag_name": "${VERSION}",
|
|
||||||
"target_commitish": "${{ gitea.sha }}",
|
|
||||||
"name": "Cloudflare Domain Manager ${VERSION}",
|
|
||||||
"body": "## Cloudflare Domain Manager ${VERSION}\n\n### Docker\n\n\`\`\`bash\ndocker pull git.shts.su/${{ gitea.repository }}:${VERSION}\ndocker run -d -p 8080:8080 -v ./data:/data --name cfdm git.shts.su/${{ gitea.repository }}:${VERSION}\n\`\`\`\n\n### Changelog\n\n${{ steps.changelog.outputs.CHANGELOG }}",
|
|
||||||
"draft": false,
|
|
||||||
"prerelease": false
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
)
|
|
||||||
HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/release.json -X POST \
|
|
||||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$RELEASE_DATA" \
|
|
||||||
"${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}/releases")
|
|
||||||
if [ "$HTTP_CODE" -eq 201 ] || [ "$HTTP_CODE" -eq 409 ]; then
|
|
||||||
echo "Release OK (HTTP $HTTP_CODE)"
|
|
||||||
else
|
|
||||||
cat /tmp/release.json
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
update-wiki:
|
|
||||||
needs: test
|
|
||||||
if: gitea.ref_name == 'main' && gitea.event_name == 'push'
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout main repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- name: Check for changes in documentation
|
|
||||||
id: check_changes
|
|
||||||
run: |
|
|
||||||
if git diff HEAD~1 HEAD --quiet -- docs/Home.md; then
|
|
||||||
echo "changed=false" >> $GITEA_OUTPUT
|
|
||||||
else
|
|
||||||
echo "changed=true" >> $GITEA_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Clone Wiki repository
|
|
||||||
if: steps.check_changes.outputs.changed == 'true'
|
|
||||||
run: |
|
|
||||||
WIKI_URL=$(echo "${{ gitea.server_url }}/${{ gitea.repository }}.wiki.git" | sed -e "s|://|://gitea-actions:${{ secrets.GITEA_TOKEN }}@|")
|
|
||||||
git clone "${WIKI_URL}" cfdm.wiki
|
|
||||||
|
|
||||||
- name: Update and push Wiki content
|
|
||||||
if: steps.check_changes.outputs.changed == 'true'
|
|
||||||
run: |
|
|
||||||
cp docs/Home.md cfdm.wiki/Home.md
|
|
||||||
cd cfdm.wiki
|
|
||||||
git config user.name "Gitea Actions"
|
|
||||||
git config user.email "actions@gitea"
|
|
||||||
git add Home.md
|
|
||||||
git diff --staged --quiet || git commit -m "docs: Update Wiki from main repository"
|
|
||||||
git push
|
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
# Quality gates (reusable). Callers: ci.yaml (PR), cd.yaml (push main).
|
||||||
|
name: quality
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
is_pull_request:
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
base_sha:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
head_sha:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
before_sha:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
allow_registry_login:
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
secrets:
|
||||||
|
ACTIONS_PAT:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
web: ${{ steps.detect.outputs.web }}
|
||||||
|
api: ${{ steps.detect.outputs.api }}
|
||||||
|
docker: ${{ steps.detect.outputs.docker }}
|
||||||
|
steps:
|
||||||
|
- if: ${{ inputs.is_pull_request }}
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
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:
|
||||||
|
IS_PR: ${{ inputs.is_pull_request }}
|
||||||
|
BASE_SHA: ${{ inputs.base_sha }}
|
||||||
|
HEAD_SHA: ${{ inputs.head_sha }}
|
||||||
|
BEFORE_SHA: ${{ inputs.before_sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
web=false
|
||||||
|
api=false
|
||||||
|
docker=false
|
||||||
|
|
||||||
|
set_all_flags_true() {
|
||||||
|
web=true
|
||||||
|
api=true
|
||||||
|
docker=true
|
||||||
|
}
|
||||||
|
|
||||||
|
write_outputs() {
|
||||||
|
for v in web api docker; do
|
||||||
|
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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)"
|
||||||
|
else
|
||||||
|
set_all_flags_true
|
||||||
|
write_outputs
|
||||||
|
echo "No parent commit — full pipeline (all modules)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||||
|
set_all_flags_true
|
||||||
|
write_outputs
|
||||||
|
echo "Empty diff — full pipeline fallback"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
full_pipeline=false
|
||||||
|
|
||||||
|
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||||
|
[ -z "${f:-}" ] && continue
|
||||||
|
case "$f" in
|
||||||
|
.gitea/workflows/*|scripts/*)
|
||||||
|
full_pipeline=true
|
||||||
|
;;
|
||||||
|
.cursor/*|.claude/*|.codegraph/*|.agents/*)
|
||||||
|
;;
|
||||||
|
*.md|AGENTS.md)
|
||||||
|
;;
|
||||||
|
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||||
|
;;
|
||||||
|
apps/web/*|packages/ui/*)
|
||||||
|
web=true
|
||||||
|
;;
|
||||||
|
apps/api/*|packages/db/*)
|
||||||
|
api=true
|
||||||
|
;;
|
||||||
|
packages/shared/*)
|
||||||
|
web=true
|
||||||
|
api=true
|
||||||
|
;;
|
||||||
|
deploy/compose/*|deploy/docker/*|.dockerignore|docker-compose.yml)
|
||||||
|
docker=true
|
||||||
|
;;
|
||||||
|
docs/*)
|
||||||
|
;;
|
||||||
|
package.json|pnpm-lock.yaml|pnpm-workspace.yaml|turbo.json|.releaserc.json|commitlint.config.cjs)
|
||||||
|
full_pipeline=true
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "$FILES"
|
||||||
|
|
||||||
|
if $full_pipeline; then
|
||||||
|
set_all_flags_true
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_outputs
|
||||||
|
|
||||||
|
echo "Changed files (first 30):"
|
||||||
|
printf '%s\n' "$FILES" | head -n 30
|
||||||
|
echo "--- flags ---"
|
||||||
|
echo "web=$web api=$api docker=$docker full_pipeline=$full_pipeline"
|
||||||
|
|
||||||
|
web:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.web == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
apps/api/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
packages/shared/node_modules
|
||||||
|
packages/db/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: pnpm install, lint, test, build
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm --filter web lint
|
||||||
|
pnpm exec turbo run test --filter=web
|
||||||
|
pnpm exec turbo run build --filter=web
|
||||||
|
|
||||||
|
api:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.api == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
apps/api/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
packages/shared/node_modules
|
||||||
|
packages/db/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: pnpm install, test, build
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm exec turbo run test --filter=@cfdm/api
|
||||||
|
pnpm exec turbo run build --filter=@cfdm/api
|
||||||
|
|
||||||
|
commitlint:
|
||||||
|
if: inputs.is_pull_request
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
apps/api/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
packages/shared/node_modules
|
||||||
|
packages/db/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: Lint commit messages
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ inputs.base_sha }}
|
||||||
|
HEAD_SHA: ${{ inputs.head_sha }}
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||||
|
|
||||||
|
docker-check:
|
||||||
|
needs: [changes]
|
||||||
|
if: inputs.is_pull_request && needs.changes.outputs.docker == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
with:
|
||||||
|
name: cfdm
|
||||||
|
driver: docker-container
|
||||||
|
cleanup: false
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
if: inputs.allow_registry_login
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
registry: git.shx.one
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
- name: bake --print
|
||||||
|
working-directory: deploy/docker
|
||||||
|
env:
|
||||||
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
|
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||||
|
run: docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --print default
|
||||||
|
- name: bake (no push)
|
||||||
|
if: inputs.allow_registry_login
|
||||||
|
working-directory: deploy/docker
|
||||||
|
env:
|
||||||
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
|
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
export CACHE_REF_NODE="git.shx.one/${owner_lc}/cfdm-buildcache:node-buildcache"
|
||||||
|
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
|
||||||
@@ -32,3 +32,10 @@ Thumbs.db
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
version.txt
|
version.txt
|
||||||
|
.release-version
|
||||||
|
CHANGELOG.md
|
||||||
|
.codegraph/daemon.pid
|
||||||
|
|
||||||
|
# Local MCP configs (may contain REUI license Bearer)
|
||||||
|
.cursor/mcp.json
|
||||||
|
.mcp.json
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"codegraph": {
|
|
||||||
"type": "stdio",
|
|
||||||
"command": "codegraph",
|
|
||||||
"args": [
|
|
||||||
"serve",
|
|
||||||
"--mcp"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"reui": {
|
|
||||||
"type": "http",
|
|
||||||
"url": "https://mcp.reui.io/api/mcp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
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 `0e224b0281`.** 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 `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 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
|
||||||
@@ -58,7 +58,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)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Registry setup (one-time, per project)
|
## Registry setup (one-time, per project)
|
||||||
|
|
||||||
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
|
||||||
@@ -29,6 +29,8 @@ REUI_LICENSE_KEY=your-license-key
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`, but an MCP client config never expands variables, so a ReUI MCP server config must carry the raw token instead (for example `reui_pat_your_token_here`).
|
||||||
|
|
||||||
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
@@ -52,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
|
|||||||
|
|
||||||
## Free vs premium boundary
|
## Free vs premium boundary
|
||||||
|
|
||||||
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
- Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
|
||||||
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
|
||||||
|
|
||||||
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
|
||||||
@@ -1,24 +1,23 @@
|
|||||||
# ReUI components
|
# ReUI components
|
||||||
|
|
||||||
The 17 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `filters`, `frame`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
|
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.
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
## data-grid (the flagship - read its API every time)
|
## data-grid (the flagship - read its API every time)
|
||||||
|
|
||||||
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
|
||||||
|
|
||||||
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection).
|
- Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
|
||||||
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
|
||||||
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
|
||||||
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const table = useReactTable({
|
const table = useTable({
|
||||||
|
features: dataGridFeatures,
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
// add sorting/pagination/selection models per the API
|
|
||||||
})
|
})
|
||||||
|
|
||||||
<DataGrid table={table} recordCount={data.length}>
|
<DataGrid table={table} recordCount={data.length}>
|
||||||
@@ -28,9 +27,37 @@ const table = useReactTable({
|
|||||||
|
|
||||||
Common mistakes:
|
Common mistakes:
|
||||||
|
|
||||||
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`.
|
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
|
||||||
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
|
||||||
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`).
|
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
|
||||||
|
|
||||||
|
## event-calendar
|
||||||
|
|
||||||
|
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
|
||||||
|
<EventCalendarNav />
|
||||||
|
<EventCalendarContent />
|
||||||
|
</EventCalendar>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
|
||||||
|
|
||||||
|
## gantt
|
||||||
|
|
||||||
|
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
|
||||||
|
<GanttNav />
|
||||||
|
<GanttView />
|
||||||
|
</Gantt>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
|
||||||
|
|
||||||
## kanban
|
## kanban
|
||||||
|
|
||||||
@@ -284,6 +311,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
|
|||||||
|
|
||||||
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
|
||||||
|
|
||||||
|
## icon-tile
|
||||||
|
|
||||||
|
**Required:** one child icon
|
||||||
|
**Shape:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<IconTile variant="elevated" size="lg">
|
||||||
|
<PackageIcon />
|
||||||
|
</IconTile>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
|
||||||
|
|
||||||
## alert
|
## alert
|
||||||
|
|
||||||
**Required:** `Alert` > `AlertTitle`
|
**Required:** `Alert` > `AlertTitle`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
|
||||||
|
|
||||||
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
- **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
|
||||||
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
|
||||||
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
|
||||||
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
|
||||||
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
|
|||||||
|
|
||||||
## Free vs premium
|
## Free vs premium
|
||||||
|
|
||||||
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill.
|
- **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
|
||||||
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
|
||||||
|
|
||||||
## Component API index
|
## Component API index
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
|
|||||||
|
|
||||||
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
|
||||||
|
|
||||||
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
- `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
|
||||||
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
|
||||||
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
|
|||||||
|
|
||||||
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
|
||||||
|
|
||||||
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
|
||||||
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
|
||||||
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
|
||||||
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"branches": ["main", "master"],
|
||||||
|
"tagFormat": "v${version}",
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"@semantic-release/commit-analyzer",
|
||||||
|
{
|
||||||
|
"preset": "conventionalcommits",
|
||||||
|
"releaseRules": [
|
||||||
|
{ "type": "feat", "release": "minor" },
|
||||||
|
{ "type": "fix", "release": "patch" },
|
||||||
|
{ "type": "perf", "release": "patch" },
|
||||||
|
{ "type": "ci", "release": "patch" },
|
||||||
|
{ "type": "refactor", "release": "patch" },
|
||||||
|
{ "breaking": true, "release": "major" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/release-notes-generator",
|
||||||
|
{
|
||||||
|
"preset": "conventionalcommits"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/changelog",
|
||||||
|
{
|
||||||
|
"changelogFile": "CHANGELOG.md"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@semantic-release/exec",
|
||||||
|
{
|
||||||
|
"successCmd": "echo ${nextRelease.version} > .release-version"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"@markwylde/semantic-release-gitea",
|
||||||
|
{
|
||||||
|
"giteaUrl": "https://git.shx.one",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"path": "CHANGELOG.md",
|
||||||
|
"label": "Changelog"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Cloudflare Domain Manager — Руководство для ИИ
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
CFDM — self-hosted управление доменами, DNS и SSL через Cloudflare API. Группы доменов, сервисы, health-check, балансировка.
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
- Monorepo: pnpm + turbo (`apps/*`, `packages/*`)
|
||||||
|
- `apps/web` — Vite + React + TanStack + shadcn **base-nova** + ReUI Frame
|
||||||
|
- `apps/api` — Fastify + Drizzle + SQLite
|
||||||
|
- Packages: `@cfdm/ui`, `@cfdm/shared`, `@cfdm/db`
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm --filter @cfdm/shared build
|
||||||
|
pnpm --filter @cfdm/db build
|
||||||
|
pnpm --filter @cfdm/api dev
|
||||||
|
pnpm --filter web dev
|
||||||
|
pnpm --filter web build
|
||||||
|
```
|
||||||
|
|
||||||
|
## ReUI PRO
|
||||||
|
|
||||||
|
Surface: **frame** (`apps/web/src/lib/ui-surface.ts` → `UI_SURFACE = 'frame'`).
|
||||||
|
Hierarchy: **ReUI PRO > shadcn**. Contract: [`docs/ui-design-contract.md`](docs/ui-design-contract.md).
|
||||||
|
MCP `user-reui` primary + `plugin-shadcn-shadcn` primitives. CLI из `apps/web`.
|
||||||
|
KPI/QuickActions: EvoBGP hybrid SoT (`KpiStatGrid`, `QuickActionGrid`) + `IconTile` `elevated` `size-10.5`.
|
||||||
|
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills).
|
||||||
|
Skill: `.claude/skills/reui` (`668fb463eb`).
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
[`docs/Home.md`](docs/Home.md), [`CONTRIBUTING.md`](CONTRIBUTING.md), [`docs/ui-design-contract.md`](docs/ui-design-contract.md), [`docs/releasing.md`](docs/releasing.md).
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
Gitea Actions: [`.gitea/workflows`](.gitea/workflows) (`ci.yaml` / `cd.yaml` / `quality.yaml`). Образы — `docker buildx bake` из `deploy/docker` (runtime `node:22-alpine`, теги `cfdm` + `cloudflare-domain-manager`). Подробнее: [`.gitea/README.md`](.gitea/README.md).
|
||||||
+6
-9
@@ -17,23 +17,23 @@ Types: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`
|
|||||||
## Pull requests
|
## Pull requests
|
||||||
|
|
||||||
- Target `develop` for features; `main` for release/hotfix
|
- Target `develop` for features; `main` for release/hotfix
|
||||||
- CI `test` job must pass
|
- CI quality jobs (`web`, `api`) must pass
|
||||||
- At least one approval
|
- At least one approval
|
||||||
- Rebase/merge when up to date with target branch
|
- Rebase/merge when up to date with target branch
|
||||||
- Delete branch after merge
|
- Delete branch after merge
|
||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
- SemVer in [`VERSION`](VERSION)
|
- SemVer через [semantic-release](https://semantic-release.gitbook.io/) на push в `main`
|
||||||
- Bump version in `release/*` or `hotfix/*` branches
|
- Не править версию вручную; тег `vX.Y.Z` ставит CI
|
||||||
- Tag `vX.Y.Z` on `main` after release merge
|
- Подробнее: [`docs/releasing.md`](docs/releasing.md)
|
||||||
|
|
||||||
## Branch protection (Gitea)
|
## Branch protection (Gitea)
|
||||||
|
|
||||||
Configure for **main** and **develop**:
|
Configure for **main** and **develop**:
|
||||||
|
|
||||||
- Require pull request reviews
|
- Require pull request reviews
|
||||||
- Require status checks (`test`)
|
- Require status checks (`CI` / quality jobs `web`, `api`)
|
||||||
- Require branches to be up to date
|
- Require branches to be up to date
|
||||||
- No force push, no deletion
|
- No force push, no deletion
|
||||||
|
|
||||||
@@ -54,7 +54,4 @@ pnpm --filter web dev
|
|||||||
|
|
||||||
## Release process
|
## Release process
|
||||||
|
|
||||||
1. `git checkout -b release/v0.2.0 develop`
|
Релизы делает workflow **CD** на push в `main` (semantic-release + bake). Не создавать ветки `release/*` и не bump'ать файлы версии вручную. См. [`docs/releasing.md`](docs/releasing.md).
|
||||||
2. Bump `VERSION`, fix release issues
|
|
||||||
3. PR to `main` → merge → tag `v0.2.0`
|
|
||||||
4. Merge release branch back to `develop`
|
|
||||||
-52
@@ -1,52 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
ARG NODE_VERSION=22.23.0-bookworm-slim
|
|
||||||
|
|
||||||
FROM node:${NODE_VERSION} AS build
|
|
||||||
WORKDIR /app
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& corepack enable
|
|
||||||
|
|
||||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
|
|
||||||
COPY apps/web/package.json apps/web/
|
|
||||||
COPY apps/api/package.json apps/api/
|
|
||||||
COPY packages/ui/package.json packages/ui/
|
|
||||||
COPY packages/shared/package.json packages/shared/
|
|
||||||
COPY packages/db/package.json packages/db/
|
|
||||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY apps/web apps/web
|
|
||||||
COPY apps/api apps/api
|
|
||||||
COPY packages/ui packages/ui
|
|
||||||
COPY packages/shared packages/shared
|
|
||||||
COPY packages/db packages/db
|
|
||||||
|
|
||||||
RUN pnpm turbo build --filter=web --filter=@cfdm/api \
|
|
||||||
&& pnpm --filter @cfdm/api deploy --prod /out \
|
|
||||||
&& cp -r apps/web/dist /out/static \
|
|
||||||
&& rm -rf /out/src /out/test /out/.turbo \
|
|
||||||
&& rm -rf /out/node_modules/@cfdm/db/src /out/node_modules/@cfdm/db/scripts /out/node_modules/@cfdm/db/.turbo \
|
|
||||||
&& rm -rf /out/node_modules/@cfdm/shared/src /out/node_modules/@cfdm/shared/.turbo \
|
|
||||||
&& find /out/dist /out/node_modules/@cfdm -type f \( -name '*.d.ts' -o -name '*.map' -o -name 'tsconfig*.json' -o -name 'vitest.config.ts' -o -name 'drizzle.config.ts' \) -delete
|
|
||||||
|
|
||||||
FROM node:${NODE_VERSION}
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production \
|
|
||||||
STATIC_DIR=/app/static \
|
|
||||||
DATABASE_URL=sqlite:/data/app.db \
|
|
||||||
SERVER_PORT=8080
|
|
||||||
|
|
||||||
COPY --from=build /out ./
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
VOLUME ["/data"]
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
||||||
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
|
||||||
CMD ["node", "dist/server.js"]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
ARG NODE_VERSION=22.23.0-bookworm-slim
|
|
||||||
|
|
||||||
FROM node:${NODE_VERSION} AS test
|
|
||||||
WORKDIR /app
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& corepack enable
|
|
||||||
|
|
||||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
|
|
||||||
COPY apps/web/package.json apps/web/
|
|
||||||
COPY apps/api/package.json apps/api/
|
|
||||||
COPY packages/ui/package.json packages/ui/
|
|
||||||
COPY packages/shared/package.json packages/shared/
|
|
||||||
COPY packages/db/package.json packages/db/
|
|
||||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY apps/web apps/web
|
|
||||||
COPY apps/api apps/api
|
|
||||||
COPY packages/ui packages/ui
|
|
||||||
COPY packages/shared packages/shared
|
|
||||||
COPY packages/db packages/db
|
|
||||||
|
|
||||||
RUN pnpm turbo build --filter=@cfdm/shared --filter=@cfdm/db --filter=@cfdm/api
|
|
||||||
CMD ["sh", "-c", "pnpm --filter @cfdm/api test && pnpm --filter web test"]
|
|
||||||
@@ -28,7 +28,7 @@ Open http://localhost:8080 — default login `admin` / `admin` (dev only).
|
|||||||
- **Backend:** Node.js 22, Fastify 5, Drizzle ORM, SQLite (`apps/api`)
|
- **Backend:** Node.js 22, Fastify 5, Drizzle ORM, SQLite (`apps/api`)
|
||||||
- **Shared:** Zod schemas (`packages/shared`), DB layer (`packages/db`)
|
- **Shared:** Zod schemas (`packages/shared`), DB layer (`packages/db`)
|
||||||
- **Frontend:** pnpm monorepo (`apps/web` + `packages/ui`), React, Vite, TanStack Router/Query/Table, shadcn/ui (base-nova), Recharts
|
- **Frontend:** pnpm monorepo (`apps/web` + `packages/ui`), React, Vite, TanStack Router/Query/Table, shadcn/ui (base-nova), Recharts
|
||||||
- **CI:** Gitea Actions (Gitflow)
|
- **CI:** Gitea Actions ([docs/releasing.md](docs/releasing.md)) — quality gates + semantic-release + bake (`cfdm` / `cloudflare-domain-manager`)
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ shadcn CLI: `cd apps/web && pnpm dlx shadcn@latest add <component>`
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
See [docs/Home.md](docs/Home.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
|
See [docs/Home.md](docs/Home.md), [docs/releasing.md](docs/releasing.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
## Load balancing & health checks
|
## Load balancing & health checks
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from "./routes/domain-monitors.js";
|
} from "./routes/domain-monitors.js";
|
||||||
import { settingsRoutes } from "./routes/settings.js";
|
import { settingsRoutes } from "./routes/settings.js";
|
||||||
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
|
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
|
||||||
|
import { auditRoutes } from "./routes/audit.js";
|
||||||
import * as certificateService from "./services/certificate-service.js";
|
import * as certificateService from "./services/certificate-service.js";
|
||||||
import * as healthCheckService from "./services/health-check-service.js";
|
import * as healthCheckService from "./services/health-check-service.js";
|
||||||
import * as serviceConfigService from "./services/service-config-service.js";
|
import * as serviceConfigService from "./services/service-config-service.js";
|
||||||
@@ -83,6 +84,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
await protectedApi.register(domainMonitorRoutes);
|
await protectedApi.register(domainMonitorRoutes);
|
||||||
await protectedApi.register(notificationRoutes);
|
await protectedApi.register(notificationRoutes);
|
||||||
await protectedApi.register(settingsRoutes);
|
await protectedApi.register(settingsRoutes);
|
||||||
|
await protectedApi.register(auditRoutes);
|
||||||
},
|
},
|
||||||
{ prefix: "/api/v1" },
|
{ prefix: "/api/v1" },
|
||||||
);
|
);
|
||||||
@@ -131,6 +133,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
};
|
};
|
||||||
const n = await healthCheckService.runAllChecks(app.db, {
|
const n = await healthCheckService.runAllChecks(app.db, {
|
||||||
thresholds,
|
thresholds,
|
||||||
|
probeGapMs: config.healthProbeGapMs,
|
||||||
onStatusChange: async (target, prev, next) => {
|
onStatusChange: async (target, prev, next) => {
|
||||||
try {
|
try {
|
||||||
const label =
|
const label =
|
||||||
|
|||||||
+34
-2
@@ -14,14 +14,33 @@ export interface AppConfig {
|
|||||||
healthDegradedFailures: number;
|
healthDegradedFailures: number;
|
||||||
healthDownFailures: number;
|
healthDownFailures: number;
|
||||||
healthLatencyWarnMs: number;
|
healthLatencyWarnMs: number;
|
||||||
|
/** Min pause between probes to different physical targets (same IP is probed once). */
|
||||||
|
healthProbeGapMs: number;
|
||||||
logLevel: string;
|
logLevel: string;
|
||||||
|
/** Portal SSO — when true, require portal JWT with apps includes cfdm */
|
||||||
|
authRequired: boolean;
|
||||||
|
authIssuer: string;
|
||||||
|
authPortalUrl: string;
|
||||||
|
/** Bearer secret for POST {authPortalUrl}/api/v1/ingest/audit */
|
||||||
|
authAuditIngestSecret: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolEnv(v: string | undefined, fallback: boolean): boolean {
|
||||||
|
if (v === undefined || v === "") return fallback;
|
||||||
|
return v === "1" || v.toLowerCase() === "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadConfig(): AppConfig {
|
export function loadConfig(): AppConfig {
|
||||||
|
const isProd = process.env.NODE_ENV === "production";
|
||||||
|
const jwtSecret =
|
||||||
|
process.env.AUTH_JWT_SECRET ??
|
||||||
|
process.env.JWT_SECRET ??
|
||||||
|
(isProd ? "" : "dev-secret-change-me");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db",
|
databaseUrl: process.env.DATABASE_URL ?? "sqlite:data/app.db",
|
||||||
cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim(),
|
cloudflareApiToken: (process.env.CLOUDFLARE_API_TOKEN ?? "").trim(),
|
||||||
jwtSecret: process.env.JWT_SECRET ?? "dev-secret-change-me",
|
jwtSecret: jwtSecret || "dev-secret-change-me",
|
||||||
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24,
|
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? "24") || 24,
|
||||||
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
|
adminUsername: process.env.ADMIN_USERNAME ?? "admin",
|
||||||
adminPasswordHash:
|
adminPasswordHash:
|
||||||
@@ -31,12 +50,25 @@ export function loadConfig(): AppConfig {
|
|||||||
? resolve(process.env.STATIC_DIR)
|
? resolve(process.env.STATIC_DIR)
|
||||||
: null,
|
: null,
|
||||||
certCheckCron: process.env.CERT_CHECK_CRON ?? "0 0 */6 * * *",
|
certCheckCron: process.env.CERT_CHECK_CRON ?? "0 0 */6 * * *",
|
||||||
healthCheckCron: process.env.HEALTH_CHECK_CRON ?? "*/30 * * * * *",
|
// Default: every 2 minutes (was every 30s — hammered origins / anti-bot).
|
||||||
|
healthCheckCron: process.env.HEALTH_CHECK_CRON ?? "0 */2 * * * *",
|
||||||
healthDegradedFailures:
|
healthDegradedFailures:
|
||||||
Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1,
|
Number(process.env.HEALTH_DEGRADED_FAILURES ?? "1") || 1,
|
||||||
healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2,
|
healthDownFailures: Number(process.env.HEALTH_DOWN_FAILURES ?? "2") || 2,
|
||||||
healthLatencyWarnMs:
|
healthLatencyWarnMs:
|
||||||
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
|
Number(process.env.HEALTH_LATENCY_WARN_MS ?? "1000") || 1000,
|
||||||
|
healthProbeGapMs: Number(process.env.HEALTH_PROBE_GAP_MS ?? "2000") || 2000,
|
||||||
logLevel: process.env.LOG_LEVEL ?? "info",
|
logLevel: process.env.LOG_LEVEL ?? "info",
|
||||||
|
authRequired: boolEnv(process.env.AUTH_REQUIRED, false),
|
||||||
|
authIssuer:
|
||||||
|
process.env.AUTH_ISSUER ?? process.env.ISSUER ?? "https://auth.shnt.top",
|
||||||
|
authPortalUrl: (
|
||||||
|
process.env.AUTH_PORTAL_URL ??
|
||||||
|
process.env.VITE_AUTH_PORTAL_URL ??
|
||||||
|
"http://localhost:5175"
|
||||||
|
).replace(/\/$/, ""),
|
||||||
|
authAuditIngestSecret:
|
||||||
|
process.env.AUTH_AUDIT_INGEST_SECRET?.trim() ||
|
||||||
|
(!isProd ? "dev-audit-ingest-secret" : null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -32,8 +32,8 @@ export class AppError extends Error {
|
|||||||
return new AppError("UNAUTHORIZED", "unauthorized", 401);
|
return new AppError("UNAUTHORIZED", "unauthorized", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
static forbidden() {
|
static forbidden(message = "forbidden") {
|
||||||
return new AppError("FORBIDDEN", "forbidden", 403);
|
return new AppError("FORBIDDEN", message, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
static conflict(message: string) {
|
static conflict(message: string) {
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||||
|
import { appendAudit, type AppendAuditInput } from "@cfdm/db";
|
||||||
|
import { pushAuditEvents } from "../services/audit-portal-push.js";
|
||||||
|
|
||||||
|
export function clientIp(request: FastifyRequest): string | null {
|
||||||
|
const forwarded = request.headers["x-forwarded-for"];
|
||||||
|
if (typeof forwarded === "string" && forwarded.trim()) {
|
||||||
|
return forwarded.split(",")[0]?.trim() ?? null;
|
||||||
|
}
|
||||||
|
return request.ip ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actorFromRequest(
|
||||||
|
request: FastifyRequest,
|
||||||
|
): Pick<AppendAuditInput, "actorUserId" | "actorEmail" | "actorName"> {
|
||||||
|
const u = request.authUser;
|
||||||
|
if (u) {
|
||||||
|
return {
|
||||||
|
actorUserId: u.id,
|
||||||
|
actorEmail: u.email || null,
|
||||||
|
actorName: u.name || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = request.user;
|
||||||
|
if (payload?.sub) {
|
||||||
|
return {
|
||||||
|
actorUserId: String(payload.sub),
|
||||||
|
actorEmail: payload.email ? String(payload.email) : null,
|
||||||
|
actorName: payload.name ? String(payload.name) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
actorUserId: null,
|
||||||
|
actorEmail: null,
|
||||||
|
actorName: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordAuditInput = Omit<
|
||||||
|
AppendAuditInput,
|
||||||
|
| "sourceApp"
|
||||||
|
| "eventId"
|
||||||
|
| "ip"
|
||||||
|
| "actorUserId"
|
||||||
|
| "actorEmail"
|
||||||
|
| "actorName"
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** Local append + optional async push to auth-portal ingest. */
|
||||||
|
export function recordAudit(
|
||||||
|
app: FastifyInstance,
|
||||||
|
request: FastifyRequest,
|
||||||
|
input: RecordAuditInput,
|
||||||
|
): void {
|
||||||
|
const eventId = randomUUID();
|
||||||
|
const actor = actorFromRequest(request);
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const createdAt = input.createdAt ?? new Date().toISOString();
|
||||||
|
|
||||||
|
const full: AppendAuditInput = {
|
||||||
|
...input,
|
||||||
|
...actor,
|
||||||
|
eventId,
|
||||||
|
sourceApp: "cfdm",
|
||||||
|
ip,
|
||||||
|
createdAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
appendAudit(app.db, full);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn({ err }, "audit_log append failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = app.config.authAuditIngestSecret;
|
||||||
|
const portalUrl = app.config.authPortalUrl;
|
||||||
|
if (!secret || !portalUrl) return;
|
||||||
|
|
||||||
|
void pushAuditEvents(portalUrl, secret, [
|
||||||
|
{
|
||||||
|
event_id: eventId,
|
||||||
|
source_app: "cfdm",
|
||||||
|
action: input.action,
|
||||||
|
severity: input.severity,
|
||||||
|
actor_user_id: actor.actorUserId,
|
||||||
|
actor_email: actor.actorEmail,
|
||||||
|
actor_name: actor.actorName,
|
||||||
|
target_type: input.targetType,
|
||||||
|
target_id: input.targetId,
|
||||||
|
summary: input.summary,
|
||||||
|
details: input.details,
|
||||||
|
ip,
|
||||||
|
created_at: createdAt,
|
||||||
|
},
|
||||||
|
]).catch((err) => {
|
||||||
|
app.log.warn({ err, eventId }, "audit portal push failed");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
|
||||||
|
* Format: cfdm:<section>:<read|write|admin>
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
apps: string[];
|
||||||
|
permissions: string[];
|
||||||
|
isAdmin?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function hasPermission(
|
||||||
|
granted: readonly string[],
|
||||||
|
required: string,
|
||||||
|
): boolean {
|
||||||
|
if (granted.includes(required)) return true;
|
||||||
|
const parts = required.split(":");
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
const [app, section, action] = parts;
|
||||||
|
if (action === "read") {
|
||||||
|
return (
|
||||||
|
granted.includes(`${app}:${section}:write`) ||
|
||||||
|
granted.includes(`${app}:${section}:admin`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (action === "write") {
|
||||||
|
return granted.includes(`${app}:${section}:admin`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Rule = {
|
||||||
|
methods: string[];
|
||||||
|
match: (path: string) => boolean;
|
||||||
|
permission: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RULES: Rule[] = [
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/domains") ||
|
||||||
|
p.startsWith("/api/v1/domain-monitors") ||
|
||||||
|
p === "/api/v1/domain-monitors",
|
||||||
|
permission: "cfdm:domains:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/domains") ||
|
||||||
|
p.startsWith("/api/v1/domain-monitors"),
|
||||||
|
permission: "cfdm:domains:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"),
|
||||||
|
permission: "cfdm:dns:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/dns") || p.startsWith("/api/v1/subdomains"),
|
||||||
|
permission: "cfdm:dns:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/v1/certificates"),
|
||||||
|
permission: "cfdm:certificates:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) => p.startsWith("/api/v1/certificates"),
|
||||||
|
permission: "cfdm:certificates:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/groups") ||
|
||||||
|
p.startsWith("/api/v1/service-groups"),
|
||||||
|
permission: "cfdm:groups:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/groups") ||
|
||||||
|
p.startsWith("/api/v1/service-groups"),
|
||||||
|
permission: "cfdm:groups:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/services") ||
|
||||||
|
p.startsWith("/api/v1/service-bindings"),
|
||||||
|
permission: "cfdm:services:read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/services") ||
|
||||||
|
p.startsWith("/api/v1/service-bindings"),
|
||||||
|
permission: "cfdm:services:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET", "POST"],
|
||||||
|
match: (p) => p.startsWith("/api/v1/sync"),
|
||||||
|
permission: "cfdm:domains:write",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||||
|
match: (p) =>
|
||||||
|
p.startsWith("/api/v1/settings") ||
|
||||||
|
p.startsWith("/api/v1/notifications") ||
|
||||||
|
p.startsWith("/api/v1/health-check") ||
|
||||||
|
p.startsWith("/api/v1/health-checks"),
|
||||||
|
permission: "cfdm:settings:admin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
methods: ["GET"],
|
||||||
|
match: (p) => p.startsWith("/api/v1/audit"),
|
||||||
|
permission: "cfdm:settings:admin",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Resolve required permission for method+path, or null if public / unknown. */
|
||||||
|
export function permissionForRequest(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
): string | null {
|
||||||
|
const m = method.toUpperCase();
|
||||||
|
const pathname = path.split("?")[0] ?? path;
|
||||||
|
for (const rule of RULES) {
|
||||||
|
if (!rule.methods.includes(m)) continue;
|
||||||
|
if (rule.match(pathname)) return rule.permission;
|
||||||
|
}
|
||||||
|
// Default: any authenticated cfdm user for unmatched /api/v1/*
|
||||||
|
if (pathname.startsWith("/api/v1/")) return "cfdm:domains:read";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,28 +1,126 @@
|
|||||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import fp from "fastify-plugin";
|
import fp from "fastify-plugin";
|
||||||
import type { AppConfig } from "../config.js";
|
import type { AppConfig } from "../config.js";
|
||||||
import { AppError } from "../errors.js";
|
import { AppError } from "../errors.js";
|
||||||
|
import {
|
||||||
|
hasPermission,
|
||||||
|
permissionForRequest,
|
||||||
|
type AuthUser,
|
||||||
|
} from "../lib/permissions.js";
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
authUser?: AuthUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@fastify/jwt" {
|
||||||
|
interface FastifyJWT {
|
||||||
|
payload: {
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
apps?: string[];
|
||||||
|
permissions?: string[];
|
||||||
|
is_admin?: boolean;
|
||||||
|
iss?: string;
|
||||||
|
exp?: number;
|
||||||
|
};
|
||||||
|
user: {
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
apps?: string[];
|
||||||
|
permissions?: string[];
|
||||||
|
is_admin?: boolean;
|
||||||
|
iss?: string;
|
||||||
|
exp?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function authPlugin(
|
async function authPlugin(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
opts: { config: AppConfig },
|
opts: { config: AppConfig },
|
||||||
) {
|
) {
|
||||||
|
const { config } = opts;
|
||||||
|
|
||||||
|
if (config.authRequired && (!config.jwtSecret || config.jwtSecret.length < 8)) {
|
||||||
|
throw new Error(
|
||||||
|
"AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await app.register(import("@fastify/jwt"), {
|
await app.register(import("@fastify/jwt"), {
|
||||||
secret: opts.config.jwtSecret,
|
secret: config.jwtSecret,
|
||||||
|
...(config.authRequired
|
||||||
|
? {
|
||||||
|
verify: {
|
||||||
|
allowedIss: [config.authIssuer],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (config.authRequired) {
|
||||||
|
app.log.info(
|
||||||
|
{ issuer: config.authIssuer, portal: config.authPortalUrl },
|
||||||
|
"AUTH_REQUIRED=true — portal JWT middleware enabled",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
app.log.info("AUTH_REQUIRED=false — local JWT / open protected routes with requireAuth");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireAuth(request: FastifyRequest): Promise<void> {
|
/**
|
||||||
|
* Protect /api/v1 routes.
|
||||||
|
* - AUTH_REQUIRED=false: Bearer JWT from local login (legacy admin).
|
||||||
|
* - AUTH_REQUIRED=true: portal JWT with apps.includes('cfdm') + permissions.
|
||||||
|
*/
|
||||||
|
export async function requireAuth(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
): Promise<void> {
|
||||||
|
const config = request.server.config;
|
||||||
const authHeader = request.headers.authorization ?? "";
|
const authHeader = request.headers.authorization ?? "";
|
||||||
const token = authHeader.startsWith("Bearer ")
|
const token = authHeader.startsWith("Bearer ")
|
||||||
? authHeader.slice(7)
|
? authHeader.slice(7)
|
||||||
: "";
|
: "";
|
||||||
if (!token) throw AppError.unauthorized();
|
if (!token) throw AppError.unauthorized();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await request.jwtVerify();
|
await request.jwtVerify();
|
||||||
} catch {
|
} catch {
|
||||||
throw AppError.unauthorized();
|
throw AppError.unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!config.authRequired) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = request.user;
|
||||||
|
const apps = Array.isArray(payload.apps) ? payload.apps.map(String) : [];
|
||||||
|
const permissions = Array.isArray(payload.permissions)
|
||||||
|
? payload.permissions.map(String)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!apps.includes("cfdm")) {
|
||||||
|
throw AppError.forbidden("Нет доступа к приложению Cloudflare Domain Manager");
|
||||||
|
}
|
||||||
|
|
||||||
|
request.authUser = {
|
||||||
|
id: String(payload.sub),
|
||||||
|
email: String(payload.email ?? ""),
|
||||||
|
name: String(payload.name ?? ""),
|
||||||
|
apps,
|
||||||
|
permissions,
|
||||||
|
isAdmin: Boolean(payload.is_admin),
|
||||||
|
};
|
||||||
|
|
||||||
|
const required = permissionForRequest(request.method, request.url);
|
||||||
|
if (required && !hasPermission(permissions, required)) {
|
||||||
|
throw AppError.forbidden(`Недостаточно прав: ${required}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default fp(authPlugin, { name: "auth" });
|
export default fp(authPlugin, { name: "auth" });
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { listAudit } from "@cfdm/db";
|
||||||
|
import { auditListQuerySchema } from "@cfdm/shared";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
|
||||||
|
export async function auditRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
app.get("/audit", async (request) => {
|
||||||
|
const parsed = auditListQuerySchema.safeParse(request.query);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw AppError.validation("Некорректные параметры запроса");
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = parsed.data;
|
||||||
|
return listAudit(app.db, {
|
||||||
|
action: q.action,
|
||||||
|
severity: q.severity,
|
||||||
|
userId: q.user_id,
|
||||||
|
sourceApp: q.source_app,
|
||||||
|
limit: q.limit,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import * as dnsService from "../services/dns-service.js";
|
import * as dnsService from "../services/dns-service.js";
|
||||||
|
import { recordAudit } from "../lib/audit.js";
|
||||||
|
|
||||||
export async function dnsRoutes(app: FastifyInstance) {
|
export async function dnsRoutes(app: FastifyInstance) {
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
@@ -31,12 +32,25 @@ export async function dnsRoutes(app: FastifyInstance) {
|
|||||||
app.post("/domains/:id/dns", async (request) => {
|
app.post("/domains/:id/dns", async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const body = createSchema.parse(request.body);
|
const body = createSchema.parse(request.body);
|
||||||
return dnsService.create(
|
const record = await dnsService.create(
|
||||||
request.server.db,
|
request.server.db,
|
||||||
request.server.cf,
|
request.server.cf,
|
||||||
Number(id),
|
Number(id),
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "dns.create",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(record.id),
|
||||||
|
summary: `Создана DNS-запись ${record.name} (${record.record_type})`,
|
||||||
|
details: {
|
||||||
|
domain_id: Number(id),
|
||||||
|
record_type: record.record_type,
|
||||||
|
name: record.name,
|
||||||
|
content: record.content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return record;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/domains/:id/dns/bulk", async (request) => {
|
app.post("/domains/:id/dns/bulk", async (request) => {
|
||||||
@@ -69,13 +83,21 @@ export async function dnsRoutes(app: FastifyInstance) {
|
|||||||
id: string;
|
id: string;
|
||||||
recordId: string;
|
recordId: string;
|
||||||
};
|
};
|
||||||
return dnsService.update(
|
const record = await dnsService.update(
|
||||||
request.server.db,
|
request.server.db,
|
||||||
request.server.cf,
|
request.server.cf,
|
||||||
Number(id),
|
Number(id),
|
||||||
Number(recordId),
|
Number(recordId),
|
||||||
request.body as dnsService.UpdateDnsRequest,
|
request.body as dnsService.UpdateDnsRequest,
|
||||||
);
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "dns.update",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(recordId),
|
||||||
|
summary: `Обновлена DNS-запись ${record.name}`,
|
||||||
|
details: { domain_id: Number(id), record_id: Number(recordId) },
|
||||||
|
});
|
||||||
|
return record;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/domains/:id/dns/:recordId", async (request) => {
|
app.delete("/domains/:id/dns/:recordId", async (request) => {
|
||||||
@@ -83,12 +105,25 @@ export async function dnsRoutes(app: FastifyInstance) {
|
|||||||
id: string;
|
id: string;
|
||||||
recordId: string;
|
recordId: string;
|
||||||
};
|
};
|
||||||
|
const record = dnsService.get(
|
||||||
|
request.server.db,
|
||||||
|
Number(id),
|
||||||
|
Number(recordId),
|
||||||
|
);
|
||||||
await dnsService.deleteRecord(
|
await dnsService.deleteRecord(
|
||||||
request.server.db,
|
request.server.db,
|
||||||
request.server.cf,
|
request.server.cf,
|
||||||
Number(id),
|
Number(id),
|
||||||
Number(recordId),
|
Number(recordId),
|
||||||
);
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "dns.delete",
|
||||||
|
severity: "warning",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(recordId),
|
||||||
|
summary: `Удалена DNS-запись ${record.name}`,
|
||||||
|
details: { domain_id: Number(id), name: record.name },
|
||||||
|
});
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import { bulkUpdateDomainsSchema, updateDomainSchema } from "@cfdm/shared";
|
import { bulkUpdateDomainsSchema, updateDomainSchema } from "@cfdm/shared";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import * as domainService from "../services/domain-service.js";
|
import * as domainService from "../services/domain-service.js";
|
||||||
|
import { recordAudit } from "../lib/audit.js";
|
||||||
|
|
||||||
export async function domainRoutes(app: FastifyInstance) {
|
export async function domainRoutes(app: FastifyInstance) {
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
@@ -17,12 +18,20 @@ export async function domainRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.post("/domains", async (request) => {
|
app.post("/domains", async (request) => {
|
||||||
const body = createSchema.parse(request.body);
|
const body = createSchema.parse(request.body);
|
||||||
return domainService.createDomain(
|
const domain = await domainService.createDomain(
|
||||||
request.server.db,
|
request.server.db,
|
||||||
request.server.cf,
|
request.server.cf,
|
||||||
body.group_id ?? null,
|
body.group_id ?? null,
|
||||||
body.zone_name,
|
body.zone_name,
|
||||||
);
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "domain.create",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(domain.id),
|
||||||
|
summary: `Добавлен домен ${domain.zone_name}`,
|
||||||
|
details: { zone_name: domain.zone_name, group_id: domain.group_id },
|
||||||
|
});
|
||||||
|
return domain;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/domains/bulk", async (request) => {
|
app.post("/domains/bulk", async (request) => {
|
||||||
@@ -47,12 +56,29 @@ export async function domainRoutes(app: FastifyInstance) {
|
|||||||
app.patch("/domains/:id", async (request) => {
|
app.patch("/domains/:id", async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const body = updateDomainSchema.parse(request.body);
|
const body = updateDomainSchema.parse(request.body);
|
||||||
return domainService.updateDomain(request.server.db, Number(id), body);
|
const domain = domainService.updateDomain(request.server.db, Number(id), body);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "domain.update",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(domain.id),
|
||||||
|
summary: `Обновлён домен ${domain.zone_name}`,
|
||||||
|
details: body,
|
||||||
|
});
|
||||||
|
return domain;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/domains/:id", async (request) => {
|
app.delete("/domains/:id", async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const domain = domainService.getDomain(request.server.db, Number(id));
|
||||||
domainService.deleteDomain(request.server.db, Number(id));
|
domainService.deleteDomain(request.server.db, Number(id));
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "domain.delete",
|
||||||
|
severity: "warning",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(id),
|
||||||
|
summary: `Удалён домен ${domain.zone_name}`,
|
||||||
|
details: { zone_name: domain.zone_name },
|
||||||
|
});
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
import * as groupService from "../services/group-service.js";
|
import * as groupService from "../services/group-service.js";
|
||||||
|
import { recordAudit } from "../lib/audit.js";
|
||||||
|
|
||||||
export async function groupRoutes(app: FastifyInstance) {
|
export async function groupRoutes(app: FastifyInstance) {
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
@@ -14,7 +16,15 @@ export async function groupRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.post("/groups", async (request) => {
|
app.post("/groups", async (request) => {
|
||||||
const body = bodySchema.parse(request.body);
|
const body = bodySchema.parse(request.body);
|
||||||
return groupService.createGroup(request.server.db, body.name, body.slug);
|
const group = groupService.createGroup(request.server.db, body.name, body.slug);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "group.create",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(group.id),
|
||||||
|
summary: `Создана группа «${group.name}»`,
|
||||||
|
details: { name: group.name, slug: group.slug },
|
||||||
|
});
|
||||||
|
return group;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/groups/:id", async (request) => {
|
app.get("/groups/:id", async (request) => {
|
||||||
@@ -25,17 +35,34 @@ export async function groupRoutes(app: FastifyInstance) {
|
|||||||
app.patch("/groups/:id", async (request) => {
|
app.patch("/groups/:id", async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const body = bodySchema.parse(request.body);
|
const body = bodySchema.parse(request.body);
|
||||||
return groupService.updateGroup(
|
const group = groupService.updateGroup(
|
||||||
request.server.db,
|
request.server.db,
|
||||||
Number(id),
|
Number(id),
|
||||||
body.name,
|
body.name,
|
||||||
body.slug,
|
body.slug,
|
||||||
);
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "group.update",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(group.id),
|
||||||
|
summary: `Обновлена группа «${group.name}»`,
|
||||||
|
details: { name: group.name, slug: group.slug },
|
||||||
|
});
|
||||||
|
return group;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/groups/:id", async (request) => {
|
app.delete("/groups/:id", async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const group = repos.getGroup(request.server.db, Number(id));
|
||||||
groupService.deleteGroup(request.server.db, Number(id));
|
groupService.deleteGroup(request.server.db, Number(id));
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "group.delete",
|
||||||
|
severity: "warning",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(id),
|
||||||
|
summary: `Удалена группа «${group.name}»`,
|
||||||
|
details: { name: group.name, slug: group.slug },
|
||||||
|
});
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@ export async function healthCheckRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
const checked = await healthCheckService.runAllChecks(request.server.db, {
|
const checked = await healthCheckService.runAllChecks(request.server.db, {
|
||||||
thresholds,
|
thresholds,
|
||||||
|
probeGapMs: config.healthProbeGapMs,
|
||||||
onStatusChange: async (target, prev, next) => {
|
onStatusChange: async (target, prev, next) => {
|
||||||
try {
|
try {
|
||||||
const label =
|
const label =
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { healthCheck } from "../plugins/db.js";
|
import { healthCheck } from "../plugins/db.js";
|
||||||
import { getAppSwitcher } from "@cfdm/db";
|
|
||||||
import * as authService from "../services/auth.js";
|
import * as authService from "../services/auth.js";
|
||||||
|
|
||||||
export async function healthRoutes(app: FastifyInstance) {
|
export async function healthRoutes(app: FastifyInstance) {
|
||||||
@@ -32,8 +31,12 @@ export async function healthRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance) {
|
export async function authRoutes(app: FastifyInstance) {
|
||||||
app.get("/settings/app-switcher", async (request) => {
|
app.get("/auth/config", async (request) => {
|
||||||
return getAppSwitcher(request.server.db);
|
const { config } = request.server;
|
||||||
|
return {
|
||||||
|
required: config.authRequired,
|
||||||
|
portal_url: config.authPortalUrl,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
@@ -42,6 +45,14 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post("/auth/login", async (request, reply) => {
|
app.post("/auth/login", async (request, reply) => {
|
||||||
|
if (request.server.config.authRequired) {
|
||||||
|
return reply.code(403).send({
|
||||||
|
error: {
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Локальный вход отключён — используйте auth-portal",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
const body = loginSchema.parse(request.body);
|
const body = loginSchema.parse(request.body);
|
||||||
const result = await authService.login(
|
const result = await authService.login(
|
||||||
request.server.config,
|
request.server.config,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import { vpsTrackerEventSchema } from "@cfdm/shared";
|
import { vpsTrackerEventSchema } from "@cfdm/shared";
|
||||||
import { getAppSettingsSecrets } from "@cfdm/db";
|
import { getAppSettingsSecrets } from "@cfdm/db";
|
||||||
import * as vpsTrackerEvents from "../services/vps-tracker-events.js";
|
import * as vpsTrackerEvents from "../services/vps-tracker-events.js";
|
||||||
|
import { buildAllSyncBindings } from "../services/vps-tracker-sync.js";
|
||||||
|
|
||||||
function verifyBearer(authHeader: string | undefined, expected: string): boolean {
|
function verifyBearer(authHeader: string | undefined, expected: string): boolean {
|
||||||
if (!authHeader?.startsWith("Bearer ")) return false;
|
if (!authHeader?.startsWith("Bearer ")) return false;
|
||||||
@@ -41,4 +42,31 @@ export async function integrationsVpsTrackerRoutes(app: FastifyInstance) {
|
|||||||
);
|
);
|
||||||
return { ok: true, reconciled };
|
return { ok: true, reconciled };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полная синхронизация по запросу VPS Tracker (pull).
|
||||||
|
* Bindings возвращаются в ответе — клиент применяет их локально.
|
||||||
|
* Не требует доступности VPS Tracker URL из CFDM.
|
||||||
|
*/
|
||||||
|
app.post("/integrations/vps-tracker/sync", async (req, reply) => {
|
||||||
|
const secrets = getAppSettingsSecrets(app.db);
|
||||||
|
const token = secrets.vpsTrackerIntegrationToken;
|
||||||
|
if (!token) {
|
||||||
|
return reply.status(503).send({
|
||||||
|
ok: false,
|
||||||
|
error: "Integration not configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!verifyBearer(req.headers.authorization, token)) {
|
||||||
|
return reply.status(401).send({ ok: false, error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindings = await buildAllSyncBindings(app.db);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
count: bindings.length,
|
||||||
|
bindings,
|
||||||
|
fullSync: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import { reorderServicesSchema, updateServiceConfigSchema } from "@cfdm/shared";
|
import { reorderServicesSchema, updateServiceConfigSchema } from "@cfdm/shared";
|
||||||
import { repos } from "@cfdm/db";
|
import { repos } from "@cfdm/db";
|
||||||
import * as serviceConfig from "../services/service-config-service.js";
|
import * as serviceConfig from "../services/service-config-service.js";
|
||||||
|
import { recordAudit } from "../lib/audit.js";
|
||||||
|
|
||||||
export async function serviceRoutes(app: FastifyInstance) {
|
export async function serviceRoutes(app: FastifyInstance) {
|
||||||
const createSchema = z.object({
|
const createSchema = z.object({
|
||||||
@@ -39,7 +40,15 @@ export async function serviceRoutes(app: FastifyInstance) {
|
|||||||
body.service_group_id,
|
body.service_group_id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return serviceConfig.getView(request.server.db, service.id);
|
const view = await serviceConfig.getView(request.server.db, service.id);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "service.create",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(service.id),
|
||||||
|
summary: `Создан сервис «${service.name}»`,
|
||||||
|
details: { name: service.name, slug: service.slug },
|
||||||
|
});
|
||||||
|
return view;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/services/:id", async (request) => {
|
app.get("/services/:id", async (request) => {
|
||||||
@@ -47,20 +56,38 @@ export async function serviceRoutes(app: FastifyInstance) {
|
|||||||
return serviceConfig.getView(request.server.db, Number(id));
|
return serviceConfig.getView(request.server.db, Number(id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
app.patch("/services/:id", async (request) => {
|
app.patch("/services/:id", async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const body = updateServiceConfigSchema.parse(request.body);
|
const body = updateServiceConfigSchema.parse(request.body);
|
||||||
return serviceConfig.updateConfig(
|
const view = await serviceConfig.updateConfig(
|
||||||
request.server.db,
|
request.server.db,
|
||||||
request.server.cf,
|
request.server.cf,
|
||||||
Number(id),
|
Number(id),
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "service.update",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(id),
|
||||||
|
summary: `Обновлён сервис «${view.name}»`,
|
||||||
|
details: body,
|
||||||
|
});
|
||||||
|
return view;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete("/services/:id", async (request) => {
|
app.delete("/services/:id", async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
|
const view = await serviceConfig.getView(request.server.db, Number(id));
|
||||||
repos.deleteService(request.server.db, Number(id));
|
repos.deleteService(request.server.db, Number(id));
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "service.delete",
|
||||||
|
severity: "warning",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(id),
|
||||||
|
summary: `Удалён сервис «${view.name}»`,
|
||||||
|
details: { name: view.name, slug: view.slug },
|
||||||
|
});
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { request } from "undici";
|
||||||
|
import type { IngestAuditEvent } from "@cfdm/shared";
|
||||||
|
|
||||||
|
export async function pushAuditEvents(
|
||||||
|
portalUrl: string,
|
||||||
|
secret: string,
|
||||||
|
events: IngestAuditEvent[],
|
||||||
|
): Promise<void> {
|
||||||
|
const base = portalUrl.replace(/\/$/, "");
|
||||||
|
const res = await request(`${base}/api/v1/ingest/audit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
authorization: `Bearer ${secret}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ events }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.statusCode >= 400) {
|
||||||
|
const body = await res.body.text();
|
||||||
|
throw new Error(`portal audit ingest ${res.statusCode}: ${body}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,10 +21,19 @@ export interface CertificateTarget {
|
|||||||
hostname: string;
|
hostname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pruneStaleCertificates(db: Db): void {
|
||||||
|
const targets = resolveCertificateTargets(db);
|
||||||
|
repos.deleteCertificatesNotIn(
|
||||||
|
db,
|
||||||
|
targets.map((t) => t.hostname),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function listCertificates(
|
export function listCertificates(
|
||||||
db: Db,
|
db: Db,
|
||||||
status?: string,
|
status?: string,
|
||||||
): Certificate[] {
|
): Certificate[] {
|
||||||
|
pruneStaleCertificates(db);
|
||||||
return repos.listCertificates(db, status);
|
return repos.listCertificates(db, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +142,29 @@ function bindingSubdomain(
|
|||||||
return repos.findSubdomainByDomainAndName(db, domainId, hostname);
|
return repos.findSubdomainByDomainAndName(db, domainId, hostname);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HealthTlsFlags = {
|
||||||
|
health_check_enabled: boolean;
|
||||||
|
health_check_verify_tls: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto SSL monitoring follows effective health-check with TLS verify.
|
||||||
|
* No health → no SSL. Health without verify_tls (self-signed) → no SSL.
|
||||||
|
* Binding inherits group health when its own health is off.
|
||||||
|
*/
|
||||||
|
function hasSslHealthGate(
|
||||||
|
own: HealthTlsFlags,
|
||||||
|
group: (HealthTlsFlags & { enabled: boolean }) | null,
|
||||||
|
): boolean {
|
||||||
|
if (own.health_check_enabled) {
|
||||||
|
return own.health_check_verify_tls;
|
||||||
|
}
|
||||||
|
if (group?.enabled && group.health_check_enabled) {
|
||||||
|
return group.health_check_verify_tls;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildServiceCertificateFqdns(
|
export function buildServiceCertificateFqdns(
|
||||||
db: Db,
|
db: Db,
|
||||||
): Map<string, CertificateTarget> {
|
): Map<string, CertificateTarget> {
|
||||||
@@ -144,6 +176,17 @@ export function buildServiceCertificateFqdns(
|
|||||||
? repos.getServiceGroup(db, service.service_group_id)
|
? repos.getServiceGroup(db, service.service_group_id)
|
||||||
: null;
|
: null;
|
||||||
if (!shouldMonitorService(service, group)) continue;
|
if (!shouldMonitorService(service, group)) continue;
|
||||||
|
if (
|
||||||
|
!hasSslHealthGate(
|
||||||
|
{
|
||||||
|
health_check_enabled: binding.health_check_enabled,
|
||||||
|
health_check_verify_tls: binding.health_check_verify_tls,
|
||||||
|
},
|
||||||
|
group,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
|
const subdomain = bindingSubdomain(db, binding.domain_id, binding.hostname);
|
||||||
if (subdomain && !subdomain.enabled) continue;
|
if (subdomain && !subdomain.enabled) continue;
|
||||||
@@ -159,6 +202,7 @@ export function buildServiceCertificateFqdns(
|
|||||||
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name);
|
const knownZones = repos.listAllDomains(db).map((d) => d.zone_name);
|
||||||
for (const group of repos.listServiceGroups(db)) {
|
for (const group of repos.listServiceGroups(db)) {
|
||||||
if (!group.enabled || !group.domain?.trim()) continue;
|
if (!group.enabled || !group.domain?.trim()) continue;
|
||||||
|
if (!group.health_check_enabled || !group.health_check_verify_tls) continue;
|
||||||
|
|
||||||
const parsed = parseFqdn(group.domain, knownZones);
|
const parsed = parseFqdn(group.domain, knownZones);
|
||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
@@ -244,5 +288,6 @@ export async function runAllChecks(db: Db): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function statusSummary(db: Db): Array<[string, number]> {
|
export function statusSummary(db: Db): Array<[string, number]> {
|
||||||
|
pruneStaleCertificates(db);
|
||||||
return repos.countCertificatesByStatus(db);
|
return repos.countCertificatesByStatus(db);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { connect } from "node:net";
|
import { connect, isIP } from "node:net";
|
||||||
import { resolve4, resolve6 } from "node:dns/promises";
|
import { resolve4, resolve6 } from "node:dns/promises";
|
||||||
import { Agent, fetch as undiciFetch } from "undici";
|
import { Agent, buildConnector, fetch as undiciFetch } from "undici";
|
||||||
import type { Db } from "@cfdm/db";
|
import type { Db } from "@cfdm/db";
|
||||||
import { repos } from "@cfdm/db";
|
import { repos } from "@cfdm/db";
|
||||||
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
|
import type { HealthCheckTarget, IpHealthState } from "@cfdm/shared";
|
||||||
@@ -18,6 +18,26 @@ export interface ProbeResult {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bracket IPv6 for URL authority; leave IPv4/hostname as-is. */
|
||||||
|
export function hostForUrl(ipOrHost: string): string {
|
||||||
|
return isIP(ipOrHost) === 6 ? `[${ipOrHost}]` : ipOrHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build http(s) URL authority for the probe.
|
||||||
|
* Prefer FQDN in the URL (correct Host/SNI); TCP dial goes to configured IP via custom connector.
|
||||||
|
*/
|
||||||
|
export function buildHttpProbeUrl(
|
||||||
|
urlHost: string,
|
||||||
|
port: number,
|
||||||
|
pathWithSlash: string,
|
||||||
|
useTls: boolean,
|
||||||
|
): string {
|
||||||
|
const defaultPort = useTls ? 443 : 80;
|
||||||
|
const portPart = port === defaultPort ? "" : `:${port}`;
|
||||||
|
return `${useTls ? "https" : "http"}://${hostForUrl(urlHost)}${portPart}${pathWithSlash}`;
|
||||||
|
}
|
||||||
|
|
||||||
function tcpProbe(
|
function tcpProbe(
|
||||||
ip: string,
|
ip: string,
|
||||||
port: number,
|
port: number,
|
||||||
@@ -59,6 +79,41 @@ function tcpProbe(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dial `connectAddr` for TCP/TLS while URL Host/SNI stay on the FQDN. */
|
||||||
|
function createIpPinnedAgent(
|
||||||
|
connectAddr: string,
|
||||||
|
sniHost: string,
|
||||||
|
useTls: boolean,
|
||||||
|
timeoutMs: number,
|
||||||
|
verifyTls: boolean,
|
||||||
|
): Agent {
|
||||||
|
const connector = buildConnector({
|
||||||
|
rejectUnauthorized: verifyTls,
|
||||||
|
timeout: timeoutMs,
|
||||||
|
});
|
||||||
|
return new Agent({
|
||||||
|
connect(opts, callback) {
|
||||||
|
connector(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
// Force socket to configured IP (or CNAME target), not public DNS of FQDN.
|
||||||
|
hostname: connectAddr,
|
||||||
|
host: connectAddr,
|
||||||
|
servername:
|
||||||
|
useTls && isIP(sniHost) === 0
|
||||||
|
? sniHost
|
||||||
|
: (opts.servername as string | undefined),
|
||||||
|
},
|
||||||
|
callback,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP(S) probe: URL/Host/SNI use hostname (vhost), TCP connects to configured IP when numeric.
|
||||||
|
* Avoids re-resolving FQDN via public DNS (which skewed group vs binding latency for the same IP).
|
||||||
|
*/
|
||||||
async function httpProbe(
|
async function httpProbe(
|
||||||
ip: string,
|
ip: string,
|
||||||
target: HealthCheckTarget,
|
target: HealthCheckTarget,
|
||||||
@@ -69,21 +124,23 @@ async function httpProbe(
|
|||||||
const pathWithSlash = path.startsWith("/") ? path : `/${path}`;
|
const pathWithSlash = path.startsWith("/") ? path : `/${path}`;
|
||||||
const port = target.port ?? 80;
|
const port = target.port ?? 80;
|
||||||
const useTls = port === 443;
|
const useTls = port === 443;
|
||||||
const urlHost = useTls ? target.hostname || ip : ip;
|
const connectAddr = String(ip || "").trim();
|
||||||
const url = `${useTls ? "https" : "http"}://${urlHost}${pathWithSlash}`;
|
const headerHost = (target.hostname || "").trim() || connectAddr;
|
||||||
const dispatcher =
|
const url = buildHttpProbeUrl(headerHost, port, pathWithSlash, useTls);
|
||||||
useTls && target.hostname
|
const verifyTls = target.verify_tls === true;
|
||||||
? new Agent({
|
|
||||||
connect: {
|
const family = isIP(connectAddr);
|
||||||
servername: target.hostname,
|
const pinToIp = family === 4 || family === 6;
|
||||||
rejectUnauthorized: false,
|
|
||||||
},
|
const dispatcher = pinToIp
|
||||||
})
|
? createIpPinnedAgent(connectAddr, headerHost, useTls, timeoutMs, verifyTls)
|
||||||
|
: useTls
|
||||||
|
? createIpPinnedAgent(connectAddr, headerHost, useTls, timeoutMs, verifyTls)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await undiciFetch(url, {
|
const response = await undiciFetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { Host: target.hostname || ip },
|
|
||||||
signal: AbortSignal.timeout(timeoutMs),
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
dispatcher,
|
dispatcher,
|
||||||
@@ -198,6 +255,8 @@ function deriveState(
|
|||||||
|
|
||||||
export interface RunAllChecksOptions {
|
export interface RunAllChecksOptions {
|
||||||
thresholds: HealthCheckThresholds;
|
thresholds: HealthCheckThresholds;
|
||||||
|
/** Pause between unique physical probes (default 2000). Same IP is only probed once. */
|
||||||
|
probeGapMs?: number;
|
||||||
onStatusChange?: (
|
onStatusChange?: (
|
||||||
target: HealthCheckTarget,
|
target: HealthCheckTarget,
|
||||||
prevState: IpHealthState | null,
|
prevState: IpHealthState | null,
|
||||||
@@ -205,47 +264,97 @@ export interface RunAllChecksOptions {
|
|||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One network hit per key. Group+binding on the same IP share a single TCP/HTTP probe
|
||||||
|
* so anti-bot / rate-limit on the origin is not tripped by back-to-back checks.
|
||||||
|
*/
|
||||||
|
export function physicalProbeKey(target: HealthCheckTarget): string {
|
||||||
|
const port = target.port ?? (target.type === "http" ? 80 : 80);
|
||||||
|
const ip = String(target.ip || "").trim().toLowerCase();
|
||||||
|
if (target.type === "http") {
|
||||||
|
const path = (target.path?.trim() || "/") || "/";
|
||||||
|
const expected = target.expected_status ?? "";
|
||||||
|
return `http|${ip}|${port}|${path}|${expected}`;
|
||||||
|
}
|
||||||
|
if (target.type === "tcp") return `tcp|${ip}|${port}`;
|
||||||
|
if (target.type === "ping") {
|
||||||
|
return `ping|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
if (target.type === "dns") {
|
||||||
|
return `dns|${String(target.hostname || target.ip || "").trim().toLowerCase()}`;
|
||||||
|
}
|
||||||
|
return `${target.type}|${ip}|${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runAllChecks(
|
export async function runAllChecks(
|
||||||
db: Db,
|
db: Db,
|
||||||
options: RunAllChecksOptions,
|
options: RunAllChecksOptions,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const targets = repos.listHealthCheckTargets(db);
|
const targets = repos.listHealthCheckTargets(db);
|
||||||
|
const gapMs = Math.max(0, options.probeGapMs ?? 2000);
|
||||||
|
|
||||||
|
const byPhysical = new Map<string, HealthCheckTarget[]>();
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const prev = repos.getIpHealthStatusRow(
|
const key = physicalProbeKey(target);
|
||||||
db,
|
const list = byPhysical.get(key);
|
||||||
target.scope,
|
if (list) list.push(target);
|
||||||
target.ref_id,
|
else byPhysical.set(key, [target]);
|
||||||
target.ip,
|
}
|
||||||
);
|
|
||||||
const result = await probeTarget(target);
|
let probeIndex = 0;
|
||||||
const { state, failures } = deriveState(
|
for (const group of byPhysical.values()) {
|
||||||
result.ok,
|
if (probeIndex > 0 && gapMs > 0) {
|
||||||
result.latencyMs,
|
await sleep(gapMs);
|
||||||
prev
|
}
|
||||||
? {
|
probeIndex += 1;
|
||||||
consecutive_failures: prev.consecutive_failures,
|
|
||||||
status: prev.status,
|
// Prefer binding hostname for SNI when several scopes share one IP.
|
||||||
}
|
const representative =
|
||||||
: null,
|
group.find((t) => t.scope === "binding") ?? group[0]!;
|
||||||
options.thresholds,
|
const result = await probeTarget(representative);
|
||||||
);
|
|
||||||
const prevState: IpHealthState | null = prev
|
for (const target of group) {
|
||||||
? (prev.status as IpHealthState)
|
const prev = repos.getIpHealthStatusRow(
|
||||||
: null;
|
db,
|
||||||
repos.upsertIpHealthStatus(
|
target.scope,
|
||||||
db,
|
target.ref_id,
|
||||||
target.scope,
|
target.ip,
|
||||||
target.ref_id,
|
);
|
||||||
target.ip,
|
const { state, failures } = deriveState(
|
||||||
state,
|
result.ok,
|
||||||
result.latencyMs,
|
result.latencyMs,
|
||||||
failures,
|
prev
|
||||||
result.error,
|
? {
|
||||||
);
|
consecutive_failures: prev.consecutive_failures,
|
||||||
if (prevState !== state) {
|
status: prev.status,
|
||||||
options.onStatusChange?.(target, prevState, state);
|
}
|
||||||
|
: null,
|
||||||
|
options.thresholds,
|
||||||
|
);
|
||||||
|
const prevState: IpHealthState | null = prev
|
||||||
|
? (prev.status as IpHealthState)
|
||||||
|
: null;
|
||||||
|
repos.upsertIpHealthStatus(
|
||||||
|
db,
|
||||||
|
target.scope,
|
||||||
|
target.ref_id,
|
||||||
|
target.ip,
|
||||||
|
state,
|
||||||
|
result.latencyMs,
|
||||||
|
failures,
|
||||||
|
result.error,
|
||||||
|
);
|
||||||
|
if (prevState !== state) {
|
||||||
|
options.onStatusChange?.(target, prevState, state);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Orphan rows (old IPs / hostname keys) still feed MAX latency on group badge.
|
||||||
|
repos.pruneStaleIpHealthStatus(db, targets);
|
||||||
return targets.length;
|
return targets.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,6 +375,7 @@ export async function runDomainMonitors(
|
|||||||
path: monitor.path,
|
path: monitor.path,
|
||||||
expected_status: monitor.expected_status,
|
expected_status: monitor.expected_status,
|
||||||
timeout_ms: monitor.timeout_ms,
|
timeout_ms: monitor.timeout_ms,
|
||||||
|
verify_tls: false,
|
||||||
};
|
};
|
||||||
let result: ProbeResult;
|
let result: ProbeResult;
|
||||||
if (monitor.type === "http") {
|
if (monitor.type === "http") {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface ServiceDomainInput {
|
|||||||
health_check_expected_status?: number | null;
|
health_check_expected_status?: number | null;
|
||||||
health_check_interval_sec?: number;
|
health_check_interval_sec?: number;
|
||||||
health_check_timeout_ms?: number;
|
health_check_timeout_ms?: number;
|
||||||
|
health_check_verify_tls?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToggleRequest {
|
export interface ToggleRequest {
|
||||||
@@ -59,6 +60,7 @@ export interface ServiceGroupBody {
|
|||||||
health_check_expected_status?: number | null;
|
health_check_expected_status?: number | null;
|
||||||
health_check_interval_sec?: number;
|
health_check_interval_sec?: number;
|
||||||
health_check_timeout_ms?: number;
|
health_check_timeout_ms?: number;
|
||||||
|
health_check_verify_tls?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateServiceGroupBody {
|
export interface UpdateServiceGroupBody {
|
||||||
@@ -74,6 +76,7 @@ export interface UpdateServiceGroupBody {
|
|||||||
health_check_expected_status?: number | null;
|
health_check_expected_status?: number | null;
|
||||||
health_check_interval_sec?: number;
|
health_check_interval_sec?: number;
|
||||||
health_check_timeout_ms?: number;
|
health_check_timeout_ms?: number;
|
||||||
|
health_check_verify_tls?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateServiceConfigRequest {
|
export interface UpdateServiceConfigRequest {
|
||||||
@@ -322,6 +325,7 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
|||||||
health_check_expected_status: binding.health_check_expected_status,
|
health_check_expected_status: binding.health_check_expected_status,
|
||||||
health_check_interval_sec: binding.health_check_interval_sec,
|
health_check_interval_sec: binding.health_check_interval_sec,
|
||||||
health_check_timeout_ms: binding.health_check_timeout_ms,
|
health_check_timeout_ms: binding.health_check_timeout_ms,
|
||||||
|
health_check_verify_tls: binding.health_check_verify_tls,
|
||||||
sync_status: aggregateSyncStatus(statuses),
|
sync_status: aggregateSyncStatus(statuses),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -400,9 +404,8 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
|
|||||||
const withHealth = attachServiceHealth(db, allServiceViews);
|
const withHealth = attachServiceHealth(db, allServiceViews);
|
||||||
const healthById = new Map(withHealth.map((v) => [v.id, v]));
|
const healthById = new Map(withHealth.map((v) => [v.id, v]));
|
||||||
|
|
||||||
const groupHealthById = repos.aggregateIpHealthByRefs(
|
const groupHealthById = repos.aggregateGroupScopeHealthByIds(
|
||||||
db,
|
db,
|
||||||
"group",
|
|
||||||
groups.map((g) => g.id),
|
groups.map((g) => g.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -411,12 +414,17 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
|
|||||||
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null },
|
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null },
|
||||||
);
|
);
|
||||||
const groupScopeHealth = groupHealthById.get(group.id);
|
const groupScopeHealth = groupHealthById.get(group.id);
|
||||||
const merged = repos.mergeHealthAggregates([
|
// Only enabled services feed the group badge — a disabled service with a
|
||||||
groupScopeHealth,
|
// stale `down` must not keep the group red while live members are OK.
|
||||||
...services.map((s) => ({
|
const enabledServiceHealth = services
|
||||||
|
.filter((s) => s.enabled)
|
||||||
|
.map((s) => ({
|
||||||
health_status: s.health_status,
|
health_status: s.health_status,
|
||||||
health_latency_ms: s.health_latency_ms,
|
health_latency_ms: s.health_latency_ms,
|
||||||
})),
|
}));
|
||||||
|
const merged = repos.mergeHealthAggregates([
|
||||||
|
groupScopeHealth,
|
||||||
|
...enabledServiceHealth,
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
...group,
|
...group,
|
||||||
@@ -815,49 +823,6 @@ async function findOrImportDnsARecord(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function serviceBindingsExistInDns(
|
|
||||||
db: Db,
|
|
||||||
cf: CloudflareClient,
|
|
||||||
serviceId: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const bindings = repos.listBindingsByService(db, serviceId);
|
|
||||||
if (bindings.length === 0) return false;
|
|
||||||
|
|
||||||
for (const binding of bindings) {
|
|
||||||
const cnameTarget = binding.cname_target?.trim() || null;
|
|
||||||
if (cnameTarget) {
|
|
||||||
const record = await findOrImportDnsRecord(
|
|
||||||
db,
|
|
||||||
cf,
|
|
||||||
binding.domain_id,
|
|
||||||
binding.zone_name,
|
|
||||||
binding.hostname,
|
|
||||||
"CNAME",
|
|
||||||
cnameTarget,
|
|
||||||
);
|
|
||||||
if (!record) return false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetIps = repos.listBindingIps(db, binding.id);
|
|
||||||
if (targetIps.length === 0) return false;
|
|
||||||
|
|
||||||
for (const ip of targetIps) {
|
|
||||||
const record = await findOrImportDnsARecord(
|
|
||||||
db,
|
|
||||||
cf,
|
|
||||||
binding.domain_id,
|
|
||||||
binding.zone_name,
|
|
||||||
binding.hostname,
|
|
||||||
ip,
|
|
||||||
);
|
|
||||||
if (!record) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncServiceBindingsToDns(
|
async function syncServiceBindingsToDns(
|
||||||
db: Db,
|
db: Db,
|
||||||
cf: CloudflareClient,
|
cf: CloudflareClient,
|
||||||
@@ -1168,95 +1133,95 @@ export async function updateConfig(
|
|||||||
let removedBindingIds: number[] = [];
|
let removedBindingIds: number[] = [];
|
||||||
|
|
||||||
if (req.domains) {
|
if (req.domains) {
|
||||||
if (req.domains.length > 0) {
|
for (const input of req.domains) {
|
||||||
for (const input of req.domains) {
|
const fqdn = input.fqdn.trim();
|
||||||
const fqdn = input.fqdn.trim();
|
if (!fqdn) continue;
|
||||||
if (!fqdn) continue;
|
const targetCname = bindingTargetCname(input);
|
||||||
const targetCname = bindingTargetCname(input);
|
const targetIps = bindingTargetIps(input);
|
||||||
const targetIps = bindingTargetIps(input);
|
if (!targetCname) {
|
||||||
if (!targetCname) {
|
validateTargetIpsInPool(targetIps, ips);
|
||||||
validateTargetIpsInPool(targetIps, ips);
|
} else if (targetIps.length > 0) {
|
||||||
} else if (targetIps.length > 0) {
|
throw AppError.validation(
|
||||||
throw AppError.validation(
|
`укажите либо IP, либо CNAME для ${fqdn}`,
|
||||||
`укажите либо IP, либо CNAME для ${fqdn}`,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
|
|
||||||
const domainId = await resolveDomainId(db, cf, zoneName);
|
|
||||||
|
|
||||||
const binding =
|
|
||||||
repos.findBinding(db, id, domainId, hostname) ??
|
|
||||||
repos.insertBinding(db, domainId, id, hostname, null);
|
|
||||||
|
|
||||||
keptBindingIds.push(binding.id);
|
|
||||||
|
|
||||||
const targetIpWeights = input.target_ip_weights ?? {};
|
|
||||||
const targetIpPriorities = input.target_ip_priorities ?? {};
|
|
||||||
const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({
|
|
||||||
ip,
|
|
||||||
weight: targetIpWeights[ip] ?? 1,
|
|
||||||
priority: targetIpPriorities[ip] ?? 1,
|
|
||||||
}));
|
|
||||||
repos.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries);
|
|
||||||
repos.setBindingCnameTarget(db, binding.id, targetCname);
|
|
||||||
|
|
||||||
if (
|
|
||||||
input.lb_mode !== undefined ||
|
|
||||||
input.health_check_enabled !== undefined ||
|
|
||||||
input.health_check_type !== undefined ||
|
|
||||||
input.health_check_port !== undefined ||
|
|
||||||
input.health_check_path !== undefined ||
|
|
||||||
input.health_check_expected_status !== undefined ||
|
|
||||||
input.health_check_interval_sec !== undefined ||
|
|
||||||
input.health_check_timeout_ms !== undefined
|
|
||||||
) {
|
|
||||||
repos.updateBindingLbConfig(db, binding.id, {
|
|
||||||
lb_mode: input.lb_mode,
|
|
||||||
health_check_enabled: input.health_check_enabled,
|
|
||||||
health_check_type: input.health_check_type,
|
|
||||||
health_check_port: input.health_check_port,
|
|
||||||
health_check_path: input.health_check_path,
|
|
||||||
health_check_expected_status: input.health_check_expected_status,
|
|
||||||
health_check_interval_sec: input.health_check_interval_sec,
|
|
||||||
health_check_timeout_ms: input.health_check_timeout_ms,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pushDns) {
|
|
||||||
let effectiveIps = targetIps;
|
|
||||||
const refreshedBinding = repos.getBinding(db, binding.id);
|
|
||||||
if (refreshedBinding.health_check_enabled) {
|
|
||||||
const activeIps = computeActiveIps(db, "binding", binding.id);
|
|
||||||
if (activeIps.length > 0) {
|
|
||||||
effectiveIps = activeIps;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await syncBindingDns(
|
|
||||||
db,
|
|
||||||
cf,
|
|
||||||
binding.id,
|
|
||||||
domainId,
|
|
||||||
hostname,
|
|
||||||
effectiveIps,
|
|
||||||
targetCname,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removed = repos.bindingsToRemove(db, id, keptBindingIds);
|
const { zoneName, hostname } = parseFqdn(fqdn, knownZones);
|
||||||
removedBindingIds = removed.map((binding) => binding.id);
|
const domainId = await resolveDomainId(db, cf, zoneName);
|
||||||
for (const binding of removed) {
|
|
||||||
await cleanupBindingDns(
|
const binding =
|
||||||
|
repos.findBinding(db, id, domainId, hostname) ??
|
||||||
|
repos.insertBinding(db, domainId, id, hostname, null);
|
||||||
|
|
||||||
|
keptBindingIds.push(binding.id);
|
||||||
|
|
||||||
|
const targetIpWeights = input.target_ip_weights ?? {};
|
||||||
|
const targetIpPriorities = input.target_ip_priorities ?? {};
|
||||||
|
const bindingIpEntries = (targetCname ? [] : targetIps).map((ip) => ({
|
||||||
|
ip,
|
||||||
|
weight: targetIpWeights[ip] ?? 1,
|
||||||
|
priority: targetIpPriorities[ip] ?? 1,
|
||||||
|
}));
|
||||||
|
repos.replaceBindingIpsWithMeta(db, binding.id, bindingIpEntries);
|
||||||
|
repos.setBindingCnameTarget(db, binding.id, targetCname);
|
||||||
|
|
||||||
|
if (
|
||||||
|
input.lb_mode !== undefined ||
|
||||||
|
input.health_check_enabled !== undefined ||
|
||||||
|
input.health_check_type !== undefined ||
|
||||||
|
input.health_check_port !== undefined ||
|
||||||
|
input.health_check_path !== undefined ||
|
||||||
|
input.health_check_expected_status !== undefined ||
|
||||||
|
input.health_check_interval_sec !== undefined ||
|
||||||
|
input.health_check_timeout_ms !== undefined ||
|
||||||
|
input.health_check_verify_tls !== undefined
|
||||||
|
) {
|
||||||
|
repos.updateBindingLbConfig(db, binding.id, {
|
||||||
|
lb_mode: input.lb_mode,
|
||||||
|
health_check_enabled: input.health_check_enabled,
|
||||||
|
health_check_type: input.health_check_type,
|
||||||
|
health_check_port: input.health_check_port,
|
||||||
|
health_check_path: input.health_check_path,
|
||||||
|
health_check_expected_status: input.health_check_expected_status,
|
||||||
|
health_check_interval_sec: input.health_check_interval_sec,
|
||||||
|
health_check_timeout_ms: input.health_check_timeout_ms,
|
||||||
|
health_check_verify_tls: input.health_check_verify_tls,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pushDns) {
|
||||||
|
let effectiveIps = targetIps;
|
||||||
|
const refreshedBinding = repos.getBinding(db, binding.id);
|
||||||
|
if (refreshedBinding.health_check_enabled) {
|
||||||
|
const activeIps = computeActiveIps(db, "binding", binding.id);
|
||||||
|
if (activeIps.length > 0) {
|
||||||
|
effectiveIps = activeIps;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await syncBindingDns(
|
||||||
db,
|
db,
|
||||||
cf,
|
cf,
|
||||||
binding.id,
|
binding.id,
|
||||||
binding.domain_id,
|
domainId,
|
||||||
binding.hostname,
|
hostname,
|
||||||
|
effectiveIps,
|
||||||
|
targetCname,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
repos.deleteBindingsExcept(db, id, keptBindingIds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removed = repos.bindingsToRemove(db, id, keptBindingIds);
|
||||||
|
removedBindingIds = removed.map((binding) => binding.id);
|
||||||
|
for (const binding of removed) {
|
||||||
|
await cleanupBindingDns(
|
||||||
|
db,
|
||||||
|
cf,
|
||||||
|
binding.id,
|
||||||
|
binding.domain_id,
|
||||||
|
binding.hostname,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
repos.deleteBindingsExcept(db, id, keptBindingIds);
|
||||||
} else if (ipsUpdated) {
|
} else if (ipsUpdated) {
|
||||||
const bindings = repos.listBindingsByService(db, id);
|
const bindings = repos.listBindingsByService(db, id);
|
||||||
for (const binding of bindings) {
|
for (const binding of bindings) {
|
||||||
@@ -1272,17 +1237,24 @@ export async function updateConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
service = repos.getService(db, id);
|
service = repos.getService(db, id);
|
||||||
if (shouldPushDns(db, service)) {
|
const remainingBindings = repos.listBindingsByService(db, id);
|
||||||
await syncServiceBindingsToDns(db, cf, id);
|
|
||||||
await syncGroupDomainForService(db, cf, id);
|
// Creating/editing with FQDN bindings should publish DNS. Previously a
|
||||||
} else if (
|
// disabled service stayed off unless records already existed in Cloudflare
|
||||||
|
// (chicken-and-egg for brand-new services).
|
||||||
|
if (
|
||||||
req.domains &&
|
req.domains &&
|
||||||
req.domains.length > 0 &&
|
req.domains.length > 0 &&
|
||||||
!service.enabled &&
|
!service.enabled
|
||||||
(await serviceBindingsExistInDns(db, cf, id))
|
|
||||||
) {
|
) {
|
||||||
repos.setServiceEnabled(db, id, true);
|
repos.setServiceEnabled(db, id, true);
|
||||||
await syncServiceBindingsToDns(db, cf, id);
|
service = repos.getService(db, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldPushDns(db, service)) {
|
||||||
|
if (remainingBindings.length > 0) {
|
||||||
|
await syncServiceBindingsToDns(db, cf, id);
|
||||||
|
}
|
||||||
await syncGroupDomainForService(db, cf, id);
|
await syncGroupDomainForService(db, cf, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1314,6 +1286,7 @@ export async function createGroup(
|
|||||||
health_check_expected_status: body.health_check_expected_status,
|
health_check_expected_status: body.health_check_expected_status,
|
||||||
health_check_interval_sec: body.health_check_interval_sec,
|
health_check_interval_sec: body.health_check_interval_sec,
|
||||||
health_check_timeout_ms: body.health_check_timeout_ms,
|
health_check_timeout_ms: body.health_check_timeout_ms,
|
||||||
|
health_check_verify_tls: body.health_check_verify_tls,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1349,6 +1322,7 @@ export async function updateGroup(
|
|||||||
health_check_expected_status: body.health_check_expected_status,
|
health_check_expected_status: body.health_check_expected_status,
|
||||||
health_check_interval_sec: body.health_check_interval_sec,
|
health_check_interval_sec: body.health_check_interval_sec,
|
||||||
health_check_timeout_ms: body.health_check_timeout_ms,
|
health_check_timeout_ms: body.health_check_timeout_ms,
|
||||||
|
health_check_verify_tls: body.health_check_verify_tls,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (!domain && group.enabled) {
|
if (!domain && group.enabled) {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { CfdmBindingSyncItem } from "@cfdm/shared";
|
import { resolve4 } from "node:dns/promises";
|
||||||
|
import type { CfdmBindingSyncItem, ServiceBindingView } from "@cfdm/shared";
|
||||||
|
import { isIpLiteral } from "@cfdm/shared";
|
||||||
import type { Db } from "@cfdm/db";
|
import type { Db } from "@cfdm/db";
|
||||||
import { repos, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db";
|
import { repos, getAppSettingsSecrets, touchVpsTrackerSync } from "@cfdm/db";
|
||||||
|
|
||||||
@@ -7,23 +9,127 @@ function fqdnToDisplay(hostname: string, zoneName: string): string {
|
|||||||
return `${hostname}.${zoneName}`;
|
return `${hostname}.${zoneName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildServiceSyncBindings(
|
function normalizeCnameHost(target: string, zoneName: string): string {
|
||||||
|
const trimmed = target.trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
if (!trimmed) return "";
|
||||||
|
if (trimmed.includes(".")) return trimmed;
|
||||||
|
return `${trimmed}.${zoneName.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BindingIpIndex = {
|
||||||
|
byFqdn: Map<string, ServiceBindingView>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildBindingIndex(bindings: ServiceBindingView[]): BindingIpIndex {
|
||||||
|
const byFqdn = new Map<string, ServiceBindingView>();
|
||||||
|
for (const b of bindings) {
|
||||||
|
const fqdn = fqdnToDisplay(b.hostname, b.zone_name).toLowerCase();
|
||||||
|
byFqdn.set(fqdn, b);
|
||||||
|
}
|
||||||
|
return { byFqdn };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Локальная цепочка CNAME → A (по bindings в CFDM), без внешнего DNS. */
|
||||||
|
function resolveIpsLocally(
|
||||||
|
index: BindingIpIndex,
|
||||||
|
startFqdn: string,
|
||||||
|
depth = 0,
|
||||||
|
seen = new Set<string>(),
|
||||||
|
): string[] {
|
||||||
|
const key = startFqdn.toLowerCase().replace(/\.$/, "");
|
||||||
|
if (!key || depth > 8 || seen.has(key)) return [];
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
|
const binding = index.byFqdn.get(key);
|
||||||
|
if (!binding) return [];
|
||||||
|
|
||||||
|
if (binding.target_ips.some(isIpLiteral)) {
|
||||||
|
return binding.target_ips.filter(isIpLiteral);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cname = binding.cname_target?.trim();
|
||||||
|
if (!cname) return [];
|
||||||
|
|
||||||
|
const next = normalizeCnameHost(cname, binding.zone_name);
|
||||||
|
return resolveIpsLocally(index, next, depth + 1, seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveIpsViaDns(hostname: string): Promise<string[]> {
|
||||||
|
const host = hostname.trim().toLowerCase().replace(/\.$/, "");
|
||||||
|
if (!host) return [];
|
||||||
|
try {
|
||||||
|
// resolve4 следует по CNAME до A-записей
|
||||||
|
return await resolve4(host);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP для матчинга в VPS Tracker:
|
||||||
|
* 1) A/AAAA binding (только литералы IP — не CNAME hostname из dns_record.content)
|
||||||
|
* 2) локальная CNAME-цепочка по другим bindings
|
||||||
|
* 3) origin A/AAAA из dns_records CFDM
|
||||||
|
* 4) IP сервиса
|
||||||
|
* 5) публичный DNS — последний и обычно вреден (CF anycast при proxied)
|
||||||
|
*/
|
||||||
|
export async function resolveBindingIpsForSync(
|
||||||
|
binding: ServiceBindingView,
|
||||||
|
serviceIps: string[],
|
||||||
|
index: BindingIpIndex,
|
||||||
|
db?: Db,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const directIps = binding.target_ips.filter(isIpLiteral);
|
||||||
|
if (directIps.length > 0) {
|
||||||
|
return [...directIps];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cname = binding.cname_target?.trim();
|
||||||
|
if (cname) {
|
||||||
|
const targetFqdn = normalizeCnameHost(cname, binding.zone_name);
|
||||||
|
const local = resolveIpsLocally(index, targetFqdn).filter(isIpLiteral);
|
||||||
|
if (local.length > 0) return local;
|
||||||
|
|
||||||
|
if (db) {
|
||||||
|
const fromTable = repos.listOriginIpsForFqdn(db, targetFqdn).filter(isIpLiteral);
|
||||||
|
if (fromTable.length > 0) return fromTable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromService = serviceIps.filter(isIpLiteral);
|
||||||
|
if (fromService.length > 0) return [...fromService];
|
||||||
|
|
||||||
|
if (cname) {
|
||||||
|
const targetFqdn = normalizeCnameHost(cname, binding.zone_name);
|
||||||
|
const viaDns = (await resolveIpsViaDns(targetFqdn)).filter(isIpLiteral);
|
||||||
|
if (viaDns.length > 0) return viaDns;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function cnameTargetForSync(binding: ServiceBindingView): string | undefined {
|
||||||
|
const raw = binding.cname_target?.trim();
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const normalized = normalizeCnameHost(raw, binding.zone_name);
|
||||||
|
return normalized || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildServiceSyncBindingsAsync(
|
||||||
db: Db,
|
db: Db,
|
||||||
serviceId: number,
|
serviceId: number,
|
||||||
deletedBindingIds: number[] = [],
|
deletedBindingIds: number[] = [],
|
||||||
): CfdmBindingSyncItem[] {
|
): Promise<CfdmBindingSyncItem[]> {
|
||||||
const service = repos.getService(db, serviceId);
|
const service = repos.getService(db, serviceId);
|
||||||
const serviceIps = repos.listServiceIps(db, serviceId);
|
const serviceIps = repos.listServiceIps(db, serviceId);
|
||||||
|
const allBindings = repos.listAllBindings(db);
|
||||||
|
const index = buildBindingIndex(allBindings);
|
||||||
const bindings = repos.listBindingsByService(db, serviceId);
|
const bindings = repos.listBindingsByService(db, serviceId);
|
||||||
const items: CfdmBindingSyncItem[] = bindings.map((binding) => {
|
|
||||||
const targetIps = repos.listBindingIps(db, binding.id);
|
const items: CfdmBindingSyncItem[] = [];
|
||||||
const ips =
|
for (const binding of bindings) {
|
||||||
targetIps.length > 0
|
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
|
||||||
? targetIps
|
items.push({
|
||||||
: serviceIps.length > 0
|
|
||||||
? serviceIps
|
|
||||||
: [];
|
|
||||||
return {
|
|
||||||
bindingId: binding.id,
|
bindingId: binding.id,
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
serviceName: service.name,
|
serviceName: service.name,
|
||||||
@@ -32,8 +138,9 @@ export function buildServiceSyncBindings(
|
|||||||
zoneName: binding.zone_name,
|
zoneName: binding.zone_name,
|
||||||
hostname: binding.hostname,
|
hostname: binding.hostname,
|
||||||
ips,
|
ips,
|
||||||
};
|
cnameTarget: cnameTargetForSync(binding),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
for (const bindingId of deletedBindingIds) {
|
for (const bindingId of deletedBindingIds) {
|
||||||
items.push({
|
items.push({
|
||||||
@@ -52,6 +159,36 @@ export function buildServiceSyncBindings(
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function buildAllSyncBindings(
|
||||||
|
db: Db,
|
||||||
|
): Promise<CfdmBindingSyncItem[]> {
|
||||||
|
const bindings = repos.listAllBindings(db);
|
||||||
|
const index = buildBindingIndex(bindings);
|
||||||
|
const serviceIpCache = new Map<number, string[]>();
|
||||||
|
|
||||||
|
const items: CfdmBindingSyncItem[] = [];
|
||||||
|
for (const binding of bindings) {
|
||||||
|
let serviceIps = serviceIpCache.get(binding.service_id);
|
||||||
|
if (!serviceIps) {
|
||||||
|
serviceIps = repos.listServiceIps(db, binding.service_id);
|
||||||
|
serviceIpCache.set(binding.service_id, serviceIps);
|
||||||
|
}
|
||||||
|
const ips = await resolveBindingIpsForSync(binding, serviceIps, index, db);
|
||||||
|
items.push({
|
||||||
|
bindingId: binding.id,
|
||||||
|
serviceId: binding.service_id,
|
||||||
|
serviceName: binding.service_name,
|
||||||
|
serviceSlug: binding.service_slug,
|
||||||
|
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
|
||||||
|
zoneName: binding.zone_name,
|
||||||
|
hostname: binding.hostname,
|
||||||
|
ips,
|
||||||
|
cnameTarget: cnameTargetForSync(binding),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
export async function syncServiceToVpsTracker(
|
export async function syncServiceToVpsTracker(
|
||||||
db: Db,
|
db: Db,
|
||||||
serviceId: number,
|
serviceId: number,
|
||||||
@@ -64,7 +201,11 @@ export async function syncServiceToVpsTracker(
|
|||||||
const token = config.vpsTrackerIntegrationToken;
|
const token = config.vpsTrackerIntegrationToken;
|
||||||
if (!baseUrl || !token) return;
|
if (!baseUrl || !token) return;
|
||||||
|
|
||||||
const bindings = buildServiceSyncBindings(db, serviceId, deletedBindingIds);
|
const bindings = await buildServiceSyncBindingsAsync(
|
||||||
|
db,
|
||||||
|
serviceId,
|
||||||
|
deletedBindingIds,
|
||||||
|
);
|
||||||
if (bindings.length === 0) return;
|
if (bindings.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -91,6 +232,56 @@ export async function syncServiceToVpsTracker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function syncAllToVpsTracker(db: Db): Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
count: number;
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
|
const config = getAppSettingsSecrets(db);
|
||||||
|
if (!config.vpsTrackerSyncEnabled) {
|
||||||
|
return { ok: false, count: 0, error: "Синхронизация выключена в CFDM" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = config.vpsTrackerUrl.replace(/\/$/, "");
|
||||||
|
const token = config.vpsTrackerIntegrationToken;
|
||||||
|
if (!baseUrl) {
|
||||||
|
return { ok: false, count: 0, error: "Укажите URL VPS Tracker" };
|
||||||
|
}
|
||||||
|
if (!token) {
|
||||||
|
return { ok: false, count: 0, error: "Укажите integration token" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindings = await buildAllSyncBindings(db);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl}/api/integrations/cfdm/sync-bindings`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ bindings, fullSync: true }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = await res.text();
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
count: 0,
|
||||||
|
error: `VPS Tracker ${baseUrl} ответил HTTP ${res.status}: ${detail}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
touchVpsTrackerSync(db);
|
||||||
|
return { ok: true, count: bindings.length };
|
||||||
|
} catch (err) {
|
||||||
|
const raw = err instanceof Error ? err.message : "Ошибка сети";
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
count: 0,
|
||||||
|
error: `Не удалось отправить данные в VPS Tracker (${baseUrl}): ${raw}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function pingVpsTracker(db: Db): Promise<{
|
export async function pingVpsTracker(db: Db): Promise<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import * as auditPortalPush from "../src/services/audit-portal-push.js";
|
||||||
|
|
||||||
|
describe("audit log", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/v1/audit returns local entries", async () => {
|
||||||
|
const pushSpy = vi
|
||||||
|
.spyOn(auditPortalPush, "pushAuditEvents")
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const app = await buildApp({
|
||||||
|
config: {
|
||||||
|
...loadConfig(),
|
||||||
|
staticDir: null,
|
||||||
|
authAuditIngestSecret: null,
|
||||||
|
},
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const login = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
payload: { username: "admin", password: "admin" },
|
||||||
|
});
|
||||||
|
expect(login.statusCode).toBe(200);
|
||||||
|
const token = (login.json() as { token: string }).token;
|
||||||
|
|
||||||
|
const create = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/groups",
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { name: "Audit Test", slug: "audit-test" },
|
||||||
|
});
|
||||||
|
expect(create.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const list = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/audit?action=group.create",
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(list.statusCode).toBe(200);
|
||||||
|
const entries = list.json() as { action: string; source_app: string }[];
|
||||||
|
expect(entries.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(entries[0]?.action).toBe("group.create");
|
||||||
|
expect(entries[0]?.source_app).toBe("cfdm");
|
||||||
|
|
||||||
|
expect(pushSpy).not.toHaveBeenCalled();
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recordAudit pushes to portal when secret configured", async () => {
|
||||||
|
const pushSpy = vi
|
||||||
|
.spyOn(auditPortalPush, "pushAuditEvents")
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const app = await buildApp({
|
||||||
|
config: {
|
||||||
|
...loadConfig(),
|
||||||
|
staticDir: null,
|
||||||
|
authPortalUrl: "http://portal.test",
|
||||||
|
authAuditIngestSecret: "test-ingest-secret",
|
||||||
|
},
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const login = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
payload: { username: "admin", password: "admin" },
|
||||||
|
});
|
||||||
|
const token = (login.json() as { token: string }).token;
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/groups",
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
payload: { name: "Portal Push", slug: "portal-push" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(pushSpy).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
const [portalUrl, secret, events] = pushSpy.mock.calls[0]!;
|
||||||
|
expect(portalUrl).toBe("http://portal.test");
|
||||||
|
expect(secret).toBe("test-ingest-secret");
|
||||||
|
expect(events[0]?.source_app).toBe("cfdm");
|
||||||
|
expect(events[0]?.action).toBe("group.create");
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { hasPermission, permissionForRequest } from "../src/lib/permissions.js";
|
||||||
|
|
||||||
|
describe("permissions helpers", () => {
|
||||||
|
it("hasPermission respects admin ⊃ write ⊃ read", () => {
|
||||||
|
expect(hasPermission(["cfdm:domains:write"], "cfdm:domains:read")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(hasPermission(["cfdm:domains:admin"], "cfdm:domains:write")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(hasPermission(["cfdm:domains:read"], "cfdm:domains:write")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("permissionForRequest maps domains and settings", () => {
|
||||||
|
expect(permissionForRequest("GET", "/api/v1/domains")).toBe(
|
||||||
|
"cfdm:domains:read",
|
||||||
|
);
|
||||||
|
expect(permissionForRequest("POST", "/api/v1/domains")).toBe(
|
||||||
|
"cfdm:domains:write",
|
||||||
|
);
|
||||||
|
expect(permissionForRequest("GET", "/api/v1/settings")).toBe(
|
||||||
|
"cfdm:settings:admin",
|
||||||
|
);
|
||||||
|
expect(permissionForRequest("POST", "/api/v1/sync/foo")).toBe(
|
||||||
|
"cfdm:domains:write",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth plugin (AUTH_REQUIRED)", () => {
|
||||||
|
const secret = "test-secret-at-least-8";
|
||||||
|
const issuer = "https://auth.shnt.top";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.AUTH_REQUIRED = "true";
|
||||||
|
process.env.AUTH_JWT_SECRET = secret;
|
||||||
|
process.env.AUTH_ISSUER = issuer;
|
||||||
|
process.env.AUTH_PORTAL_URL = "http://localhost:5175";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.AUTH_REQUIRED;
|
||||||
|
delete process.env.AUTH_JWT_SECRET;
|
||||||
|
delete process.env.AUTH_ISSUER;
|
||||||
|
delete process.env.AUTH_PORTAL_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/v1/auth/config exposes portal settings", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: {
|
||||||
|
...loadConfig(),
|
||||||
|
authRequired: true,
|
||||||
|
jwtSecret: secret,
|
||||||
|
authIssuer: issuer,
|
||||||
|
authPortalUrl: "http://localhost:5175",
|
||||||
|
staticDir: null,
|
||||||
|
},
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({ method: "GET", url: "/api/v1/auth/config" });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toMatchObject({
|
||||||
|
required: true,
|
||||||
|
portal_url: "http://localhost:5175",
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("401 without token; 403 without cfdm app; 200 with rights", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: {
|
||||||
|
...loadConfig(),
|
||||||
|
authRequired: true,
|
||||||
|
jwtSecret: secret,
|
||||||
|
authIssuer: issuer,
|
||||||
|
authPortalUrl: "http://localhost:5175",
|
||||||
|
staticDir: null,
|
||||||
|
},
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const noAuth = await app.inject({ method: "GET", url: "/api/v1/domains" });
|
||||||
|
expect(noAuth.statusCode).toBe(401);
|
||||||
|
|
||||||
|
const tokenNoApp = app.jwt.sign(
|
||||||
|
{
|
||||||
|
sub: "u1",
|
||||||
|
email: "a@b.c",
|
||||||
|
name: "A",
|
||||||
|
apps: ["vps"],
|
||||||
|
permissions: ["cfdm:domains:read"],
|
||||||
|
iss: issuer,
|
||||||
|
},
|
||||||
|
{ expiresIn: "1h" },
|
||||||
|
);
|
||||||
|
const forbiddenApp = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/domains",
|
||||||
|
headers: { authorization: `Bearer ${tokenNoApp}` },
|
||||||
|
});
|
||||||
|
expect(forbiddenApp.statusCode).toBe(403);
|
||||||
|
|
||||||
|
const okToken = app.jwt.sign(
|
||||||
|
{
|
||||||
|
sub: "u2",
|
||||||
|
email: "r@b.c",
|
||||||
|
name: "R",
|
||||||
|
apps: ["cfdm"],
|
||||||
|
permissions: ["cfdm:domains:read"],
|
||||||
|
iss: issuer,
|
||||||
|
},
|
||||||
|
{ expiresIn: "1h" },
|
||||||
|
);
|
||||||
|
const okRead = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/domains",
|
||||||
|
headers: { authorization: `Bearer ${okToken}` },
|
||||||
|
});
|
||||||
|
expect(okRead.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const denyWrite = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/domains",
|
||||||
|
headers: { authorization: `Bearer ${okToken}` },
|
||||||
|
payload: { name: "x" },
|
||||||
|
});
|
||||||
|
expect(denyWrite.statusCode).toBe(403);
|
||||||
|
|
||||||
|
const loginBlocked = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
payload: { username: "admin", password: "admin" },
|
||||||
|
});
|
||||||
|
expect(loginBlocked.statusCode).toBe(403);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -73,7 +73,17 @@ describe("certificates", () => {
|
|||||||
);
|
);
|
||||||
const service = repos.createService(testApp.db, "Web", "web");
|
const service = repos.createService(testApp.db, "Web", "web");
|
||||||
repos.setServiceEnabled(testApp.db, service.id, true);
|
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||||
repos.insertBinding(testApp.db, domain.id, service.id, "api", null);
|
const binding = repos.insertBinding(
|
||||||
|
testApp.db,
|
||||||
|
domain.id,
|
||||||
|
service.id,
|
||||||
|
"api",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_verify_tls: true,
|
||||||
|
});
|
||||||
|
|
||||||
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||||
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||||
@@ -94,6 +104,76 @@ describe("certificates", () => {
|
|||||||
await testApp.close();
|
await testApp.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not monitor binding when health-check is off", async () => {
|
||||||
|
const testApp = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(testApp);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(
|
||||||
|
testApp.db,
|
||||||
|
null,
|
||||||
|
"rkns.example.com",
|
||||||
|
"cf-zone-imsk",
|
||||||
|
);
|
||||||
|
const service = repos.createService(testApp.db, "Cname", "cname");
|
||||||
|
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||||
|
const binding = repos.insertBinding(
|
||||||
|
testApp.db,
|
||||||
|
domain.id,
|
||||||
|
service.id,
|
||||||
|
"imsk",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
repos.setBindingCnameTarget(testApp.db, binding.id, "ihome.rkns.example.com");
|
||||||
|
repos.updateBindingLbConfig(testApp.db, binding.id, {
|
||||||
|
health_check_enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
repos.upsertCertificateCheck(
|
||||||
|
testApp.db,
|
||||||
|
domain.id,
|
||||||
|
null,
|
||||||
|
"imsk.rkns.example.com",
|
||||||
|
null,
|
||||||
|
CERT_ERROR,
|
||||||
|
"stale",
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||||
|
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await testApp.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/certificates/check",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
repos.listCertificates(testApp.db).some(
|
||||||
|
(c) => c.hostname === "imsk.rkns.example.com",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(certificateService.checkHostname).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const listRes = await testApp.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/certificates",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(listRes.statusCode).toBe(200);
|
||||||
|
expect(
|
||||||
|
(listRes.json() as { hostname: string }[]).some(
|
||||||
|
(c) => c.hostname === "imsk.rkns.example.com",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
await testApp.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not monitor host when service is disabled", async () => {
|
it("does not monitor host when service is disabled", async () => {
|
||||||
const testApp = await buildApp({
|
const testApp = await buildApp({
|
||||||
config: { ...loadConfig(), staticDir: null },
|
config: { ...loadConfig(), staticDir: null },
|
||||||
@@ -248,4 +328,216 @@ describe("certificates", () => {
|
|||||||
|
|
||||||
await testApp.close();
|
await testApp.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("skips SSL monitoring when group health is on without TLS verify", async () => {
|
||||||
|
const testApp = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(testApp);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(
|
||||||
|
testApp.db,
|
||||||
|
null,
|
||||||
|
"rkns.example.com",
|
||||||
|
"cf-zone-rkns",
|
||||||
|
);
|
||||||
|
const group = repos.createServiceGroup(
|
||||||
|
testApp.db,
|
||||||
|
"TG Proxy",
|
||||||
|
"vpn",
|
||||||
|
null,
|
||||||
|
"gt.rkns.example.com",
|
||||||
|
{
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "http",
|
||||||
|
health_check_port: 443,
|
||||||
|
health_check_verify_tls: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const service = repos.createService(testApp.db, "Node", "node");
|
||||||
|
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||||
|
repos.setServiceGroup(testApp.db, service.id, group.id);
|
||||||
|
repos.insertBinding(testApp.db, domain.id, service.id, "rutg", null);
|
||||||
|
|
||||||
|
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||||
|
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await testApp.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/certificates/check",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const certs = repos.listCertificates(testApp.db);
|
||||||
|
expect(certs.some((c) => c.hostname === "gt.rkns.example.com")).toBe(false);
|
||||||
|
expect(certs.some((c) => c.hostname === "rutg.rkns.example.com")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(certificateService.checkHostname).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await testApp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("monitors group hosts when health TLS verify is on", async () => {
|
||||||
|
const testApp = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(testApp);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(
|
||||||
|
testApp.db,
|
||||||
|
null,
|
||||||
|
"ok.example.com",
|
||||||
|
"cf-zone-ok",
|
||||||
|
);
|
||||||
|
const group = repos.createServiceGroup(
|
||||||
|
testApp.db,
|
||||||
|
"LB",
|
||||||
|
"vpn",
|
||||||
|
null,
|
||||||
|
"lb.ok.example.com",
|
||||||
|
{
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "http",
|
||||||
|
health_check_port: 443,
|
||||||
|
health_check_verify_tls: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const service = repos.createService(testApp.db, "Edge", "edge");
|
||||||
|
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||||
|
repos.setServiceGroup(testApp.db, service.id, group.id);
|
||||||
|
repos.insertBinding(testApp.db, domain.id, service.id, "edge", null);
|
||||||
|
|
||||||
|
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||||
|
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await testApp.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/certificates/check",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const certs = repos.listCertificates(testApp.db);
|
||||||
|
expect(certs.some((c) => c.hostname === "lb.ok.example.com")).toBe(true);
|
||||||
|
expect(certs.some((c) => c.hostname === "edge.ok.example.com")).toBe(true);
|
||||||
|
|
||||||
|
await testApp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("required mode still monitors when group skips TLS verify", async () => {
|
||||||
|
const testApp = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(testApp);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(
|
||||||
|
testApp.db,
|
||||||
|
null,
|
||||||
|
"force.example.com",
|
||||||
|
"cf-zone-force",
|
||||||
|
);
|
||||||
|
repos.updateDomain(testApp.db, domain.id, {
|
||||||
|
group_id: null,
|
||||||
|
status: "active",
|
||||||
|
cert_monitoring: CERT_MONITOR_REQUIRED,
|
||||||
|
});
|
||||||
|
repos.createServiceGroup(
|
||||||
|
testApp.db,
|
||||||
|
"Proxy",
|
||||||
|
"vpn",
|
||||||
|
null,
|
||||||
|
"force.example.com",
|
||||||
|
{
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_verify_tls: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.spyOn(certificateService, "checkHostname").mockResolvedValue({
|
||||||
|
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await testApp.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/certificates/check",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
repos.listCertificates(testApp.db).some(
|
||||||
|
(c) => c.hostname === domain.zone_name,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
await testApp.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /certificates prunes stale rows without running check", async () => {
|
||||||
|
const testApp = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(testApp);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(
|
||||||
|
testApp.db,
|
||||||
|
null,
|
||||||
|
"stale-list.example.com",
|
||||||
|
"cf-zone-stale-list",
|
||||||
|
);
|
||||||
|
const group = repos.createServiceGroup(
|
||||||
|
testApp.db,
|
||||||
|
"Stale",
|
||||||
|
"vpn",
|
||||||
|
null,
|
||||||
|
"gt.stale-list.example.com",
|
||||||
|
{
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_verify_tls: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const service = repos.createService(testApp.db, "S", "s");
|
||||||
|
repos.setServiceEnabled(testApp.db, service.id, true);
|
||||||
|
repos.setServiceGroup(testApp.db, service.id, group.id);
|
||||||
|
repos.insertBinding(testApp.db, domain.id, service.id, "rutg", null);
|
||||||
|
|
||||||
|
repos.upsertCertificateCheck(
|
||||||
|
testApp.db,
|
||||||
|
domain.id,
|
||||||
|
null,
|
||||||
|
"gt.stale-list.example.com",
|
||||||
|
null,
|
||||||
|
CERT_ERROR,
|
||||||
|
"stale group domain",
|
||||||
|
);
|
||||||
|
repos.upsertCertificateCheck(
|
||||||
|
testApp.db,
|
||||||
|
domain.id,
|
||||||
|
null,
|
||||||
|
"rutg.stale-list.example.com",
|
||||||
|
null,
|
||||||
|
CERT_ERROR,
|
||||||
|
"stale binding",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(repos.listCertificates(testApp.db)).toHaveLength(2);
|
||||||
|
|
||||||
|
const listRes = await testApp.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/certificates",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(listRes.statusCode).toBe(200);
|
||||||
|
expect(listRes.json()).toEqual([]);
|
||||||
|
|
||||||
|
await testApp.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
import { describe, expect, it, beforeAll, afterAll } from "vitest";
|
||||||
import { createServer, type Server } from "node:net";
|
import { createServer, type Server } from "node:net";
|
||||||
|
import { createServer as createHttpServer, type Server as HttpServer } from "node:http";
|
||||||
import * as healthCheckService from "../src/services/health-check-service.js";
|
import * as healthCheckService from "../src/services/health-check-service.js";
|
||||||
import type { HealthCheckTarget } from "@cfdm/shared";
|
import type { HealthCheckTarget } from "@cfdm/shared";
|
||||||
|
|
||||||
@@ -15,6 +16,20 @@ function startTcpServer(): Promise<{ server: Server; port: number }> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("health-check URL helpers", () => {
|
||||||
|
it("buildHttpProbeUrl uses FQDN in URL (IP pinned via connector)", () => {
|
||||||
|
expect(
|
||||||
|
healthCheckService.buildHttpProbeUrl("gt.rkns.top", 443, "/", true),
|
||||||
|
).toBe("https://gt.rkns.top/");
|
||||||
|
expect(
|
||||||
|
healthCheckService.buildHttpProbeUrl("gt.rkns.top", 8080, "/health", false),
|
||||||
|
).toBe("http://gt.rkns.top:8080/health");
|
||||||
|
expect(
|
||||||
|
healthCheckService.buildHttpProbeUrl("2001:db8::1", 443, "/", true),
|
||||||
|
).toBe("https://[2001:db8::1]/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("health-check probeTarget", () => {
|
describe("health-check probeTarget", () => {
|
||||||
let server: Server;
|
let server: Server;
|
||||||
let port: number;
|
let port: number;
|
||||||
@@ -40,6 +55,7 @@ describe("health-check probeTarget", () => {
|
|||||||
path: null,
|
path: null,
|
||||||
expected_status: null,
|
expected_status: null,
|
||||||
timeout_ms: 1000,
|
timeout_ms: 1000,
|
||||||
|
verify_tls: false,
|
||||||
};
|
};
|
||||||
const result = await healthCheckService.probeTarget(target);
|
const result = await healthCheckService.probeTarget(target);
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
@@ -58,11 +74,83 @@ describe("health-check probeTarget", () => {
|
|||||||
path: null,
|
path: null,
|
||||||
expected_status: null,
|
expected_status: null,
|
||||||
timeout_ms: 500,
|
timeout_ms: 500,
|
||||||
|
verify_tls: false,
|
||||||
};
|
};
|
||||||
const result = await healthCheckService.probeTarget(target);
|
const result = await healthCheckService.probeTarget(target);
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
expect(result.error).not.toBeNull();
|
expect(result.error).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("http probe hits IP with Host=hostname (same IP, different FQDN)", async () => {
|
||||||
|
let seenHost: string | undefined;
|
||||||
|
const httpServer: HttpServer = createHttpServer((req, res) => {
|
||||||
|
seenHost = req.headers.host;
|
||||||
|
res.writeHead(200);
|
||||||
|
res.end("ok");
|
||||||
|
});
|
||||||
|
const httpPort = await new Promise<number>((resolve) => {
|
||||||
|
httpServer.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = httpServer.address();
|
||||||
|
resolve(typeof address === "object" && address ? address.port : 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const groupTarget: HealthCheckTarget = {
|
||||||
|
scope: "group",
|
||||||
|
ref_id: 1,
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
hostname: "gt.rkns.top",
|
||||||
|
type: "http",
|
||||||
|
port: httpPort,
|
||||||
|
path: "/",
|
||||||
|
expected_status: 200,
|
||||||
|
timeout_ms: 1000,
|
||||||
|
verify_tls: false,
|
||||||
|
};
|
||||||
|
const bindingTarget: HealthCheckTarget = {
|
||||||
|
...groupTarget,
|
||||||
|
scope: "binding",
|
||||||
|
hostname: "rutg.rkns.top",
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupResult = await healthCheckService.probeTarget(groupTarget);
|
||||||
|
expect(groupResult.ok).toBe(true);
|
||||||
|
expect(seenHost?.startsWith("gt.rkns.top")).toBe(true);
|
||||||
|
|
||||||
|
const bindingResult = await healthCheckService.probeTarget(bindingTarget);
|
||||||
|
expect(bindingResult.ok).toBe(true);
|
||||||
|
expect(seenHost?.startsWith("rutg.rkns.top")).toBe(true);
|
||||||
|
|
||||||
|
// Same loopback IP → latencies in the same ballpark (not ~1s DNS skew)
|
||||||
|
expect(Math.abs(groupResult.latencyMs - bindingResult.latencyMs)).toBeLessThan(200);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("physicalProbeKey collapses group+binding on same IP for tcp", async () => {
|
||||||
|
const { physicalProbeKey } = await import("../src/services/health-check-service.js");
|
||||||
|
const group: HealthCheckTarget = {
|
||||||
|
scope: "group",
|
||||||
|
ref_id: 1,
|
||||||
|
ip: "93.115.203.183",
|
||||||
|
hostname: "gt.rkns.top",
|
||||||
|
type: "tcp",
|
||||||
|
port: 443,
|
||||||
|
path: null,
|
||||||
|
expected_status: null,
|
||||||
|
timeout_ms: 3000,
|
||||||
|
verify_tls: false,
|
||||||
|
};
|
||||||
|
const binding: HealthCheckTarget = {
|
||||||
|
...group,
|
||||||
|
scope: "binding",
|
||||||
|
ref_id: 2,
|
||||||
|
hostname: "rutg.rkns.top",
|
||||||
|
};
|
||||||
|
expect(physicalProbeKey(group)).toBe(physicalProbeKey(binding));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("health-check state derivation via runAllChecks", () => {
|
describe("health-check state derivation via runAllChecks", () => {
|
||||||
@@ -90,7 +178,6 @@ describe("health-check state derivation via runAllChecks", () => {
|
|||||||
{ ip: "127.0.0.1", weight: 1, priority: 1 },
|
{ ip: "127.0.0.1", weight: 1, priority: 1 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Запуск с closed port (port 1) — должен зафиксировать down после 2 проверок
|
|
||||||
await healthCheckService.runAllChecks(db, {
|
await healthCheckService.runAllChecks(db, {
|
||||||
thresholds: {
|
thresholds: {
|
||||||
degradedFailures: 1,
|
degradedFailures: 1,
|
||||||
@@ -121,4 +208,27 @@ describe("health-check state derivation via runAllChecks", () => {
|
|||||||
);
|
);
|
||||||
expect(status?.status).toBe("down");
|
expect(status?.status).toBe("down");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("listHealthCheckTargets includes verify_tls from binding config", async () => {
|
||||||
|
const { createMemoryDb, repos, runMigrations } = await import("@cfdm/db");
|
||||||
|
const { db, sqlite } = createMemoryDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(db, null, "example.com", "zone-id");
|
||||||
|
const service = repos.createService(db, "Svc", "svc");
|
||||||
|
const binding = repos.insertBinding(db, domain.id, service.id, "@", null);
|
||||||
|
repos.updateBindingLbConfig(db, binding.id, {
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "http",
|
||||||
|
health_check_port: 443,
|
||||||
|
health_check_verify_tls: true,
|
||||||
|
});
|
||||||
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||||
|
{ ip: "10.0.0.1", weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const targets = repos.listHealthCheckTargets(db);
|
||||||
|
expect(targets).toHaveLength(1);
|
||||||
|
expect(targets[0]?.verify_tls).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
|
||||||
|
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||||
|
import { updateConfig } from "../src/services/service-config-service.js";
|
||||||
|
|
||||||
|
function mockCf(): CloudflareClient {
|
||||||
|
return {
|
||||||
|
listDnsRecords: async () => [],
|
||||||
|
createDnsRecord: async () => ({
|
||||||
|
id: "cf-new",
|
||||||
|
type: "A",
|
||||||
|
name: "api.example.com",
|
||||||
|
content: "10.0.0.1",
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
updateDnsRecord: async () => ({
|
||||||
|
id: "cf-upd",
|
||||||
|
type: "A",
|
||||||
|
name: "api.example.com",
|
||||||
|
content: "10.0.0.1",
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
deleteDnsRecord: async () => undefined,
|
||||||
|
verifyToken: async () => true,
|
||||||
|
listZones: async () => [],
|
||||||
|
} as unknown as CloudflareClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDb() {
|
||||||
|
const { db, sqlite } = createMemoryDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("service bindings prune", () => {
|
||||||
|
it("updateConfig with domains: [] removes all bindings", async () => {
|
||||||
|
const db = setupDb();
|
||||||
|
const cf = mockCf();
|
||||||
|
|
||||||
|
const domain = repos.createDomain(db, null, "example.com", "cf-zone-example");
|
||||||
|
const service = repos.createService(db, "Web", "web");
|
||||||
|
repos.replaceServiceIps(db, service.id, ["10.0.0.1"]);
|
||||||
|
const binding = repos.insertBinding(db, domain.id, service.id, "api", null);
|
||||||
|
repos.replaceBindingIps(db, binding.id, ["10.0.0.1"]);
|
||||||
|
|
||||||
|
expect(repos.listBindingsByService(db, service.id)).toHaveLength(1);
|
||||||
|
|
||||||
|
const view = await updateConfig(db, cf, service.id, {
|
||||||
|
ips: ["10.0.0.1"],
|
||||||
|
domains: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(repos.listBindingsByService(db, service.id)).toHaveLength(0);
|
||||||
|
expect(view.domains).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updateConfig keeps multiple FQDN bindings", async () => {
|
||||||
|
const db = setupDb();
|
||||||
|
const cf = mockCf();
|
||||||
|
|
||||||
|
repos.createDomain(db, null, "a.example", "cf-zone-a");
|
||||||
|
repos.createDomain(db, null, "b.example", "cf-zone-b");
|
||||||
|
const service = repos.createService(db, "Edge", "edge");
|
||||||
|
repos.replaceServiceIps(db, service.id, ["10.0.0.2"]);
|
||||||
|
|
||||||
|
const view = await updateConfig(db, cf, service.id, {
|
||||||
|
ips: ["10.0.0.2"],
|
||||||
|
domains: [
|
||||||
|
{ fqdn: "api.a.example", target_ips: ["10.0.0.2"] },
|
||||||
|
{ fqdn: "www.b.example", target_ips: ["10.0.0.2"] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(view.domains).toHaveLength(2);
|
||||||
|
const bindings = repos.listBindingsByService(db, service.id);
|
||||||
|
expect(bindings).toHaveLength(2);
|
||||||
|
expect(bindings.map((b) => b.hostname).sort()).toEqual(["api", "www"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildApp } from "../src/app.js";
|
import { buildApp } from "../src/app.js";
|
||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
import { repos } from "@cfdm/db";
|
import { createMemoryDb, repos, runMigrations } from "@cfdm/db";
|
||||||
|
|
||||||
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -31,8 +31,14 @@ describe("service groups health enrichment", () => {
|
|||||||
null,
|
null,
|
||||||
"vpn.example.com",
|
"vpn.example.com",
|
||||||
);
|
);
|
||||||
|
repos.updateServiceGroup(app.db, group.id, "VPN", "vpn", null, "vpn.example.com", {
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: 443,
|
||||||
|
});
|
||||||
const service = repos.createService(app.db, "Panel", "panel");
|
const service = repos.createService(app.db, "Panel", "panel");
|
||||||
repos.setServiceGroup(app.db, service.id, group.id);
|
repos.setServiceGroup(app.db, service.id, group.id);
|
||||||
|
repos.setServiceEnabled(app.db, service.id, true);
|
||||||
const binding = repos.insertBinding(
|
const binding = repos.insertBinding(
|
||||||
app.db,
|
app.db,
|
||||||
domain.id,
|
domain.id,
|
||||||
@@ -57,7 +63,7 @@ describe("service groups health enrichment", () => {
|
|||||||
app.db,
|
app.db,
|
||||||
"group",
|
"group",
|
||||||
group.id,
|
group.id,
|
||||||
"5.6.7.8",
|
"1.2.3.4",
|
||||||
"up",
|
"up",
|
||||||
40,
|
40,
|
||||||
0,
|
0,
|
||||||
@@ -91,4 +97,124 @@ describe("service groups health enrichment", () => {
|
|||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("group badge ignores disabled service health and pool-only dead IPs", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(app.db, null, "rkns.top", "zone-tg");
|
||||||
|
const group = repos.createServiceGroup(
|
||||||
|
app.db,
|
||||||
|
"TG Proxy",
|
||||||
|
"custom",
|
||||||
|
null,
|
||||||
|
"gt.rkns.top",
|
||||||
|
);
|
||||||
|
repos.updateServiceGroup(app.db, group.id, "TG Proxy", "custom", null, "gt.rkns.top", {
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: 443,
|
||||||
|
});
|
||||||
|
|
||||||
|
const live = repos.createService(app.db, "Live", "live");
|
||||||
|
repos.setServiceGroup(app.db, live.id, group.id);
|
||||||
|
repos.setServiceEnabled(app.db, live.id, true);
|
||||||
|
repos.replaceServiceIps(app.db, live.id, ["10.0.0.1", "10.0.0.99"]);
|
||||||
|
const liveBinding = repos.insertBinding(app.db, domain.id, live.id, "gt", null);
|
||||||
|
repos.replaceBindingIpsWithMeta(app.db, liveBinding.id, [
|
||||||
|
{ ip: "10.0.0.1", weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const dead = repos.createService(app.db, "Dead", "dead");
|
||||||
|
repos.setServiceGroup(app.db, dead.id, group.id);
|
||||||
|
repos.setServiceEnabled(app.db, dead.id, false);
|
||||||
|
const deadBinding = repos.insertBinding(app.db, domain.id, dead.id, "old", null);
|
||||||
|
repos.replaceBindingIpsWithMeta(app.db, deadBinding.id, [
|
||||||
|
{ ip: "10.0.0.2", weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Live binding + group FQDN are up; pool-only IP and disabled service are down.
|
||||||
|
repos.upsertIpHealthStatus(app.db, "binding", liveBinding.id, "10.0.0.1", "up", 20, 0, null);
|
||||||
|
repos.upsertIpHealthStatus(app.db, "group", group.id, "10.0.0.1", "up", 20, 0, null);
|
||||||
|
repos.upsertIpHealthStatus(app.db, "group", group.id, "10.0.0.99", "down", null, 5, "timeout");
|
||||||
|
repos.upsertIpHealthStatus(app.db, "binding", deadBinding.id, "10.0.0.2", "down", null, 5, "timeout");
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/service-groups",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as {
|
||||||
|
groups: Array<{ id: number; health_status: string }>;
|
||||||
|
};
|
||||||
|
const groupView = body.groups.find((g) => g.id === group.id);
|
||||||
|
expect(groupView?.health_status).toBe("up");
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("group health check targets", () => {
|
||||||
|
it("probes binding IPs for group scope, not unused pool IPs", () => {
|
||||||
|
const { db, sqlite } = createMemoryDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(db, null, "rkns.top", "zone-1");
|
||||||
|
const group = repos.createServiceGroup(db, "TG Proxy", "custom", null, "gt.rkns.top");
|
||||||
|
repos.updateServiceGroup(db, group.id, "TG Proxy", "custom", null, "gt.rkns.top", {
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: 443,
|
||||||
|
});
|
||||||
|
const service = repos.createService(db, "Svc", "svc");
|
||||||
|
repos.setServiceGroup(db, service.id, group.id);
|
||||||
|
repos.setServiceEnabled(db, service.id, true);
|
||||||
|
repos.replaceServiceIps(db, service.id, ["10.0.0.1", "10.0.0.99"]);
|
||||||
|
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
|
||||||
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||||
|
{ ip: "10.0.0.1", weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const targets = repos.listHealthCheckTargets(db);
|
||||||
|
const groupScope = targets.filter((t) => t.scope === "group" && t.ref_id === group.id);
|
||||||
|
expect(groupScope.map((t) => t.ip).sort()).toEqual(["10.0.0.1"]);
|
||||||
|
expect(groupScope.every((t) => t.hostname === "gt.rkns.top")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pruneStaleIpHealthStatus drops pool-only and orphan group rows", () => {
|
||||||
|
const { db, sqlite } = createMemoryDb();
|
||||||
|
runMigrations(sqlite);
|
||||||
|
|
||||||
|
const domain = repos.createDomain(db, null, "rkns.top", "zone-1");
|
||||||
|
const group = repos.createServiceGroup(db, "TG Proxy", "custom", null, "gt.rkns.top");
|
||||||
|
repos.updateServiceGroup(db, group.id, "TG Proxy", "custom", null, "gt.rkns.top", {
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: 443,
|
||||||
|
});
|
||||||
|
const service = repos.createService(db, "Svc", "svc");
|
||||||
|
repos.setServiceGroup(db, service.id, group.id);
|
||||||
|
repos.setServiceEnabled(db, service.id, true);
|
||||||
|
repos.replaceServiceIps(db, service.id, ["10.0.0.1", "10.0.0.99"]);
|
||||||
|
const binding = repos.insertBinding(db, domain.id, service.id, "gt", null);
|
||||||
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||||
|
{ ip: "10.0.0.1", weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
repos.upsertIpHealthStatus(db, "group", group.id, "10.0.0.1", "up", 10, 0, null);
|
||||||
|
repos.upsertIpHealthStatus(db, "group", group.id, "10.0.0.99", "down", null, 3, "dead pool");
|
||||||
|
repos.upsertIpHealthStatus(db, "group", 999, "1.1.1.1", "down", null, 3, "orphan ref");
|
||||||
|
|
||||||
|
const targets = repos.listHealthCheckTargets(db);
|
||||||
|
const deleted = repos.pruneStaleIpHealthStatus(db, targets);
|
||||||
|
expect(deleted).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
const rows = repos.listIpHealthStatus(db, "group", group.id);
|
||||||
|
expect(rows.map((r) => r.ip)).toEqual(["10.0.0.1"]);
|
||||||
|
expect(repos.listIpHealthStatus(db, "group", 999)).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { serviceGroupsResponseSchema } from "@cfdm/shared";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type { CloudflareClient } from "../src/lib/cf-client.js";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import {
|
||||||
|
listGroupViews,
|
||||||
|
updateConfig,
|
||||||
|
} from "../src/services/service-config-service.js";
|
||||||
|
|
||||||
|
function mockCf(): CloudflareClient {
|
||||||
|
return {
|
||||||
|
listDnsRecords: async () => [],
|
||||||
|
createDnsRecord: async (_zoneId: string, payload: { type: string; name: string; content: string }) => ({
|
||||||
|
id: `cf-${payload.name}`,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
updateDnsRecord: async (
|
||||||
|
_zoneId: string,
|
||||||
|
id: string,
|
||||||
|
payload: { type: string; name: string; content: string },
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
type: payload.type,
|
||||||
|
name: payload.name,
|
||||||
|
content: payload.content,
|
||||||
|
ttl: 1,
|
||||||
|
proxied: false,
|
||||||
|
}),
|
||||||
|
deleteDnsRecord: async () => undefined,
|
||||||
|
verifyToken: async () => true,
|
||||||
|
listZones: async () => [{ id: "zone-1", name: "example.com", status: "active" }],
|
||||||
|
} as unknown as CloudflareClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||||
|
const config = loadConfig();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
payload: { username: config.adminUsername, password: "admin" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const { token } = res.json() as { token: string };
|
||||||
|
return { authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("create service then list groups", () => {
|
||||||
|
it("create + updateConfig then listGroupViews parses with shared Zod schema", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const cf = mockCf();
|
||||||
|
|
||||||
|
repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||||
|
const group = repos.createServiceGroup(
|
||||||
|
app.db,
|
||||||
|
"VPN",
|
||||||
|
"vpn",
|
||||||
|
null,
|
||||||
|
"vpn.example.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/services",
|
||||||
|
headers,
|
||||||
|
payload: {
|
||||||
|
name: "Panel",
|
||||||
|
slug: "panel",
|
||||||
|
service_group_id: group.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createRes.statusCode).toBe(200);
|
||||||
|
const created = createRes.json() as {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
expect(created.id).toBeTypeOf("number");
|
||||||
|
expect(created.enabled).toBe(true);
|
||||||
|
|
||||||
|
await updateConfig(app.db, cf, created.id, {
|
||||||
|
ips: ["1.2.3.4"],
|
||||||
|
service_group_id: group.id,
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
fqdn: "panel.example.com",
|
||||||
|
target_ips: ["1.2.3.4"],
|
||||||
|
target_ip_weights: { "1.2.3.4": 1 },
|
||||||
|
target_ip_priorities: { "1.2.3.4": 1 },
|
||||||
|
lb_mode: "round_robin",
|
||||||
|
health_check_enabled: true,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: 443,
|
||||||
|
health_check_path: null,
|
||||||
|
health_check_expected_status: null,
|
||||||
|
health_check_interval_sec: 30,
|
||||||
|
health_check_timeout_ms: 3000,
|
||||||
|
health_check_verify_tls: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = await listGroupViews(app.db);
|
||||||
|
const parsed = serviceGroupsResponseSchema.safeParse(body);
|
||||||
|
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
|
||||||
|
|
||||||
|
const listRes = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/service-groups",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(listRes.statusCode).toBe(200);
|
||||||
|
const httpParsed = serviceGroupsResponseSchema.safeParse(listRes.json());
|
||||||
|
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);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST /services returns resolved ServiceView with numeric id", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/services",
|
||||||
|
headers,
|
||||||
|
payload: { name: "Bare", slug: "bare" },
|
||||||
|
});
|
||||||
|
expect(createRes.statusCode).toBe(200);
|
||||||
|
const created = createRes.json() as Record<string, unknown>;
|
||||||
|
expect(typeof created.id).toBe("number");
|
||||||
|
expect(created.name).toBe("Bare");
|
||||||
|
expect(created.enabled).toBe(true);
|
||||||
|
expect(Array.isArray(created.ips)).toBe(true);
|
||||||
|
expect(Array.isArray(created.domains)).toBe(true);
|
||||||
|
|
||||||
|
const listRes = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/service-groups",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(listRes.statusCode).toBe(200);
|
||||||
|
const parsed = serviceGroupsResponseSchema.safeParse(listRes.json());
|
||||||
|
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
|
||||||
|
expect(parsed.data!.ungrouped.some((s) => s.slug === "bare")).toBe(true);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ServiceBindingView } from "@cfdm/shared";
|
||||||
|
import { resolveBindingIpsForSync } from "../src/services/vps-tracker-sync.js";
|
||||||
|
|
||||||
|
function binding(
|
||||||
|
partial: Partial<ServiceBindingView> &
|
||||||
|
Pick<ServiceBindingView, "id" | "hostname" | "zone_name">,
|
||||||
|
): ServiceBindingView {
|
||||||
|
return {
|
||||||
|
domain_id: 1,
|
||||||
|
service_id: 1,
|
||||||
|
dns_record_id: null,
|
||||||
|
group_id: null,
|
||||||
|
group_name: null,
|
||||||
|
service_name: "svc",
|
||||||
|
service_slug: "svc",
|
||||||
|
target_ip: null,
|
||||||
|
target_ips: [],
|
||||||
|
target_ip_weights: {},
|
||||||
|
target_ip_priorities: {},
|
||||||
|
cname_target: null,
|
||||||
|
lb_mode: "off",
|
||||||
|
health_check_enabled: false,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: null,
|
||||||
|
health_check_path: null,
|
||||||
|
health_check_expected_status: null,
|
||||||
|
health_check_interval_sec: 60,
|
||||||
|
health_check_timeout_ms: 3000,
|
||||||
|
health_check_verify_tls: true,
|
||||||
|
sync_status: null,
|
||||||
|
created_at: "",
|
||||||
|
updated_at: "",
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveBindingIpsForSync", () => {
|
||||||
|
it("uses A-record IPs when present", async () => {
|
||||||
|
const a = binding({
|
||||||
|
id: 1,
|
||||||
|
hostname: "ihome",
|
||||||
|
zone_name: "rkns.top",
|
||||||
|
target_ips: ["10.0.0.5"],
|
||||||
|
});
|
||||||
|
const index = { byFqdn: new Map([["ihome.rkns.top", a]]) };
|
||||||
|
const ips = await resolveBindingIpsForSync(a, ["9.9.9.9"], index);
|
||||||
|
expect(ips).toEqual(["10.0.0.5"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves CNAME via local binding chain to VPS IP", async () => {
|
||||||
|
const target = binding({
|
||||||
|
id: 1,
|
||||||
|
hostname: "ihome",
|
||||||
|
zone_name: "rkns.top",
|
||||||
|
target_ips: ["203.0.113.10"],
|
||||||
|
});
|
||||||
|
const cname = binding({
|
||||||
|
id: 2,
|
||||||
|
hostname: "imsk",
|
||||||
|
zone_name: "rkns.top",
|
||||||
|
cname_target: "ihome.rkns.top",
|
||||||
|
target_ips: [],
|
||||||
|
});
|
||||||
|
const index = {
|
||||||
|
byFqdn: new Map([
|
||||||
|
["ihome.rkns.top", target],
|
||||||
|
["imsk.rkns.top", cname],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const ips = await resolveBindingIpsForSync(cname, [], index);
|
||||||
|
expect(ips).toEqual(["203.0.113.10"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves short CNAME target relative to zone", async () => {
|
||||||
|
const target = binding({
|
||||||
|
id: 1,
|
||||||
|
hostname: "ihome",
|
||||||
|
zone_name: "rkns.top",
|
||||||
|
target_ips: ["203.0.113.11"],
|
||||||
|
});
|
||||||
|
const cname = binding({
|
||||||
|
id: 2,
|
||||||
|
hostname: "imsk",
|
||||||
|
zone_name: "rkns.top",
|
||||||
|
cname_target: "ihome",
|
||||||
|
target_ips: [],
|
||||||
|
});
|
||||||
|
const index = {
|
||||||
|
byFqdn: new Map([
|
||||||
|
["ihome.rkns.top", target],
|
||||||
|
["imsk.rkns.top", cname],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const ips = await resolveBindingIpsForSync(cname, [], index);
|
||||||
|
expect(ips).toEqual(["203.0.113.11"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to service IPs when CNAME target unknown locally and DNS fails", async () => {
|
||||||
|
const cname = binding({
|
||||||
|
id: 2,
|
||||||
|
hostname: "imsk",
|
||||||
|
zone_name: "rkns.top",
|
||||||
|
cname_target: "definitely-not-resolvable-xyz.invalid",
|
||||||
|
target_ips: [],
|
||||||
|
});
|
||||||
|
const index = { byFqdn: new Map([["imsk.rkns.top", cname]]) };
|
||||||
|
const ips = await resolveBindingIpsForSync(cname, ["198.51.100.7"], index);
|
||||||
|
expect(ips).toEqual(["198.51.100.7"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat CNAME hostname in target_ips as an IP", async () => {
|
||||||
|
const target = binding({
|
||||||
|
id: 1,
|
||||||
|
hostname: "ihome",
|
||||||
|
zone_name: "rkns.top",
|
||||||
|
target_ips: ["203.0.113.10"],
|
||||||
|
});
|
||||||
|
const cname = binding({
|
||||||
|
id: 2,
|
||||||
|
hostname: "imsk",
|
||||||
|
zone_name: "rkns.top",
|
||||||
|
cname_target: "ihome.rkns.top",
|
||||||
|
// как в прод: JOIN dns_record кладёт CNAME content в target_ip → target_ips
|
||||||
|
target_ips: ["ihome.rkns.top"],
|
||||||
|
});
|
||||||
|
const index = {
|
||||||
|
byFqdn: new Map([
|
||||||
|
["ihome.rkns.top", target],
|
||||||
|
["imsk.rkns.top", cname],
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const ips = await resolveBindingIpsForSync(cname, [], index);
|
||||||
|
expect(ips).toEqual(["203.0.113.10"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers service IPs over empty CNAME resolution chain", async () => {
|
||||||
|
const cname = binding({
|
||||||
|
id: 2,
|
||||||
|
hostname: "mhome",
|
||||||
|
zone_name: "rkns.top",
|
||||||
|
cname_target: "macloud.rkns.top",
|
||||||
|
target_ips: ["macloud.rkns.top"],
|
||||||
|
});
|
||||||
|
const index = { byFqdn: new Map([["mhome.rkns.top", cname]]) };
|
||||||
|
const ips = await resolveBindingIpsForSync(cname, ["203.0.113.55"], index);
|
||||||
|
expect(ips).toEqual(["203.0.113.55"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,7 +74,6 @@ export default defineConfig([
|
|||||||
'**/services-board/*-row.tsx',
|
'**/services-board/*-row.tsx',
|
||||||
'**/groups-board/*-row.tsx',
|
'**/groups-board/*-row.tsx',
|
||||||
'**/layout/app-shell.tsx',
|
'**/layout/app-shell.tsx',
|
||||||
'**/service-binding-card.tsx',
|
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
'no-restricted-syntax': 'off',
|
'no-restricted-syntax': 'off',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsr generate && tsc -b && vite build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@tanstack/router-cli": "^1.167.31",
|
||||||
"@types/node": "^24.12.3",
|
"@types/node": "^24.12.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import type { ComponentProps } from 'react'
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarBadge,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarGroup,
|
|
||||||
AvatarGroupCount,
|
|
||||||
AvatarImage,
|
|
||||||
} from '@cfdm/ui/components/avatar'
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
|
||||||
|
|
||||||
export type AppAvatarProps = ComponentProps<typeof Avatar>
|
|
||||||
export type AppAvatarImageProps = ComponentProps<typeof AvatarImage>
|
|
||||||
export type AppAvatarFallbackProps = ComponentProps<typeof AvatarFallback>
|
|
||||||
export type AppAvatarBadgeProps = ComponentProps<typeof AvatarBadge>
|
|
||||||
export type AppAvatarGroupProps = ComponentProps<typeof AvatarGroup>
|
|
||||||
export type AppAvatarGroupCountProps = ComponentProps<typeof AvatarGroupCount>
|
|
||||||
|
|
||||||
export function AppAvatar({ className, ...props }: AppAvatarProps) {
|
|
||||||
return <Avatar className={cn(className)} {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppAvatarImage({ className, ...props }: AppAvatarImageProps) {
|
|
||||||
return <AvatarImage className={cn(className)} {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppAvatarFallback({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AppAvatarFallbackProps) {
|
|
||||||
return <AvatarFallback className={cn(className)} {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppAvatarBadge({ className, ...props }: AppAvatarBadgeProps) {
|
|
||||||
return <AvatarBadge className={cn(className)} {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppAvatarGroup({ className, ...props }: AppAvatarGroupProps) {
|
|
||||||
return <AvatarGroup className={cn(className)} {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppAvatarGroupCount({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AppAvatarGroupCountProps) {
|
|
||||||
return <AvatarGroupCount className={cn(className)} {...props} />
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import type { ComponentProps } from 'react'
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardAction,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from '@cfdm/ui/components/card'
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
|
||||||
|
|
||||||
export type AppCardProps = ComponentProps<typeof Card>
|
|
||||||
export type AppCardHeaderProps = ComponentProps<typeof CardHeader>
|
|
||||||
export type AppCardTitleProps = ComponentProps<typeof CardTitle>
|
|
||||||
export type AppCardDescriptionProps = ComponentProps<typeof CardDescription>
|
|
||||||
export type AppCardActionProps = ComponentProps<typeof CardAction>
|
|
||||||
export type AppCardContentProps = ComponentProps<typeof CardContent>
|
|
||||||
export type AppCardFooterProps = ComponentProps<typeof CardFooter>
|
|
||||||
|
|
||||||
export function AppCard({ className, ...props }: AppCardProps) {
|
|
||||||
return <Card className={cn(className)} {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppCardHeader({ className, ...props }: AppCardHeaderProps) {
|
|
||||||
return <CardHeader className={cn(className)} {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppCardTitle({ className, ...props }: AppCardTitleProps) {
|
|
||||||
return <CardTitle className={cn(className)} {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppCardDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: AppCardDescriptionProps) {
|
|
||||||
return <CardDescription className={cn(className)} {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppCardAction({ className, ...props }: AppCardActionProps) {
|
|
||||||
return <CardAction className={cn(className)} {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppCardContent({ className, ...props }: AppCardContentProps) {
|
|
||||||
return <CardContent className={cn(className)} {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppCardFooter({ className, ...props }: AppCardFooterProps) {
|
|
||||||
return <CardFooter className={cn(className)} {...props} />
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { ComponentProps } from 'react'
|
|
||||||
import { Separator } from '@cfdm/ui/components/separator'
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
|
||||||
|
|
||||||
export type AppSeparatorProps = ComponentProps<typeof Separator>
|
|
||||||
|
|
||||||
export function AppSeparator({ className, ...props }: AppSeparatorProps) {
|
|
||||||
return <Separator className={cn(className)} {...props} />
|
|
||||||
}
|
|
||||||
@@ -14,12 +14,12 @@ import {
|
|||||||
SidebarContent,
|
SidebarContent,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarRail,
|
|
||||||
} from '@cfdm/ui/components/sidebar'
|
} from '@cfdm/ui/components/sidebar'
|
||||||
|
|
||||||
const infrastructureNav = [
|
const infrastructureNav = [
|
||||||
@@ -31,7 +31,7 @@ const infrastructureNav = [
|
|||||||
const operationsNav = [
|
const operationsNav = [
|
||||||
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
|
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
|
||||||
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
||||||
{ to: '/settings/integrations', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
|
{ to: '/settings/appearance', label: 'Настройки', icon: SettingsIcon, exact: false, matchPrefix: '/settings' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function isNavActive(
|
function isNavActive(
|
||||||
@@ -65,30 +65,32 @@ function NavSection({
|
|||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarGroupContent>
|
||||||
{items.map((item) => (
|
<SidebarMenu>
|
||||||
<SidebarMenuItem key={item.to}>
|
{items.map((item) => (
|
||||||
<SidebarMenuButton
|
<SidebarMenuItem key={item.to}>
|
||||||
tooltip={item.label}
|
<SidebarMenuButton
|
||||||
isActive={isNavActive(
|
tooltip={item.label}
|
||||||
pathname,
|
isActive={isNavActive(
|
||||||
item.to,
|
pathname,
|
||||||
item.exact,
|
item.to,
|
||||||
item.matchPrefix,
|
item.exact,
|
||||||
)}
|
item.matchPrefix,
|
||||||
render={
|
)}
|
||||||
<Link
|
render={
|
||||||
to={item.to}
|
<Link
|
||||||
activeOptions={{ exact: item.exact }}
|
to={item.to}
|
||||||
/>
|
activeOptions={{ exact: item.exact }}
|
||||||
}
|
/>
|
||||||
>
|
}
|
||||||
<item.icon />
|
>
|
||||||
<span>{item.label}</span>
|
<item.icon className="size-4" />
|
||||||
</SidebarMenuButton>
|
<span>{item.label}</span>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuButton>
|
||||||
))}
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -108,7 +110,6 @@ export function AppSidebar() {
|
|||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser />
|
<NavUser />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
<SidebarRail />
|
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ export function AppSwitcher() {
|
|||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
const { config, isLoading } = useAppSwitcherConfig()
|
const { config, isLoading } = useAppSwitcherConfig()
|
||||||
const current = getCurrentApp(config)
|
const current = getCurrentApp(config)
|
||||||
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
|
const CurrentIcon = APP_SWITCHER_ICONS[current.icon] ?? APP_SWITCHER_ICONS.server
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
@@ -61,7 +61,7 @@ export function AppSwitcher() {
|
|||||||
{isLoading ? 'Загрузка…' : config.menuLabel}
|
{isLoading ? 'Загрузка…' : config.menuLabel}
|
||||||
</div>
|
</div>
|
||||||
{config.apps.map((app) => {
|
{config.apps.map((app) => {
|
||||||
const Icon = APP_SWITCHER_ICONS[app.icon]
|
const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
|
||||||
const isCurrent = app.id === CURRENT_APP_ID
|
const isCurrent = app.id === CURRENT_APP_ID
|
||||||
|
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { type CSSProperties } from "react"
|
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
|
||||||
import { SidebarInset, SidebarProvider } from "@cfdm/ui/components/sidebar"
|
|
||||||
|
|
||||||
import { AppSidebar } from "./app-sidebar"
|
|
||||||
import { PageToolbar } from "./page-toolbar"
|
|
||||||
import { SiteFooter } from "./site-footer"
|
|
||||||
import { SiteHeader } from "./site-header"
|
|
||||||
|
|
||||||
export function AppShell() {
|
|
||||||
return (
|
|
||||||
<SidebarProvider
|
|
||||||
className={cn(
|
|
||||||
"[--sidebar:color-mix(in_oklab,var(--color-sidebar)_60%,transparent)]",
|
|
||||||
"[--sidebar-border:transparent]",
|
|
||||||
"[--sidebar-accent:color-mix(in_oklab,var(--color-primary)_5%,transparent)]",
|
|
||||||
"[--sidebar-accent-foreground:var(--color-primary)]"
|
|
||||||
)}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--sidebar-width": "240px",
|
|
||||||
} as CSSProperties
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Sidebar */}
|
|
||||||
<AppSidebar />
|
|
||||||
<SidebarInset>
|
|
||||||
{/* Header */}
|
|
||||||
<SiteHeader />
|
|
||||||
{/* Content */}
|
|
||||||
<main className="flex flex-1 flex-col gap-5 px-4 py-4 md:px-6 md:py-5">
|
|
||||||
<PageToolbar />
|
|
||||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
|
||||||
<div className="border-border/70 bg-muted/20 aspect-video rounded-lg border border-dashed" />
|
|
||||||
<div className="border-border/70 bg-muted/20 aspect-video rounded-lg border border-dashed" />
|
|
||||||
<div className="border-border/70 bg-muted/20 aspect-video rounded-lg border border-dashed" />
|
|
||||||
</div>
|
|
||||||
<div className="border-border/70 bg-muted/20 min-h-80 flex-1 rounded-lg border border-dashed" />
|
|
||||||
</main>
|
|
||||||
{/* Footer */}
|
|
||||||
<SiteFooter />
|
|
||||||
</SidebarInset>
|
|
||||||
</SidebarProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import { cn } from "@cfdm/ui/lib/utils"
|
|
||||||
import { Separator } from "@cfdm/ui/components/separator"
|
|
||||||
import {
|
|
||||||
SidebarContent,
|
|
||||||
SidebarFooter,
|
|
||||||
SidebarHeader,
|
|
||||||
Sidebar as SidebarRoot,
|
|
||||||
useSidebar,
|
|
||||||
} from "@cfdm/ui/components/sidebar"
|
|
||||||
|
|
||||||
import { Brand } from "./brand"
|
|
||||||
import { NavMain } from "./nav-main"
|
|
||||||
import { NavProjects } from "./nav-projects"
|
|
||||||
import { NavSecondary } from "./nav-secondary"
|
|
||||||
|
|
||||||
function SidebarRailToggle() {
|
|
||||||
const { state, toggleSidebar } = useSidebar()
|
|
||||||
const isExpanded = state === "expanded"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={isExpanded ? "Collapse sidebar" : "Expand sidebar"}
|
|
||||||
onClick={toggleSidebar}
|
|
||||||
style={{
|
|
||||||
left: isExpanded ? "var(--sidebar-width)" : "var(--sidebar-width-icon)",
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"group/rail fixed top-1/2 z-30 flex h-12 w-7 -translate-y-1/2 cursor-pointer items-center pl-2 outline-none",
|
|
||||||
"transition-[left] duration-200 ease-linear"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="flex flex-col items-center">
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn(
|
|
||||||
"bg-foreground/40 block h-2 w-0.5 rounded-t-full",
|
|
||||||
"origin-bottom transition-all duration-100 ease-linear",
|
|
||||||
isExpanded
|
|
||||||
? "group-hover/rail:bg-foreground/60 group-hover/rail:rotate-40"
|
|
||||||
: "group-hover/rail:bg-foreground/60 group-hover/rail:-rotate-40"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn(
|
|
||||||
"bg-foreground/40 block h-2 w-0.5 rounded-b-full",
|
|
||||||
"origin-top transition-all duration-100 ease-linear",
|
|
||||||
isExpanded
|
|
||||||
? "group-hover/rail:bg-foreground/60 group-hover/rail:-rotate-40"
|
|
||||||
: "group-hover/rail:bg-foreground/60 group-hover/rail:rotate-40"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"border-border bg-foreground text-background absolute left-full -ml-2 rounded-md border px-2 py-0.5 text-[11px] font-medium whitespace-nowrap shadow-xs shadow-black/5",
|
|
||||||
"pointer-events-none transition-all duration-200 ease-out",
|
|
||||||
"-translate-x-0.5 opacity-0",
|
|
||||||
"group-hover/rail:translate-x-0 group-hover/rail:opacity-100"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isExpanded ? "Collapse" : "Expand"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppSidebar() {
|
|
||||||
return (
|
|
||||||
<SidebarRoot collapsible="icon">
|
|
||||||
<SidebarHeader className="pb-0">
|
|
||||||
<Brand />
|
|
||||||
</SidebarHeader>
|
|
||||||
|
|
||||||
<SidebarContent>
|
|
||||||
<NavMain />
|
|
||||||
<NavProjects />
|
|
||||||
</SidebarContent>
|
|
||||||
|
|
||||||
<SidebarFooter className="px-1! in-data-[state=collapsed]:px-1!">
|
|
||||||
<div className="px-2">
|
|
||||||
<Separator />
|
|
||||||
</div>
|
|
||||||
<NavSecondary />
|
|
||||||
</SidebarFooter>
|
|
||||||
|
|
||||||
<SidebarRailToggle />
|
|
||||||
</SidebarRoot>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { Button } from "@cfdm/ui/components/button"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@cfdm/ui/components/dropdown-menu"
|
|
||||||
import { APPS } from "./data"
|
|
||||||
import { LayoutGridIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export function AppsMenu() {
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={<Button variant="ghost" size="icon" aria-label="Apps" />}
|
|
||||||
>
|
|
||||||
<LayoutGridIcon className="size-4.5 transition-colors" aria-hidden="true" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent
|
|
||||||
side="bottom"
|
|
||||||
align="end"
|
|
||||||
sideOffset={8}
|
|
||||||
className="w-72"
|
|
||||||
>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel>Apps</DropdownMenuLabel>
|
|
||||||
<div className="grid grid-cols-3 gap-1 p-1">
|
|
||||||
{APPS.map((app) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={app.id}
|
|
||||||
render={<a href="#" />}
|
|
||||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
|
||||||
>
|
|
||||||
<span className="text-muted-foreground">{app.icon}</span>
|
|
||||||
<span className="text-xs font-medium">{app.label}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
render={<a href="#" />}
|
|
||||||
className="justify-center text-sm font-medium"
|
|
||||||
>
|
|
||||||
Browse All Apps
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { cn } from "@cfdm/ui/lib/utils"
|
|
||||||
import { Item, ItemMedia } from "@cfdm/ui/components/item"
|
|
||||||
import {
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
} from "@cfdm/ui/components/sidebar"
|
|
||||||
|
|
||||||
import { BRAND } from "./data"
|
|
||||||
|
|
||||||
function AuthLogo({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<Item
|
|
||||||
className={cn(
|
|
||||||
"p-0",
|
|
||||||
"bg-primary text-primary-foreground flex size-8 shrink-0 items-center justify-center",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
|
||||||
<svg
|
|
||||||
width="50"
|
|
||||||
height="50"
|
|
||||||
viewBox="25.668 25.1352 49.6644 50"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="size-4"
|
|
||||||
>
|
|
||||||
<circle cx="70.634" cy="29.8334" r="4.69799" fill="currentColor" />
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
clipRule="evenodd"
|
|
||||||
d="M25.668 57.0144V29.8332C25.668 27.2386 27.7713 25.1352 30.366 25.1352C32.9606 25.1352 35.0639 27.2386 35.0639 29.8332V57.0144C35.0639 61.833 38.9702 65.7392 43.7888 65.7392H57.2116C62.0302 65.7392 65.9364 61.833 65.9364 57.0144V43.7258C65.9364 41.1312 68.0398 39.0278 70.6344 39.0278C73.229 39.0278 75.3324 41.1312 75.3324 43.7258V57.0144C75.3324 67.0222 67.2194 75.1352 57.2116 75.1352H43.7888C33.7809 75.1352 25.668 67.0222 25.668 57.0144Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BrandLogo() {
|
|
||||||
return (
|
|
||||||
<span className="relative flex size-7 shrink-0 items-center justify-center">
|
|
||||||
<AuthLogo className="absolute top-1/2 left-1/2 origin-center -translate-x-1/2 -translate-y-1/2 scale-[0.875]" />
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Brand() {
|
|
||||||
return (
|
|
||||||
<SidebarMenu>
|
|
||||||
{/* Sidebar */}
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton
|
|
||||||
size="lg"
|
|
||||||
render={<a href="#" aria-label={`${BRAND.name} home`} />}
|
|
||||||
className="gap-2.5"
|
|
||||||
>
|
|
||||||
<BrandLogo />
|
|
||||||
<div className="grid min-w-0 flex-1 text-left leading-tight">
|
|
||||||
<span className="truncate text-sm font-semibold">{BRAND.name}</span>
|
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
|
||||||
{BRAND.tier} plan
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,604 +0,0 @@
|
|||||||
import { type ReactNode } from "react"
|
|
||||||
import { SettingsIcon, UsersIcon, BookOpenIcon, HouseIcon, BarChart3Icon, GlobeIcon, ExternalLinkIcon, UserPlusIcon, FlagIcon, CopyIcon, ArchiveIcon, InboxIcon, CalendarIcon, FileTextIcon, ListChecksIcon } from "lucide-react"
|
|
||||||
|
|
||||||
// ── Types ──
|
|
||||||
|
|
||||||
export type NavChild = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
isActive?: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NavItem = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
icon: ReactNode
|
|
||||||
badge?: string | number
|
|
||||||
isActive?: boolean
|
|
||||||
disabled?: boolean
|
|
||||||
children?: NavChild[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NavGroup = {
|
|
||||||
id: string
|
|
||||||
label?: string
|
|
||||||
items: NavItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SecondaryItem = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
icon: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Workspace = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
tier: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Project = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
progress: number
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ItemAction = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
icon: ReactNode
|
|
||||||
destructive: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AppShortcut = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
icon: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NotificationType =
|
|
||||||
| "mention"
|
|
||||||
| "comment"
|
|
||||||
| "share"
|
|
||||||
| "invite"
|
|
||||||
| "billing"
|
|
||||||
| "security"
|
|
||||||
| "feature"
|
|
||||||
| "deployment"
|
|
||||||
| "usage"
|
|
||||||
| "system"
|
|
||||||
| "task"
|
|
||||||
| "approval"
|
|
||||||
| "integration"
|
|
||||||
| "achievement"
|
|
||||||
| "feedback"
|
|
||||||
| "team_join"
|
|
||||||
| "reaction"
|
|
||||||
| "review"
|
|
||||||
| "event"
|
|
||||||
|
|
||||||
export type NotificationVariant = "info" | "success" | "warning" | "destructive"
|
|
||||||
|
|
||||||
export type NotificationAction = {
|
|
||||||
label: string
|
|
||||||
variant?: "default" | "outline" | "destructive"
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NotificationAttachment = {
|
|
||||||
name: string
|
|
||||||
size: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NotificationAvatar = {
|
|
||||||
src: string
|
|
||||||
fallback: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NotificationGroupMember = {
|
|
||||||
src: string
|
|
||||||
fallback: string
|
|
||||||
online?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NotificationMeta = {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
color?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Notification = {
|
|
||||||
id: string
|
|
||||||
type: NotificationType
|
|
||||||
variant?: NotificationVariant
|
|
||||||
title: string
|
|
||||||
body?: string
|
|
||||||
time: string
|
|
||||||
unread?: boolean
|
|
||||||
avatar?: NotificationAvatar
|
|
||||||
username?: string
|
|
||||||
link?: string
|
|
||||||
badge?: string
|
|
||||||
actions?: NotificationAction[]
|
|
||||||
attachment?: NotificationAttachment
|
|
||||||
meta?: NotificationMeta
|
|
||||||
progress?: number
|
|
||||||
progressVariant?: "default" | "success"
|
|
||||||
avatarGroup?: NotificationGroupMember[]
|
|
||||||
avatarGroupCount?: number
|
|
||||||
rating?: number
|
|
||||||
eventDate?: string
|
|
||||||
eventTime?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FooterLink = { id: string; label: string }
|
|
||||||
|
|
||||||
// ── Brand + account ──
|
|
||||||
|
|
||||||
export const BRAND = {
|
|
||||||
name: "ReUI",
|
|
||||||
tier: "Pro",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const USER = {
|
|
||||||
name: "Theo Park",
|
|
||||||
email: "theo@reui.io",
|
|
||||||
image:
|
|
||||||
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=96&h=96&dpr=2&q=80",
|
|
||||||
initials: "TP",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const WORKSPACES: Workspace[] = [
|
|
||||||
{
|
|
||||||
id: "reui",
|
|
||||||
name: "ReUI Labs",
|
|
||||||
tier: "Enterprise",
|
|
||||||
description: "Design systems and block releases",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "studio",
|
|
||||||
name: "ReUI Studio",
|
|
||||||
tier: "Pro",
|
|
||||||
description: "Customer workspace builds",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ops",
|
|
||||||
name: "ReUI Ops",
|
|
||||||
tier: "Team",
|
|
||||||
description: "Billing, support, and reliability",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── Nav secondary ──
|
|
||||||
|
|
||||||
export const NAV_SECONDARY: SecondaryItem[] = [
|
|
||||||
{
|
|
||||||
id: "settings",
|
|
||||||
label: "Settings",
|
|
||||||
icon: (
|
|
||||||
<SettingsIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "invite",
|
|
||||||
label: "Invite Team",
|
|
||||||
icon: (
|
|
||||||
<UsersIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "docs",
|
|
||||||
label: "Documentation",
|
|
||||||
icon: (
|
|
||||||
<BookOpenIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── Sidebar navigation ──
|
|
||||||
|
|
||||||
export const NAV_GROUPS: NavGroup[] = [
|
|
||||||
{
|
|
||||||
id: "dashboards",
|
|
||||||
label: "Dashboards",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: "overview",
|
|
||||||
label: "Overview",
|
|
||||||
icon: (
|
|
||||||
<HouseIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "analytics",
|
|
||||||
label: "Analytics",
|
|
||||||
icon: (
|
|
||||||
<BarChart3Icon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
badge: "Soon",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "workspace",
|
|
||||||
label: "Workspace",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: "audience",
|
|
||||||
label: "Audience",
|
|
||||||
icon: (
|
|
||||||
<UsersIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
{ id: "people", label: "People" },
|
|
||||||
{ id: "segments", label: "Segments" },
|
|
||||||
{ id: "companies", label: "Companies" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "account",
|
|
||||||
label: "Account",
|
|
||||||
icon: (
|
|
||||||
<SettingsIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
{ id: "profile", label: "Profile" },
|
|
||||||
{ id: "billing", label: "Billing", isActive: true },
|
|
||||||
{ id: "security", label: "Security" },
|
|
||||||
{ id: "members", label: "Members" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "network",
|
|
||||||
label: "Network",
|
|
||||||
icon: (
|
|
||||||
<GlobeIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
{ id: "directory", label: "Directory" },
|
|
||||||
{ id: "activity", label: "Activity" },
|
|
||||||
{ id: "leads", label: "Leads", disabled: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── Active projects ──
|
|
||||||
|
|
||||||
export const ACTIVE_PROJECTS: Project[] = [
|
|
||||||
{
|
|
||||||
id: "billing-portal",
|
|
||||||
name: "Billing Portal",
|
|
||||||
progress: 78,
|
|
||||||
color: "stroke-blue-500",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "audience-sync",
|
|
||||||
name: "Audience Sync",
|
|
||||||
progress: 64,
|
|
||||||
color: "stroke-emerald-500",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "report-builder",
|
|
||||||
name: "Report Builder",
|
|
||||||
progress: 42,
|
|
||||||
color: "stroke-violet-500",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "network-map",
|
|
||||||
name: "Network Map",
|
|
||||||
progress: 56,
|
|
||||||
color: "stroke-orange-500",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "access-review",
|
|
||||||
name: "Access Review",
|
|
||||||
progress: 88,
|
|
||||||
color: "stroke-rose-500",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const ITEM_ACTIONS: ItemAction[] = [
|
|
||||||
{
|
|
||||||
id: "open",
|
|
||||||
label: "Open Project",
|
|
||||||
icon: (
|
|
||||||
<ExternalLinkIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
destructive: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "owner",
|
|
||||||
label: "Assign Owner",
|
|
||||||
icon: (
|
|
||||||
<UserPlusIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
destructive: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "milestone",
|
|
||||||
label: "Set Milestone",
|
|
||||||
icon: (
|
|
||||||
<FlagIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
destructive: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "duplicate",
|
|
||||||
label: "Duplicate",
|
|
||||||
icon: (
|
|
||||||
<CopyIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
destructive: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "archive",
|
|
||||||
label: "Archive",
|
|
||||||
icon: (
|
|
||||||
<ArchiveIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
destructive: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── Header apps ──
|
|
||||||
|
|
||||||
export const APPS: AppShortcut[] = [
|
|
||||||
{
|
|
||||||
id: "inbox",
|
|
||||||
label: "Inbox",
|
|
||||||
icon: (
|
|
||||||
<InboxIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "calendar",
|
|
||||||
label: "Calendar",
|
|
||||||
icon: (
|
|
||||||
<CalendarIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "docs",
|
|
||||||
label: "Docs",
|
|
||||||
icon: (
|
|
||||||
<FileTextIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tasks",
|
|
||||||
label: "Tasks",
|
|
||||||
icon: (
|
|
||||||
<ListChecksIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "reports",
|
|
||||||
label: "Reports",
|
|
||||||
icon: (
|
|
||||||
<BarChart3Icon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "settings",
|
|
||||||
label: "Settings",
|
|
||||||
icon: (
|
|
||||||
<SettingsIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── Header notifications ──
|
|
||||||
|
|
||||||
export const NOTIFICATIONS: Notification[] = [
|
|
||||||
{
|
|
||||||
id: "mention",
|
|
||||||
type: "mention",
|
|
||||||
title: "mentioned you in",
|
|
||||||
body: '"Can you review the token cleanup?"',
|
|
||||||
time: "2m ago",
|
|
||||||
unread: true,
|
|
||||||
avatar: {
|
|
||||||
src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
|
||||||
fallback: "MS",
|
|
||||||
},
|
|
||||||
username: "@mira",
|
|
||||||
link: "Block QA",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "approval",
|
|
||||||
type: "approval",
|
|
||||||
variant: "warning",
|
|
||||||
title: "Release Approval",
|
|
||||||
body: "App shell updates need signoff before registry publish.",
|
|
||||||
time: "8m ago",
|
|
||||||
unread: true,
|
|
||||||
actions: [
|
|
||||||
{ label: "Approve", variant: "default" },
|
|
||||||
{ label: "Review", variant: "outline" },
|
|
||||||
],
|
|
||||||
meta: { label: "Priority", value: "High", color: "text-warning" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "share",
|
|
||||||
type: "share",
|
|
||||||
title: "shared",
|
|
||||||
body: "Dashboard polish brief",
|
|
||||||
time: "16m ago",
|
|
||||||
unread: true,
|
|
||||||
avatar: {
|
|
||||||
src: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
|
||||||
fallback: "LG",
|
|
||||||
},
|
|
||||||
username: "@leo",
|
|
||||||
attachment: {
|
|
||||||
name: "release-notes.pdf",
|
|
||||||
size: "2 MB",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "task",
|
|
||||||
type: "task",
|
|
||||||
variant: "info",
|
|
||||||
title: "Task Assigned",
|
|
||||||
body: "Audit dark sidebar contrast in app-shell-18.",
|
|
||||||
time: "28m ago",
|
|
||||||
unread: true,
|
|
||||||
meta: { label: "Due", value: "Today", color: "text-destructive" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "team",
|
|
||||||
type: "team_join",
|
|
||||||
variant: "success",
|
|
||||||
title: "4 people joined ReUI Labs",
|
|
||||||
body: "Mira, Leo, Anika and James joined the Pro workspace.",
|
|
||||||
time: "42m ago",
|
|
||||||
unread: true,
|
|
||||||
avatarGroup: [
|
|
||||||
{
|
|
||||||
src: "https://images.unsplash.com/photo-1519699047748-de8e457a634e?w=96&h=96&dpr=2&q=80",
|
|
||||||
fallback: "MR",
|
|
||||||
online: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=96&h=96&dpr=2&q=80",
|
|
||||||
fallback: "AR",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
|
|
||||||
fallback: "JW",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
avatarGroupCount: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "reaction",
|
|
||||||
type: "reaction",
|
|
||||||
variant: "info",
|
|
||||||
title: "Reactions On Your Comment",
|
|
||||||
body: "Mira and 2 others reacted in #blocks-review.",
|
|
||||||
time: "1h ago",
|
|
||||||
avatarGroup: [
|
|
||||||
{
|
|
||||||
src: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=96&h=96&dpr=2&q=80",
|
|
||||||
fallback: "MS",
|
|
||||||
online: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=96&h=96&dpr=2&q=80",
|
|
||||||
fallback: "LG",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
avatarGroupCount: 1,
|
|
||||||
badge: "5 reacts",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "review-score",
|
|
||||||
type: "review",
|
|
||||||
variant: "success",
|
|
||||||
title: "New Review Received",
|
|
||||||
body: "Anika rated the app shell updates.",
|
|
||||||
time: "2h ago",
|
|
||||||
avatar: {
|
|
||||||
src: "https://images.unsplash.com/photo-1485893086445-ed75865251e0?w=96&h=96&dpr=2&q=80",
|
|
||||||
fallback: "AR",
|
|
||||||
},
|
|
||||||
username: "@anika",
|
|
||||||
rating: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "event",
|
|
||||||
type: "event",
|
|
||||||
variant: "info",
|
|
||||||
title: "Release Review",
|
|
||||||
body: "Production readiness with the design systems team.",
|
|
||||||
time: "3h ago",
|
|
||||||
unread: true,
|
|
||||||
eventDate: "Mar 3, 2026",
|
|
||||||
eventTime: "10:00 to 11:00 AM",
|
|
||||||
meta: { label: "Where", value: "Meet", color: "text-info" },
|
|
||||||
actions: [
|
|
||||||
{ label: "Join", variant: "default" },
|
|
||||||
{ label: "Decline", variant: "outline" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "invite",
|
|
||||||
type: "invite",
|
|
||||||
variant: "info",
|
|
||||||
title: "Workspace Invitation",
|
|
||||||
body: "Priya invited you to ReUI Studio.",
|
|
||||||
time: "1h ago",
|
|
||||||
actions: [
|
|
||||||
{ label: "Accept", variant: "default" },
|
|
||||||
{ label: "Decline", variant: "outline" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "integration",
|
|
||||||
type: "integration",
|
|
||||||
variant: "success",
|
|
||||||
title: "Linear Connected",
|
|
||||||
body: "Issue activity now syncs with ReUI notifications.",
|
|
||||||
time: "2h ago",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "billing",
|
|
||||||
type: "billing",
|
|
||||||
variant: "info",
|
|
||||||
title: "Payment Processed",
|
|
||||||
body: "Your ReUI Pro subscription was renewed.",
|
|
||||||
time: "3h ago",
|
|
||||||
badge: "$49.00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "achievement",
|
|
||||||
type: "achievement",
|
|
||||||
variant: "success",
|
|
||||||
title: "Monthly Goal Reached",
|
|
||||||
body: "100 of 100 review tasks completed this month.",
|
|
||||||
time: "4h ago",
|
|
||||||
progress: 100,
|
|
||||||
progressVariant: "success",
|
|
||||||
meta: { label: "Goal", value: "100 tasks", color: "text-success" },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "security",
|
|
||||||
type: "security",
|
|
||||||
variant: "destructive",
|
|
||||||
title: "New Sign-In Detected",
|
|
||||||
body: "New login from Mac OS, Chrome.",
|
|
||||||
time: "Yesterday",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "usage",
|
|
||||||
type: "usage",
|
|
||||||
variant: "warning",
|
|
||||||
title: "API Usage At 80%",
|
|
||||||
body: "Your workspace used 80% of the monthly API quota.",
|
|
||||||
time: "5 days ago",
|
|
||||||
progress: 80,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "system",
|
|
||||||
type: "system",
|
|
||||||
variant: "info",
|
|
||||||
title: "Scheduled Maintenance",
|
|
||||||
body: "Registry maintenance is planned for Feb 28, 2026 at 2:00 AM UTC.",
|
|
||||||
time: "1 week ago",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ── Footer ──
|
|
||||||
|
|
||||||
export const FOOTER_LINKS: FooterLink[] = [
|
|
||||||
{ id: "docs", label: "Docs" },
|
|
||||||
{ id: "changelog", label: "Changelog" },
|
|
||||||
{ id: "support", label: "Support" },
|
|
||||||
{ id: "privacy", label: "Privacy" },
|
|
||||||
{ id: "status", label: "Status" },
|
|
||||||
]
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Fragment } from "react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@cfdm/ui/components/dropdown-menu"
|
|
||||||
import { SidebarMenuAction } from "@cfdm/ui/components/sidebar"
|
|
||||||
import { ITEM_ACTIONS } from "./data"
|
|
||||||
import { MoreHorizontalIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export function ItemActionMenu({ label }: { label: string }) {
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<SidebarMenuAction showOnHover aria-label={`Actions for ${label}`} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon aria-hidden="true" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
{/* Content */}
|
|
||||||
<DropdownMenuContent
|
|
||||||
side="right"
|
|
||||||
align="start"
|
|
||||||
sideOffset={4}
|
|
||||||
className="w-44"
|
|
||||||
>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
||||||
{ITEM_ACTIONS.map((action) => (
|
|
||||||
<Fragment key={action.id}>
|
|
||||||
{action.destructive && <DropdownMenuSeparator />}
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant={action.destructive ? "destructive" : "default"}
|
|
||||||
className="[&_svg]:size-3.5 [&_svg]:opacity-60"
|
|
||||||
>
|
|
||||||
{action.icon}
|
|
||||||
{action.label}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from "react"
|
|
||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuBadge,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarMenuSub,
|
|
||||||
SidebarMenuSubButton,
|
|
||||||
SidebarMenuSubItem,
|
|
||||||
} from "@cfdm/ui/components/sidebar"
|
|
||||||
import { NAV_GROUPS, type NavChild, type NavItem } from "./data"
|
|
||||||
import { ChevronRightIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function NavSubItem({ child }: { child: NavChild }) {
|
|
||||||
if (child.disabled) {
|
|
||||||
return (
|
|
||||||
<SidebarMenuSubItem>
|
|
||||||
{/* Row */}
|
|
||||||
<SidebarMenuSubButton
|
|
||||||
aria-disabled="true"
|
|
||||||
className="pointer-events-none opacity-60"
|
|
||||||
>
|
|
||||||
<span>{child.label}</span>
|
|
||||||
<Badge className="ml-auto h-5 rounded-full px-2 text-[10px] font-medium">
|
|
||||||
Soon
|
|
||||||
</Badge>
|
|
||||||
</SidebarMenuSubButton>
|
|
||||||
</SidebarMenuSubItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarMenuSubItem>
|
|
||||||
{/* Row */}
|
|
||||||
<SidebarMenuSubButton render={<a href="#" />} isActive={child.isActive}>
|
|
||||||
{child.label}
|
|
||||||
</SidebarMenuSubButton>
|
|
||||||
</SidebarMenuSubItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CollapsibleNavItem: isolated so only this item re-renders on open/close.
|
|
||||||
function CollapsibleNavItem({
|
|
||||||
item,
|
|
||||||
}: {
|
|
||||||
item: NavItem & { children: NavChild[] }
|
|
||||||
}) {
|
|
||||||
const [open, setOpen] = useState(() => item.children.some((c) => c.isActive))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem>
|
|
||||||
{/* Sidebar */}
|
|
||||||
<SidebarMenuButton
|
|
||||||
tooltip={item.label}
|
|
||||||
isActive={item.isActive}
|
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls={`subnav-${item.id}`}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
<span>{item.label}</span>
|
|
||||||
<ChevronRightIcon className={cn(
|
|
||||||
"ml-auto size-4 shrink-0 opacity-60 transition-transform duration-200 group-data-[collapsible=icon]:hidden",
|
|
||||||
open && "rotate-90"
|
|
||||||
)} aria-hidden="true" />
|
|
||||||
</SidebarMenuButton>
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<SidebarMenuSub id={`subnav-${item.id}`}>
|
|
||||||
{item.children.map((child) => (
|
|
||||||
<NavSubItem key={child.id} child={child} />
|
|
||||||
))}
|
|
||||||
</SidebarMenuSub>
|
|
||||||
)}
|
|
||||||
</SidebarMenuItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LeafNavItem({ item }: { item: NavItem }) {
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem>
|
|
||||||
{/* Sidebar */}
|
|
||||||
<SidebarMenuButton
|
|
||||||
tooltip={item.label}
|
|
||||||
isActive={item.isActive}
|
|
||||||
render={<a href="#" />}
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
<span>{item.label}</span>
|
|
||||||
{item.badge !== undefined && (
|
|
||||||
<SidebarMenuBadge className="group-data-[collapsible=icon]:hidden">
|
|
||||||
<Badge
|
|
||||||
className={cn(
|
|
||||||
"h-5 min-w-5 rounded-full px-2 text-[10px] font-medium",
|
|
||||||
typeof item.badge === "number" && "tabular-nums"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.badge}
|
|
||||||
</Badge>
|
|
||||||
</SidebarMenuBadge>
|
|
||||||
)}
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NavMain() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{NAV_GROUPS.map((group) => (
|
|
||||||
<SidebarGroup key={group.id}>
|
|
||||||
{/* Sidebar */}
|
|
||||||
{group.label && <SidebarGroupLabel>{group.label}</SidebarGroupLabel>}
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu className="gap-0.25">
|
|
||||||
{group.items.map((item) =>
|
|
||||||
item.children ? (
|
|
||||||
<CollapsibleNavItem
|
|
||||||
key={item.id}
|
|
||||||
item={item as NavItem & { children: NavChild[] }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<LeafNavItem key={item.id} item={item} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { useState } from "react"
|
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
} from "@cfdm/ui/components/sidebar"
|
|
||||||
import { ACTIVE_PROJECTS, type Project } from "./data"
|
|
||||||
import { ItemActionMenu } from "./item-action-menu"
|
|
||||||
import { ChevronDownIcon } from "lucide-react"
|
|
||||||
|
|
||||||
const RADIUS = 6
|
|
||||||
const CX = 8
|
|
||||||
const CY = 8
|
|
||||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS
|
|
||||||
|
|
||||||
function PieProgress({ progress, color }: { progress: number; color: string }) {
|
|
||||||
const offset =
|
|
||||||
CIRCUMFERENCE * (1 - Math.min(100, Math.max(0, progress)) / 100)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
className="shrink-0 -rotate-90 opacity-100!"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
cx={CX}
|
|
||||||
cy={CY}
|
|
||||||
r={RADIUS}
|
|
||||||
fill="none"
|
|
||||||
className="stroke-muted-foreground/20"
|
|
||||||
strokeWidth="2.5"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx={CX}
|
|
||||||
cy={CY}
|
|
||||||
r={RADIUS}
|
|
||||||
fill="none"
|
|
||||||
className={color}
|
|
||||||
strokeWidth="2.5"
|
|
||||||
strokeDasharray={CIRCUMFERENCE}
|
|
||||||
strokeDashoffset={offset}
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProjectItem({ project }: { project: Project }) {
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem>
|
|
||||||
{/* Sidebar */}
|
|
||||||
<SidebarMenuButton
|
|
||||||
tooltip={`${project.name} · ${project.progress}% complete`}
|
|
||||||
render={<a href="#" />}
|
|
||||||
>
|
|
||||||
<PieProgress progress={project.progress} color={project.color} />
|
|
||||||
<span className="min-w-0 truncate">{project.name}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
{/* Row */}
|
|
||||||
<ItemActionMenu label={project.name} />
|
|
||||||
</SidebarMenuItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProjectList() {
|
|
||||||
return (
|
|
||||||
<SidebarGroupContent id="active-projects-list">
|
|
||||||
{/* Sidebar */}
|
|
||||||
<SidebarMenu className="gap-0.25">
|
|
||||||
{ACTIVE_PROJECTS.map((project) => (
|
|
||||||
<ProjectItem key={project.id} project={project} />
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NavProjects() {
|
|
||||||
const [open, setOpen] = useState(true)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
|
||||||
{/* Sidebar */}
|
|
||||||
<SidebarGroupLabel
|
|
||||||
render={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls="active-projects-list"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
className="focus-visible:ring-sidebar-ring w-full cursor-pointer focus-visible:ring-2 focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
Active Projects
|
|
||||||
<ChevronDownIcon className={cn(
|
|
||||||
"ml-auto size-4 shrink-0 opacity-60 transition-transform duration-200",
|
|
||||||
!open && "-rotate-90"
|
|
||||||
)} aria-hidden="true" />
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
|
|
||||||
{open && <ProjectList />}
|
|
||||||
</SidebarGroup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
} from "@cfdm/ui/components/sidebar"
|
|
||||||
|
|
||||||
import { NAV_SECONDARY, type SecondaryItem } from "./data"
|
|
||||||
|
|
||||||
function SecondaryNavItem({ item }: { item: SecondaryItem }) {
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton
|
|
||||||
size="sm"
|
|
||||||
tooltip={item.label}
|
|
||||||
render={<a href="#" />}
|
|
||||||
className="h-8! in-data-[state=collapsed]:h-8! [&_svg]:size-3.5"
|
|
||||||
>
|
|
||||||
{item.icon}
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NavSecondary() {
|
|
||||||
return (
|
|
||||||
<SidebarGroup className="py-1">
|
|
||||||
<SidebarMenu>
|
|
||||||
{NAV_SECONDARY.map((item) => (
|
|
||||||
<SecondaryNavItem key={item.id} item={item} />
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,460 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type { ReactNode } from "react"
|
|
||||||
import { Badge } from "@/components/reui/badge"
|
|
||||||
import { Rating } from "@/components/reui/rating"
|
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarGroup,
|
|
||||||
AvatarGroupCount,
|
|
||||||
AvatarImage,
|
|
||||||
} from "@cfdm/ui/components/avatar"
|
|
||||||
import { Button } from "@cfdm/ui/components/button"
|
|
||||||
import { ButtonGroup } from "@cfdm/ui/components/button-group"
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@cfdm/ui/components/popover"
|
|
||||||
import { Progress } from "@cfdm/ui/components/progress"
|
|
||||||
import { ScrollArea } from "@cfdm/ui/components/scroll-area"
|
|
||||||
import { Separator } from "@cfdm/ui/components/separator"
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@cfdm/ui/components/tooltip"
|
|
||||||
import {
|
|
||||||
NOTIFICATIONS,
|
|
||||||
type Notification,
|
|
||||||
type NotificationGroupMember,
|
|
||||||
type NotificationType,
|
|
||||||
type NotificationVariant,
|
|
||||||
} from "./data"
|
|
||||||
import { MessageSquareIcon, PaperclipIcon, UserPlusIcon, CreditCardIcon, ShieldAlertIcon, SparklesIcon, RocketIcon, ActivityIcon, AlertCircleIcon, CircleCheckIcon, LinkIcon, StarIcon, ThumbsUpIcon, UsersIcon, SmileIcon, CalendarIcon, DownloadIcon, CheckCheckIcon, BellIcon } from "lucide-react"
|
|
||||||
|
|
||||||
const NOTIFICATION_ICONS: Record<NotificationType, ReactNode> = {
|
|
||||||
mention: (
|
|
||||||
<MessageSquareIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
comment: (
|
|
||||||
<MessageSquareIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
share: (
|
|
||||||
<PaperclipIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
invite: (
|
|
||||||
<UserPlusIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
billing: (
|
|
||||||
<CreditCardIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
security: (
|
|
||||||
<ShieldAlertIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
feature: (
|
|
||||||
<SparklesIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
deployment: (
|
|
||||||
<RocketIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
usage: (
|
|
||||||
<ActivityIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
system: (
|
|
||||||
<AlertCircleIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
task: (
|
|
||||||
<CircleCheckIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
approval: (
|
|
||||||
<CircleCheckIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
integration: (
|
|
||||||
<LinkIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
achievement: (
|
|
||||||
<StarIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
feedback: (
|
|
||||||
<ThumbsUpIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
team_join: (
|
|
||||||
<UsersIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
reaction: (
|
|
||||||
<SmileIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
review: (
|
|
||||||
<StarIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
event: (
|
|
||||||
<CalendarIcon aria-hidden="true" />
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
const VARIANT_COLORS: Record<NotificationVariant, string> = {
|
|
||||||
info: "text-info",
|
|
||||||
success: "text-success",
|
|
||||||
warning: "text-warning",
|
|
||||||
destructive: "text-destructive",
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotifAvatarGroup({
|
|
||||||
members,
|
|
||||||
count,
|
|
||||||
}: {
|
|
||||||
members: NotificationGroupMember[]
|
|
||||||
count?: number
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<AvatarGroup className="mt-1.5 -space-x-1">
|
|
||||||
{members.map((member) => (
|
|
||||||
<Avatar key={member.src} className="size-5">
|
|
||||||
<AvatarImage src={member.src} alt={member.fallback} />
|
|
||||||
<AvatarFallback className="text-[9px]">
|
|
||||||
{member.fallback}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
))}
|
|
||||||
{count && count > 0 ? (
|
|
||||||
<AvatarGroupCount className="size-5 text-[9px] leading-none">
|
|
||||||
+{count}
|
|
||||||
</AvatarGroupCount>
|
|
||||||
) : null}
|
|
||||||
</AvatarGroup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetaBadge({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
color?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<span className="border-border/60 inline-flex h-[18px] shrink-0 items-center overflow-hidden rounded-full border text-[10px] font-medium">
|
|
||||||
<span className="bg-muted/60 text-muted-foreground border-border/50 flex h-full items-center border-r px-1.5 leading-none">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"flex h-full items-center px-1.5 leading-none",
|
|
||||||
color || "text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MiniProgress({
|
|
||||||
value,
|
|
||||||
variant = "default",
|
|
||||||
}: {
|
|
||||||
value: number
|
|
||||||
variant?: "default" | "success"
|
|
||||||
}) {
|
|
||||||
const indicatorColor =
|
|
||||||
variant === "success"
|
|
||||||
? "**:data-[slot=progress-indicator]:bg-success"
|
|
||||||
: value >= 80
|
|
||||||
? "**:data-[slot=progress-indicator]:bg-warning"
|
|
||||||
: "**:data-[slot=progress-indicator]:bg-primary"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-muted/55 relative mt-1.5 h-1 overflow-hidden rounded-full">
|
|
||||||
<div
|
|
||||||
className="text-muted-foreground pointer-events-none absolute inset-0 opacity-20"
|
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
|
||||||
backgroundImage:
|
|
||||||
"repeating-linear-gradient(-45deg, currentColor 0, currentColor 1px, transparent 0, transparent 4px)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Progress
|
|
||||||
value={value}
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-0 gap-0",
|
|
||||||
"**:data-[slot=progress-track]:h-full **:data-[slot=progress-track]:bg-transparent",
|
|
||||||
"**:data-[slot=progress-indicator]:h-full",
|
|
||||||
indicatorColor
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotificationItem({ notification }: { notification: Notification }) {
|
|
||||||
const {
|
|
||||||
type,
|
|
||||||
variant = "info",
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
time,
|
|
||||||
unread,
|
|
||||||
avatar,
|
|
||||||
username,
|
|
||||||
link,
|
|
||||||
badge,
|
|
||||||
actions,
|
|
||||||
attachment,
|
|
||||||
meta,
|
|
||||||
progress,
|
|
||||||
avatarGroup,
|
|
||||||
avatarGroupCount,
|
|
||||||
rating,
|
|
||||||
eventDate,
|
|
||||||
eventTime,
|
|
||||||
progressVariant,
|
|
||||||
} = notification
|
|
||||||
|
|
||||||
const iconColor = VARIANT_COLORS[variant]
|
|
||||||
const hasAvatar = Boolean(avatar)
|
|
||||||
const hasActions = Boolean(actions?.length)
|
|
||||||
const hasInteractiveChildren = hasActions || Boolean(attachment)
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<>
|
|
||||||
<div className="shrink-0">
|
|
||||||
{hasAvatar ? (
|
|
||||||
<Avatar size="sm">
|
|
||||||
<AvatarImage src={avatar?.src} alt={avatar?.fallback} />
|
|
||||||
<AvatarFallback>{avatar?.fallback}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex size-6 items-center justify-center [&_svg]:size-4",
|
|
||||||
iconColor
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{NOTIFICATION_ICONS[type]}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<p className="text-foreground text-xs leading-snug">
|
|
||||||
{username && (
|
|
||||||
<span className="text-primary font-medium">{username}</span>
|
|
||||||
)}{" "}
|
|
||||||
{hasAvatar ? (
|
|
||||||
<>
|
|
||||||
{title}{" "}
|
|
||||||
{link && (
|
|
||||||
<span className="text-primary font-medium">{link}</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="font-medium">{title}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
{badge && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="h-5 shrink-0 rounded-full px-1.5 text-[10px]"
|
|
||||||
>
|
|
||||||
{badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{body && (
|
|
||||||
<p className="text-muted-foreground line-clamp-2 text-xs">{body}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{avatarGroup && avatarGroup.length > 0 && (
|
|
||||||
<NotifAvatarGroup members={avatarGroup} count={avatarGroupCount} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{rating !== undefined && (
|
|
||||||
<Rating rating={rating} size="sm" className="mt-0.5" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(eventDate || eventTime) && (
|
|
||||||
<div className="bg-muted/50 border-border/50 text-muted-foreground mt-1 inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-[11px]">
|
|
||||||
<CalendarIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
|
|
||||||
{eventDate && (
|
|
||||||
<span className="text-foreground font-medium">{eventDate}</span>
|
|
||||||
)}
|
|
||||||
{eventTime && <span className="opacity-70">{eventTime}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{progress !== undefined && (
|
|
||||||
<MiniProgress value={progress} variant={progressVariant} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{attachment && (
|
|
||||||
<div className="flex items-center gap-1 py-1">
|
|
||||||
<ButtonGroup>
|
|
||||||
<Button variant="outline" size="xs">
|
|
||||||
<PaperclipIcon aria-hidden="true" />
|
|
||||||
{attachment.name}
|
|
||||||
<span className="opacity-60">({attachment.size})</span>
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="icon-xs" aria-label="Download">
|
|
||||||
<DownloadIcon aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
</ButtonGroup>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasActions && (
|
|
||||||
<div className="flex items-center gap-1 py-1">
|
|
||||||
{actions?.map((action) => (
|
|
||||||
<Button
|
|
||||||
key={action.label}
|
|
||||||
size="xs"
|
|
||||||
variant={action.variant === "outline" ? "outline" : "default"}
|
|
||||||
>
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pt-0.5">
|
|
||||||
<p className="text-muted-foreground text-[11px]">{time}</p>
|
|
||||||
{meta && (
|
|
||||||
<MetaBadge
|
|
||||||
label={meta.label}
|
|
||||||
value={meta.value}
|
|
||||||
color={meta.color}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
{unread && (
|
|
||||||
<span
|
|
||||||
className="bg-primary ring-background pointer-events-none absolute top-3 right-3 z-10 size-1.5 rounded-full ring-1"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{hasInteractiveChildren ? (
|
|
||||||
<div className="flex w-full items-start gap-2 p-2 text-left">
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="h-auto w-full items-start justify-start p-2 text-left whitespace-normal"
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotificationsPanel() {
|
|
||||||
const unreadCount = NOTIFICATIONS.filter(
|
|
||||||
(notification) => notification.unread
|
|
||||||
).length
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="border-border/40 flex items-center justify-between border-b px-4 py-3">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<span className="truncate text-sm font-semibold">Notifications</span>
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<Badge className="h-5 min-w-5 rounded-full px-1.5 text-[11px]">
|
|
||||||
{unreadCount}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
className="opacity-60 hover:opacity-100"
|
|
||||||
aria-label="Mark all as read"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CheckCheckIcon className="size-3.5" aria-hidden="true" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Mark all as read</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative flex max-h-full">
|
|
||||||
<ScrollArea className="max-h-[320px] grow">
|
|
||||||
{NOTIFICATIONS.map((notification, index) => (
|
|
||||||
<div key={notification.id}>
|
|
||||||
<NotificationItem notification={notification} />
|
|
||||||
{index < NOTIFICATIONS.length - 1 && (
|
|
||||||
<Separator className="opacity-60" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-border/60 border-t px-2 py-1">
|
|
||||||
<Button variant="ghost" size="sm" className="w-full text-xs">
|
|
||||||
View All Notifications
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotificationsMenu() {
|
|
||||||
const unreadCount = NOTIFICATIONS.filter(
|
|
||||||
(notification) => notification.unread
|
|
||||||
).length
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger
|
|
||||||
aria-label="Open notifications"
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={`Notifications, ${unreadCount} unread`}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="relative inline-flex">
|
|
||||||
<BellIcon className="size-4.5 transition-colors" aria-hidden="true" />
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<span
|
|
||||||
className="bg-primary ring-background absolute -top-1 -right-1 size-1.5 rounded-full ring-2"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</PopoverTrigger>
|
|
||||||
|
|
||||||
<PopoverContent
|
|
||||||
side="bottom"
|
|
||||||
align="end"
|
|
||||||
sideOffset={8}
|
|
||||||
className="!bg-background w-80 gap-0 p-0"
|
|
||||||
>
|
|
||||||
<NotificationsPanel />
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Button } from "@cfdm/ui/components/button"
|
|
||||||
import { DownloadIcon, PlusIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export function PageToolbar() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<h1 className="text-foreground text-xl leading-6 font-semibold tracking-tight">
|
|
||||||
Overview
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Your workspace at a glance.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<DownloadIcon aria-hidden="true" />
|
|
||||||
Export
|
|
||||||
</Button>
|
|
||||||
<Button size="sm">
|
|
||||||
<PlusIcon aria-hidden="true" />
|
|
||||||
New Report
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { useEffect, useId, useState } from "react"
|
|
||||||
|
|
||||||
import { Button } from "@cfdm/ui/components/button"
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@cfdm/ui/components/dialog"
|
|
||||||
import { Input } from "@cfdm/ui/components/input"
|
|
||||||
import { SearchIcon } from "lucide-react"
|
|
||||||
|
|
||||||
export function SearchMenu() {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const searchInputId = useId()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
|
|
||||||
event.preventDefault()
|
|
||||||
setOpen(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown)
|
|
||||||
return () => window.removeEventListener("keydown", onKeyDown)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label="Search"
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
aria-expanded={open}
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<SearchIcon className="size-4.5 transition-colors" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogHeader className="sr-only">
|
|
||||||
<DialogTitle>Search</DialogTitle>
|
|
||||||
<DialogDescription>Search your workspace content.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogContent className="max-w-md px-4 py-2 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
|
|
||||||
<div className="relative flex items-center gap-3">
|
|
||||||
<SearchIcon aria-hidden="true" className="pointer-events-none size-4 opacity-60 select-none" />
|
|
||||||
<Input
|
|
||||||
id={searchInputId}
|
|
||||||
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
|
||||||
autoFocus
|
|
||||||
placeholder="Type to search..."
|
|
||||||
aria-label="Search"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { FOOTER_LINKS } from "./data"
|
|
||||||
|
|
||||||
export function SiteFooter() {
|
|
||||||
return (
|
|
||||||
<footer className="border-t px-4 py-2.5 md:px-6">
|
|
||||||
<div className="flex flex-col items-center justify-between gap-2 text-xs md:flex-row">
|
|
||||||
<div className="text-muted-foreground flex items-center gap-1.5">
|
|
||||||
<span>2026 ©</span>
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="text-secondary-foreground hover:text-primary font-medium"
|
|
||||||
>
|
|
||||||
ReUI
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<nav className="text-muted-foreground flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
|
|
||||||
{FOOTER_LINKS.map((link) => (
|
|
||||||
<a key={link.id} href="#" className="hover:text-primary">
|
|
||||||
{link.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
} from "@cfdm/ui/components/breadcrumb"
|
|
||||||
import { SidebarTrigger } from "@cfdm/ui/components/sidebar"
|
|
||||||
|
|
||||||
import { AppsMenu } from "./apps-menu"
|
|
||||||
import { NotificationsMenu } from "./notifications-menu"
|
|
||||||
import { SearchMenu } from "./search-menu"
|
|
||||||
import { UserMenu } from "./user-menu"
|
|
||||||
|
|
||||||
export function SiteHeader() {
|
|
||||||
return (
|
|
||||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<SidebarTrigger className="-ml-1 md:hidden" />
|
|
||||||
<Breadcrumb>
|
|
||||||
<BreadcrumbList>
|
|
||||||
<BreadcrumbItem className="hidden md:block">
|
|
||||||
<BreadcrumbLink render={<a href="#" />}>
|
|
||||||
Dashboards
|
|
||||||
</BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
<BreadcrumbSeparator className="hidden md:block" />
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<BreadcrumbPage>Overview</BreadcrumbPage>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-muted-foreground [&_button_svg]:text-muted-foreground [&_button:active_svg]:text-foreground! [&_button:hover>span>svg]:text-foreground! [&_button:hover>svg]:text-foreground! [&_button[aria-expanded=true]_svg]:text-foreground! [&_button[data-popup-open]_svg]:text-foreground! ml-auto flex items-center gap-2">
|
|
||||||
<SearchMenu />
|
|
||||||
<NotificationsMenu />
|
|
||||||
<AppsMenu />
|
|
||||||
<UserMenu />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useState, type ComponentType } from "react"
|
|
||||||
import { useTheme } from "next-themes"
|
|
||||||
|
|
||||||
import { cn } from "@cfdm/ui/lib/utils"
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
} from "@cfdm/ui/components/avatar"
|
|
||||||
import { Button } from "@cfdm/ui/components/button"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@cfdm/ui/components/dropdown-menu"
|
|
||||||
import { USER, WORKSPACES, type Workspace } from "./data"
|
|
||||||
import { SunIcon, MoonIcon, MonitorIcon, CheckIcon, PlusIcon, UserIcon, CreditCardIcon, SettingsIcon, PaletteIcon, LogOutIcon } from "lucide-react"
|
|
||||||
|
|
||||||
function ReuiLogo({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 28 28"
|
|
||||||
fill="none"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
className={className}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient
|
|
||||||
id="sb12-reui"
|
|
||||||
x1="0"
|
|
||||||
y1="0"
|
|
||||||
x2="28"
|
|
||||||
y2="28"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
<stop offset="0%" stopColor="#6366f1" />
|
|
||||||
<stop offset="52%" stopColor="#8b5cf6" />
|
|
||||||
<stop offset="100%" stopColor="#ec4899" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<circle cx="14" cy="14" r="14" fill="url(#sb12-reui)" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StudioLogo({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 28 28"
|
|
||||||
fill="none"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
className={className}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient
|
|
||||||
id="sb12-studio"
|
|
||||||
x1="0"
|
|
||||||
y1="0"
|
|
||||||
x2="28"
|
|
||||||
y2="28"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
<stop offset="0%" stopColor="#0ea5e9" />
|
|
||||||
<stop offset="50%" stopColor="#06b6d4" />
|
|
||||||
<stop offset="100%" stopColor="#10b981" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<circle cx="14" cy="14" r="14" fill="url(#sb12-studio)" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function OpsLogo({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 28 28"
|
|
||||||
fill="none"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
className={className}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<linearGradient
|
|
||||||
id="sb12-ops"
|
|
||||||
x1="0"
|
|
||||||
y1="0"
|
|
||||||
x2="28"
|
|
||||||
y2="28"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
<stop offset="0%" stopColor="#f97316" />
|
|
||||||
<stop offset="50%" stopColor="#f59e0b" />
|
|
||||||
<stop offset="100%" stopColor="#84cc16" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<circle cx="14" cy="14" r="14" fill="url(#sb12-ops)" />
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const WORKSPACE_LOGOS: Record<string, ComponentType<{ className?: string }>> = {
|
|
||||||
reui: ReuiLogo,
|
|
||||||
studio: StudioLogo,
|
|
||||||
ops: OpsLogo,
|
|
||||||
}
|
|
||||||
|
|
||||||
const THEMES = [
|
|
||||||
{
|
|
||||||
value: "light",
|
|
||||||
label: "Light",
|
|
||||||
icon: (
|
|
||||||
<SunIcon className="size-3.5" aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "dark",
|
|
||||||
label: "Dark",
|
|
||||||
icon: (
|
|
||||||
<MoonIcon className="size-3.5" aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "system",
|
|
||||||
label: "System",
|
|
||||||
icon: (
|
|
||||||
<MonitorIcon className="size-3.5" aria-hidden="true" />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function ThemeSegmentedToggle() {
|
|
||||||
const { theme, setTheme } = useTheme()
|
|
||||||
const [mounted, setMounted] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const currentTheme = mounted ? (theme ?? "system") : "system"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label="Theme"
|
|
||||||
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
|
||||||
>
|
|
||||||
{THEMES.map(({ value, label, icon }) => {
|
|
||||||
const isActive = currentTheme === value
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={isActive}
|
|
||||||
aria-label={label}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
onClick={() => setTheme(value)}
|
|
||||||
className={cn(
|
|
||||||
"rounded-full",
|
|
||||||
isActive
|
|
||||||
? "bg-background text-foreground shadow-sm"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkspaceAvatar({
|
|
||||||
workspace,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
workspace: Workspace
|
|
||||||
className?: string
|
|
||||||
}) {
|
|
||||||
const LogoComponent = WORKSPACE_LOGOS[workspace.id]
|
|
||||||
|
|
||||||
if (LogoComponent) {
|
|
||||||
return <LogoComponent className={cn("shrink-0", className)} />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Avatar className={cn("shrink-0", className)}>
|
|
||||||
<AvatarFallback className="bg-background border-border text-foreground border text-sm font-medium">
|
|
||||||
{workspace.name.charAt(0).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function WorkspaceItem({
|
|
||||||
workspace,
|
|
||||||
isActive,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
workspace: Workspace
|
|
||||||
isActive: boolean
|
|
||||||
onSelect: (id: string) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem onClick={() => onSelect(workspace.id)}>
|
|
||||||
<WorkspaceAvatar workspace={workspace} className="size-5" />
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
|
||||||
<span className="truncate text-sm font-medium">{workspace.name}</span>
|
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
|
||||||
{workspace.tier}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{isActive && (
|
|
||||||
<CheckIcon className="ml-auto size-3.5 shrink-0 opacity-60" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UserMenu() {
|
|
||||||
const [activeWorkspaceId, setActiveWorkspaceId] = useState(WORKSPACES[0].id)
|
|
||||||
|
|
||||||
const activeWorkspace =
|
|
||||||
WORKSPACES.find((workspace) => workspace.id === activeWorkspaceId) ??
|
|
||||||
WORKSPACES[0]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Open user menu" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Avatar className="size-6">
|
|
||||||
<AvatarImage src={USER.image} alt={USER.name} />
|
|
||||||
<AvatarFallback className="text-[9px]">
|
|
||||||
{USER.initials}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent
|
|
||||||
side="bottom"
|
|
||||||
align="end"
|
|
||||||
sideOffset={8}
|
|
||||||
className="!bg-background w-64"
|
|
||||||
>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel className="flex items-center gap-2 py-2">
|
|
||||||
<Avatar className="size-6">
|
|
||||||
<AvatarImage src={USER.image} alt={USER.name} />
|
|
||||||
<AvatarFallback className="text-[9px]">
|
|
||||||
{USER.initials}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex min-w-0 flex-col">
|
|
||||||
<span className="text-foreground truncate text-sm font-semibold">
|
|
||||||
{USER.name}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
|
||||||
{USER.email}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel className="text-muted-foreground text-xs font-normal">
|
|
||||||
Organizations
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
{WORKSPACES.map((workspace) => (
|
|
||||||
<WorkspaceItem
|
|
||||||
key={workspace.id}
|
|
||||||
workspace={workspace}
|
|
||||||
isActive={activeWorkspaceId === workspace.id}
|
|
||||||
onSelect={setActiveWorkspaceId}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<PlusIcon aria-hidden="true" className="mx-0.5" />
|
|
||||||
New Organization
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel className="text-muted-foreground text-xs font-normal">
|
|
||||||
Account
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<UserIcon aria-hidden="true" />
|
|
||||||
Profile
|
|
||||||
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<CreditCardIcon aria-hidden="true" />
|
|
||||||
Billing
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<SettingsIcon aria-hidden="true" />
|
|
||||||
Preferences
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className="cursor-default focus:bg-transparent!">
|
|
||||||
<PaletteIcon aria-hidden="true" />
|
|
||||||
Theme
|
|
||||||
<div className="ml-auto">
|
|
||||||
<ThemeSegmentedToggle />
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<LogOutIcon aria-hidden="true" />
|
|
||||||
Sign Out
|
|
||||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { AppShell } from "./components/app-shell"
|
|
||||||
|
|
||||||
export function Page() {
|
|
||||||
return <AppShell />
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { cn } from "@cfdm/ui/lib/utils"
|
|
||||||
import { Item, ItemMedia } from "@cfdm/ui/components/item"
|
|
||||||
|
|
||||||
export function AuthLogo({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<Item
|
|
||||||
className={cn(
|
|
||||||
"p-0",
|
|
||||||
"bg-primary text-primary-foreground flex size-8 shrink-0 items-center justify-center",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
|
||||||
<svg
|
|
||||||
width="50"
|
|
||||||
height="50"
|
|
||||||
viewBox="25.668 25.1352 49.6644 50"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
className="size-4"
|
|
||||||
>
|
|
||||||
<circle cx="70.634" cy="29.8334" r="4.69799" fill="currentColor" />
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
clipRule="evenodd"
|
|
||||||
d="M25.668 57.0144V29.8332C25.668 27.2386 27.7713 25.1352 30.366 25.1352C32.9606 25.1352 35.0639 27.2386 35.0639 29.8332V57.0144C35.0639 61.833 38.9702 65.7392 43.7888 65.7392H57.2116C62.0302 65.7392 65.9364 61.833 65.9364 57.0144V43.7258C65.9364 41.1312 68.0398 39.0278 70.6344 39.0278C73.229 39.0278 75.3324 41.1312 75.3324 43.7258V57.0144C75.3324 67.0222 67.2194 75.1352 57.2116 75.1352H43.7888C33.7809 75.1352 25.668 67.0222 25.668 57.0144Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { type ComponentProps } from "react"
|
|
||||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
|
||||||
|
|
||||||
import { AUTH13_SIDEBAR_IMAGE_DARK, AUTH13_SIDEBAR_IMAGE_LIGHT } from "./data"
|
|
||||||
import { LoginForm } from "./login-form"
|
|
||||||
|
|
||||||
type FormSubmitHandler = NonNullable<ComponentProps<"form">["onSubmit"]>
|
|
||||||
type FormSubmitEvent = Parameters<FormSubmitHandler>[0]
|
|
||||||
|
|
||||||
function SidebarFrame() {
|
|
||||||
return (
|
|
||||||
<Frame
|
|
||||||
spacing="lg"
|
|
||||||
className="border-border/70 h-full w-full bg-transparent"
|
|
||||||
>
|
|
||||||
{/* Content */}
|
|
||||||
<FramePanel className="border-border/70 h-full min-h-[32rem] overflow-hidden p-0 shadow-none before:hidden">
|
|
||||||
{/* Light */}
|
|
||||||
<img
|
|
||||||
src={AUTH13_SIDEBAR_IMAGE_LIGHT}
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className="h-full w-full object-cover dark:hidden"
|
|
||||||
/>
|
|
||||||
{/* Dark */}
|
|
||||||
<img
|
|
||||||
src={AUTH13_SIDEBAR_IMAGE_DARK}
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className="hidden h-full w-full object-cover dark:block"
|
|
||||||
/>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Auth() {
|
|
||||||
function handleSubmit(event: FormSubmitEvent) {
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-svh w-full lg:h-svh lg:overflow-hidden">
|
|
||||||
{/* Grid */}
|
|
||||||
<div className="grid min-h-svh w-full gap-8 px-4 py-4 sm:px-6 sm:py-6 lg:h-svh lg:min-h-0 lg:grid-cols-[32rem_minmax(0,1fr)] lg:gap-8 lg:px-0 lg:py-0">
|
|
||||||
<section className="order-2 flex min-h-[calc(100svh-2rem)] items-center justify-center px-4 py-8 sm:px-8 lg:order-2 lg:h-full lg:min-h-0 lg:px-12 lg:py-0 xl:px-16 2xl:px-20">
|
|
||||||
<LoginForm onSubmit={handleSubmit} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<aside className="order-1 flex min-h-full items-stretch justify-start lg:order-1 lg:h-full lg:min-h-0 lg:w-full lg:self-stretch lg:justify-self-start lg:py-7 lg:pl-7">
|
|
||||||
<div className="w-full lg:sticky lg:top-7 lg:h-[calc(100svh-3.5rem)]">
|
|
||||||
<SidebarFrame />
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import type { ReactNode } from "react"
|
|
||||||
|
|
||||||
import { Apple } from "@cfdm/ui/components/svgs/apple"
|
|
||||||
import { AppleDark } from "@cfdm/ui/components/svgs/appleDark"
|
|
||||||
import { GithubDark } from "@cfdm/ui/components/svgs/githubDark"
|
|
||||||
import { GithubLight } from "@cfdm/ui/components/svgs/githubLight"
|
|
||||||
import { Google } from "@cfdm/ui/components/svgs/google"
|
|
||||||
|
|
||||||
export type AuthProvider = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
logo: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
function ThemeLogo({ light, dark }: { light: ReactNode; dark: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span aria-hidden="true" className="dark:hidden">
|
|
||||||
{light}
|
|
||||||
</span>
|
|
||||||
<span aria-hidden="true" className="hidden dark:block">
|
|
||||||
{dark}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AUTH13_SOCIAL_PROVIDERS: AuthProvider[] = [
|
|
||||||
{
|
|
||||||
id: "google",
|
|
||||||
label: "Continue with Google",
|
|
||||||
logo: <Google aria-hidden="true" data-icon="inline-start" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "apple",
|
|
||||||
label: "Continue with Apple",
|
|
||||||
logo: (
|
|
||||||
<ThemeLogo
|
|
||||||
light={<Apple aria-hidden="true" data-icon="inline-start" />}
|
|
||||||
dark={<AppleDark aria-hidden="true" data-icon="inline-start" />}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "github",
|
|
||||||
label: "Continue with GitHub",
|
|
||||||
logo: (
|
|
||||||
<ThemeLogo
|
|
||||||
light={<GithubLight aria-hidden="true" data-icon="inline-start" />}
|
|
||||||
dark={<GithubDark aria-hidden="true" data-icon="inline-start" />}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const AUTH13_SIDEBAR_IMAGE_LIGHT =
|
|
||||||
"https://images.unsplash.com/photo-1556139943-4bdca53adf1e?auto=format&fit=crop&w=1200&h=1800&q=80"
|
|
||||||
|
|
||||||
export const AUTH13_SIDEBAR_IMAGE_DARK =
|
|
||||||
"https://images.unsplash.com/photo-1709990740078-05aa8ee5b9b7?auto=format&fit=crop&w=1200&h=1800&q=80"
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { type ComponentProps } from "react"
|
|
||||||
|
|
||||||
import { Button } from "@cfdm/ui/components/button"
|
|
||||||
import {
|
|
||||||
Field,
|
|
||||||
FieldDescription,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
} from "@cfdm/ui/components/field"
|
|
||||||
import { Input } from "@cfdm/ui/components/input"
|
|
||||||
import { Separator } from "@cfdm/ui/components/separator"
|
|
||||||
import { AuthLogo } from "./auth-logo"
|
|
||||||
import { AUTH13_SOCIAL_PROVIDERS } from "./data"
|
|
||||||
import { ArrowRightIcon } from "lucide-react"
|
|
||||||
|
|
||||||
type FormSubmitHandler = NonNullable<ComponentProps<"form">["onSubmit"]>
|
|
||||||
type FormSubmitEvent = Parameters<FormSubmitHandler>[0]
|
|
||||||
|
|
||||||
export function LoginForm({
|
|
||||||
onSubmit,
|
|
||||||
}: {
|
|
||||||
onSubmit: (event: FormSubmitEvent) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex w-full max-w-[22rem] flex-col gap-8">
|
|
||||||
{/* Heading */}
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<AuthLogo />
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<h1 className="text-3xl leading-tight font-semibold text-balance">
|
|
||||||
Sign in
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground text-base text-pretty">
|
|
||||||
Enter your work email to get a secure magic link.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<form className="flex flex-col gap-5" onSubmit={onSubmit}>
|
|
||||||
<FieldGroup className="gap-4">
|
|
||||||
<Field className="gap-2">
|
|
||||||
<FieldLabel htmlFor="auth-13-email">Work email</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="auth-13-email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
placeholder="name@company.com"
|
|
||||||
className="bg-background"
|
|
||||||
/>
|
|
||||||
<FieldDescription>
|
|
||||||
Your sign-in link stays active for 15 minutes.
|
|
||||||
</FieldDescription>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full">
|
|
||||||
Send magic link
|
|
||||||
<ArrowRightIcon aria-hidden="true" data-icon="inline-end" />
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Separator className="flex-1" />
|
|
||||||
<span className="text-muted-foreground text-xs">Or continue with</span>
|
|
||||||
<Separator className="flex-1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid */}
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{AUTH13_SOCIAL_PROVIDERS.map((provider) => (
|
|
||||||
<Button
|
|
||||||
key={provider.id}
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-center px-4 [&_svg:not([class*='size-'])]:size-4"
|
|
||||||
>
|
|
||||||
{provider.logo}
|
|
||||||
{provider.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user