Compare commits
10 Commits
7cd8864f2c
...
92ea8673fc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92ea8673fc | ||
|
|
3be4c0a976 | ||
|
|
d1e5499688 | ||
|
|
8da0363089 | ||
|
|
5c1add8c9e | ||
|
|
cebaa2c55c | ||
|
|
2c427a31a1 | ||
|
|
727a5de9dc | ||
|
|
45c35b59fe | ||
|
|
5de97dab14 |
39
.gitea/workflows/publish.yml
Normal file
39
.gitea/workflows/publish.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Build & Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: linux-arm64
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npx vitest run
|
||||||
|
|
||||||
|
- name: Build library
|
||||||
|
run: npm run build:lib
|
||||||
|
|
||||||
|
- name: Publish package
|
||||||
|
run: |
|
||||||
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
npm version "$VERSION" --no-git-tag-version
|
||||||
|
TAG="latest"
|
||||||
|
else
|
||||||
|
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
|
||||||
|
DATE=$(date +%Y%m%d)
|
||||||
|
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
|
||||||
|
TAG="dev"
|
||||||
|
fi
|
||||||
|
echo '@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/' > .npmrc
|
||||||
|
echo '//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${{ secrets.REGISTRY_TOKEN }}' >> .npmrc
|
||||||
|
npm publish --tag "$TAG"
|
||||||
65
CLAUDE.md
65
CLAUDE.md
@@ -42,3 +42,68 @@ import type { Column } from '../design-system/composites'
|
|||||||
import { AppShell } from '../design-system/layout/AppShell'
|
import { AppShell } from '../design-system/layout/AppShell'
|
||||||
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
|
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Using This Design System in Other Apps
|
||||||
|
|
||||||
|
This design system is published as `@cameleer/design-system` to the Gitea npm registry.
|
||||||
|
|
||||||
|
### Registry: `https://gitea.siegeln.net/api/packages/cameleer/npm/`
|
||||||
|
|
||||||
|
### Setup in a consuming app
|
||||||
|
|
||||||
|
1. Add `.npmrc` to the project root:
|
||||||
|
|
||||||
|
```
|
||||||
|
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
|
||||||
|
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${GITEA_TOKEN}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: CI pipelines for consuming apps also need this `.npmrc` and a `GITEA_TOKEN` secret to fetch the package during `npm ci`.
|
||||||
|
|
||||||
|
2. Install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Snapshot builds (during development)
|
||||||
|
npm install @cameleer/design-system@dev
|
||||||
|
|
||||||
|
# Stable releases
|
||||||
|
npm install @cameleer/design-system
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add fonts to `index.html` (required — the package does not bundle fonts):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
```
|
||||||
|
|
||||||
|
Without these, `var(--font-body)` and `var(--font-mono)` fall back to `system-ui` / `monospace`.
|
||||||
|
|
||||||
|
4. Import styles once at app root, then use components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import '@cameleer/design-system/style.css'
|
||||||
|
import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Paths (Consumer)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// All components from single entry
|
||||||
|
import { Button, Input, Modal, DataTable, AppShell } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Providers
|
||||||
|
import { ThemeProvider, useTheme } from '@cameleer/design-system'
|
||||||
|
import { CommandPaletteProvider, useCommandPalette } from '@cameleer/design-system'
|
||||||
|
import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { hashColor } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Styles (once, at app root)
|
||||||
|
import '@cameleer/design-system/style.css'
|
||||||
|
```
|
||||||
|
|||||||
@@ -204,23 +204,26 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
|||||||
|
|
||||||
## Import Paths
|
## Import Paths
|
||||||
|
|
||||||
|
### Within this repo (design system development)
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Primitives
|
import { Button, Input, Badge } from './design-system/primitives'
|
||||||
import { Button, Input, Badge, ... } from './design-system/primitives'
|
import { DataTable, Modal, Toast } from './design-system/composites'
|
||||||
|
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
|
||||||
// Composites
|
|
||||||
import { DataTable, Modal, Toast, ... } from './design-system/composites'
|
|
||||||
import type { Column, SearchResult, FeedEvent, ... } from './design-system/composites'
|
|
||||||
|
|
||||||
// Layout
|
|
||||||
import { AppShell } from './design-system/layout/AppShell'
|
import { AppShell } from './design-system/layout/AppShell'
|
||||||
import { Sidebar } from './design-system/layout/Sidebar'
|
|
||||||
import { TopBar } from './design-system/layout/TopBar'
|
|
||||||
|
|
||||||
// Theme
|
|
||||||
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
|
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### From consuming apps (via npm package)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import '@cameleer/design-system/style.css' // once at app root
|
||||||
|
import { Button, Input, Modal, DataTable, AppShell, ThemeProvider } from '@cameleer/design-system'
|
||||||
|
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
|
||||||
|
|
||||||
## Styling Rules
|
## Styling Rules
|
||||||
|
|
||||||
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)
|
- **CSS Modules only** — no inline styles except dynamic values (width, color from props)
|
||||||
|
|||||||
612
docs/superpowers/plans/2026-03-18-design-system-packaging.md
Normal file
612
docs/superpowers/plans/2026-03-18-design-system-packaging.md
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
# Design System Packaging Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
>
|
||||||
|
> **IMPORTANT: Use an isolated git worktree** — another agent may be working on the main tree. Create a worktree before starting any task. The worktree must be created from the current working state (not a clean `main`) because several provider/util files are uncommitted.
|
||||||
|
|
||||||
|
**Goal:** Package the Cameleer3 design system as `@cameleer/design-system` and publish it to Gitea's npm registry via CI/CD.
|
||||||
|
|
||||||
|
**Architecture:** Vite library mode builds the design system into an ESM bundle + CSS + TypeScript declarations. A Gitea Actions workflow publishes snapshot versions on every push to main, and stable versions on `v*` tags. Consuming apps install from Gitea's npm registry.
|
||||||
|
|
||||||
|
**Tech Stack:** Vite (library mode), vite-plugin-dts, TypeScript, CSS Modules, Gitea Actions
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-03-18-design-system-packaging-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before starting, ensure these currently-untracked files are committed (they are referenced by the library entry point):
|
||||||
|
|
||||||
|
- `src/design-system/providers/CommandPaletteProvider.tsx`
|
||||||
|
- `src/design-system/providers/GlobalFilterProvider.tsx`
|
||||||
|
- `src/design-system/utils/timePresets.ts`
|
||||||
|
|
||||||
|
The git remote should be added **before** creating a worktree, since remotes are repo-level config shared across all worktrees.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| Action | File | Responsibility |
|
||||||
|
|--------|------|----------------|
|
||||||
|
| Create | `src/design-system/index.ts` | Library entry point — re-exports all components, imports global CSS |
|
||||||
|
| Create | `vite.lib.config.ts` | Vite library build config (separate from app build) |
|
||||||
|
| Create | `.gitea/workflows/publish.yml` | CI/CD: test, build, publish on push to main / tag |
|
||||||
|
| Modify | `.gitignore` | Add `dist/` to prevent build artifacts from being committed |
|
||||||
|
| Modify | `package.json` | Rename, add exports/peers/publishConfig/files/repository |
|
||||||
|
| Modify | `tsconfig.node.json` | Include `vite.lib.config.ts` |
|
||||||
|
| Modify | `CLAUDE.md` | Add consumer usage docs for AI agents |
|
||||||
|
| Modify | `COMPONENT_GUIDE.md` | Add package import paths for consumers |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add git remote and commit untracked files
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- None created (git operations + staging existing untracked files)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the origin remote**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote add origin https://gitea.siegeln.net/cameleer/design-system.git
|
||||||
|
```
|
||||||
|
|
||||||
|
If `origin` already exists, use `git remote set-url origin https://gitea.siegeln.net/cameleer/design-system.git` instead.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify remote**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git remote -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `origin https://gitea.siegeln.net/cameleer/design-system.git (fetch)` and `(push)`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit untracked provider/util files**
|
||||||
|
|
||||||
|
These files are required by the library entry point (Task 3) but are currently untracked:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/providers/CommandPaletteProvider.tsx src/design-system/providers/GlobalFilterProvider.tsx src/design-system/utils/timePresets.ts
|
||||||
|
git commit -m "feat: add CommandPaletteProvider, GlobalFilterProvider, and timePresets"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit any other pending changes**
|
||||||
|
|
||||||
|
Check `git status` — there may be other modified files from a prior agent's work. Stage and commit anything that belongs on main before proceeding:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status
|
||||||
|
```
|
||||||
|
|
||||||
|
If there are modified files, commit them with an appropriate message before continuing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Install vite-plugin-dts and add dist/ to .gitignore
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json` (devDependencies)
|
||||||
|
- Modify: `.gitignore`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install the plugin**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D vite-plugin-dts
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `vite-plugin-dts` with `rollupTypes: true` requires `@microsoft/api-extractor` as a peer dependency. If the install warns about this, also install it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D @microsoft/api-extractor
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify it's in devDependencies**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).devDependencies['vite-plugin-dts'])"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: a version string like `^4.x.x`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `dist/` to `.gitignore`**
|
||||||
|
|
||||||
|
Append to `.gitignore`:
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
This prevents build artifacts from being accidentally committed.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json package-lock.json .gitignore
|
||||||
|
git commit -m "chore: add vite-plugin-dts and ignore dist/"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create library entry point
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/design-system/index.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `src/design-system/index.ts`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import './tokens.css'
|
||||||
|
import './reset.css'
|
||||||
|
|
||||||
|
export * from './primitives'
|
||||||
|
export * from './composites'
|
||||||
|
export * from './layout'
|
||||||
|
export * from './providers/ThemeProvider'
|
||||||
|
export * from './providers/CommandPaletteProvider'
|
||||||
|
export * from './providers/GlobalFilterProvider'
|
||||||
|
export * from './utils/hashColor'
|
||||||
|
export * from './utils/timePresets'
|
||||||
|
```
|
||||||
|
|
||||||
|
This file imports `tokens.css` and `reset.css` at the top so Vite includes them in the bundled `style.css`. Without these imports, all `var(--*)` tokens would resolve to nothing in consuming apps.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify TypeScript is happy**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc -b --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/design-system/index.ts
|
||||||
|
git commit -m "feat: add library entry point for design system package"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Create Vite library build config
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `vite.lib.config.ts`
|
||||||
|
- Modify: `tsconfig.node.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `vite.lib.config.ts`**
|
||||||
|
|
||||||
|
Note: `__dirname` works in Vite config files despite this being an ESM project — Vite transpiles config files before executing them.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import dts from 'vite-plugin-dts'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
dts({
|
||||||
|
include: ['src/design-system'],
|
||||||
|
outDir: 'dist',
|
||||||
|
rollupTypes: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
css: {
|
||||||
|
modules: {
|
||||||
|
localsConvention: 'camelCase',
|
||||||
|
generateScopedName: 'cameleer_[name]_[local]_[hash:5]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, 'src/design-system/index.ts'),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: () => 'index.es.js',
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['react', 'react-dom', 'react-router-dom', 'react/jsx-runtime'],
|
||||||
|
},
|
||||||
|
cssFileName: 'style',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Key choices:
|
||||||
|
- `rollupTypes: true` consolidates all `.d.ts` into a single `dist/index.d.ts`
|
||||||
|
- `generateScopedName: 'cameleer_[name]_[local]_[hash:5]'` makes class names debuggable in consumer devtools
|
||||||
|
- `react/jsx-runtime` is externalized (peer dep of React, not bundled)
|
||||||
|
- `cssFileName: 'style'` ensures output is `dist/style.css`
|
||||||
|
- `fileName: () => 'index.es.js'` forces a deterministic output filename — Vite 6 defaults to `.mjs` for ES format which would mismatch `package.json` exports
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `tsconfig.node.json` to include the new config file**
|
||||||
|
|
||||||
|
Change the `include` array from:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"include": ["vite.config.ts", "vite.lib.config.ts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Test the library build**
|
||||||
|
|
||||||
|
The `build:lib` script isn't in `package.json` yet (added in Task 5). Run directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vite build --config vite.lib.config.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `dist/` directory created with `index.es.js`, `style.css`, and `index.d.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify the output**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `index.es.js`, `style.css`, `index.d.ts`
|
||||||
|
|
||||||
|
Check that `style.css` contains token variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep "bg-body" dist/style.css
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: matches found (proves tokens.css was included)
|
||||||
|
|
||||||
|
Check that type declarations contain exported components:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep "Button" dist/index.d.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: matches found (proves types were generated)
|
||||||
|
|
||||||
|
If `index.d.ts` is missing or empty, `rollupTypes` may have failed silently. In that case, install `@microsoft/api-extractor` and rebuild, or set `rollupTypes: false` (which produces individual `.d.ts` files — less clean but functional).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Clean up and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf dist
|
||||||
|
git add vite.lib.config.ts tsconfig.node.json
|
||||||
|
git commit -m "feat: add Vite library build config with dts generation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update package.json for library publishing
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update package.json**
|
||||||
|
|
||||||
|
Apply these changes to the existing `package.json`:
|
||||||
|
|
||||||
|
1. Change `"name"` from `"cameleer3"` to `"@cameleer/design-system"`
|
||||||
|
2. Change `"version"` from `"0.0.0"` to `"0.1.0"`
|
||||||
|
3. Remove `"private": true`
|
||||||
|
4. Add `"main": "./dist/index.es.js"`
|
||||||
|
5. Add `"module": "./dist/index.es.js"`
|
||||||
|
6. Add `"types": "./dist/index.d.ts"`
|
||||||
|
7. Add `"exports"` block (note: `types` must come first):
|
||||||
|
```json
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.es.js"
|
||||||
|
},
|
||||||
|
"./style.css": "./dist/style.css"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
8. Add `"files": ["dist"]`
|
||||||
|
9. Add `"sideEffects": ["*.css"]`
|
||||||
|
10. Add `"publishConfig"`:
|
||||||
|
```json
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
11. Add `"repository"`:
|
||||||
|
```json
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
12. Add `"peerDependencies"`:
|
||||||
|
```json
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
13. Add to `"scripts"`:
|
||||||
|
```json
|
||||||
|
"build:lib": "vite build --config vite.lib.config.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
The final `package.json` should look like:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@cameleer/design-system",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.es.js",
|
||||||
|
"module": "./dist/index.es.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.es.js"
|
||||||
|
},
|
||||||
|
"./style.css": "./dist/style.css"
|
||||||
|
},
|
||||||
|
"files": ["dist"],
|
||||||
|
"sideEffects": ["*.css"],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"build:lib": "vite build --config vite.lib.config.ts",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"happy-dom": "^20.8.4",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"vite-plugin-dts": "<version installed in Task 2>",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify the full library build works end-to-end**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:lib
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: succeeds, `dist/` contains `index.es.js`, `style.css`, `index.d.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Clean up and commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf dist
|
||||||
|
git add package.json
|
||||||
|
git commit -m "feat: configure package.json for @cameleer/design-system publishing"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Create Gitea Actions workflow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.gitea/workflows/publish.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `.gitea/workflows/publish.yml`**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Build & Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: linux-arm64
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npx vitest run
|
||||||
|
|
||||||
|
- name: Build library
|
||||||
|
run: npm run build:lib
|
||||||
|
|
||||||
|
- name: Publish package
|
||||||
|
run: |
|
||||||
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
npm version "$VERSION" --no-git-tag-version
|
||||||
|
TAG="latest"
|
||||||
|
else
|
||||||
|
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
|
||||||
|
DATE=$(date +%Y%m%d)
|
||||||
|
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
|
||||||
|
TAG="dev"
|
||||||
|
fi
|
||||||
|
echo '@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/' > .npmrc
|
||||||
|
echo '//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${{ secrets.REGISTRY_TOKEN }}' >> .npmrc
|
||||||
|
npm publish --tag "$TAG"
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The `.npmrc` is written with `echo` commands (not a heredoc) to avoid YAML indentation being included in the file content, which would break npm's parsing.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .gitea/workflows
|
||||||
|
git add .gitea/workflows/publish.yml
|
||||||
|
git commit -m "ci: add Gitea Actions workflow for npm publishing"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Update documentation for consumers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
- Modify: `COMPONENT_GUIDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add consumer section to `CLAUDE.md`**
|
||||||
|
|
||||||
|
Add the following section at the end of `CLAUDE.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Using This Design System in Other Apps
|
||||||
|
|
||||||
|
This design system is published as `@cameleer/design-system` to the Gitea npm registry.
|
||||||
|
|
||||||
|
### Registry: `https://gitea.siegeln.net/api/packages/cameleer/npm/`
|
||||||
|
|
||||||
|
### Setup in a consuming app
|
||||||
|
|
||||||
|
1. Add `.npmrc` to the project root:
|
||||||
|
|
||||||
|
```
|
||||||
|
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
|
||||||
|
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${GITEA_TOKEN}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: CI pipelines for consuming apps also need this `.npmrc` and a `GITEA_TOKEN` secret to fetch the package during `npm ci`.
|
||||||
|
|
||||||
|
2. Install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Snapshot builds (during development)
|
||||||
|
npm install @cameleer/design-system@dev
|
||||||
|
|
||||||
|
# Stable releases
|
||||||
|
npm install @cameleer/design-system
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add fonts to `index.html` (required — the package does not bundle fonts):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
```
|
||||||
|
|
||||||
|
Without these, `var(--font-body)` and `var(--font-mono)` fall back to `system-ui` / `monospace`.
|
||||||
|
|
||||||
|
4. Import styles once at app root, then use components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import '@cameleer/design-system/style.css'
|
||||||
|
import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Paths (Consumer)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// All components from single entry
|
||||||
|
import { Button, Input, Modal, DataTable, AppShell } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Providers
|
||||||
|
import { ThemeProvider, useTheme } from '@cameleer/design-system'
|
||||||
|
import { CommandPaletteProvider, useCommandPalette } from '@cameleer/design-system'
|
||||||
|
import { GlobalFilterProvider, useGlobalFilters } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Utils
|
||||||
|
import { hashColor } from '@cameleer/design-system'
|
||||||
|
|
||||||
|
// Styles (once, at app root)
|
||||||
|
import '@cameleer/design-system/style.css'
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update the `## Import Paths` section in `COMPONENT_GUIDE.md`**
|
||||||
|
|
||||||
|
Find the `## Import Paths` section heading and replace everything from that heading down to the next `##` heading (or end of file) with:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Import Paths
|
||||||
|
|
||||||
|
### Within this repo (design system development)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Button, Input, Badge } from './design-system/primitives'
|
||||||
|
import { DataTable, Modal, Toast } from './design-system/composites'
|
||||||
|
import type { Column, SearchResult, FeedEvent } from './design-system/composites'
|
||||||
|
import { AppShell } from './design-system/layout/AppShell'
|
||||||
|
import { ThemeProvider, useTheme } from './design-system/providers/ThemeProvider'
|
||||||
|
```
|
||||||
|
|
||||||
|
### From consuming apps (via npm package)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import '@cameleer/design-system/style.css' // once at app root
|
||||||
|
import { Button, Input, Modal, DataTable, AppShell, ThemeProvider } from '@cameleer/design-system'
|
||||||
|
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
See `CLAUDE.md` "Using This Design System in Other Apps" for full setup instructions.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add CLAUDE.md COMPONENT_GUIDE.md
|
||||||
|
git commit -m "docs: add consumer usage guide for @cameleer/design-system package"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Push to Gitea and verify CI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- None (git operations only)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Push all commits to Gitea**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Check CI status**
|
||||||
|
|
||||||
|
Use the Gitea MCP server or visit `https://gitea.siegeln.net/cameleer/design-system/actions` to monitor the workflow run. The workflow should:
|
||||||
|
1. Install deps
|
||||||
|
2. Run tests
|
||||||
|
3. Build the library
|
||||||
|
4. Publish a snapshot version with the `dev` tag
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the package is published**
|
||||||
|
|
||||||
|
Check the Gitea package registry at `https://gitea.siegeln.net/cameleer/-/packages`. The `@cameleer/design-system` package should appear with a `0.0.0-snapshot.*` version tagged as `dev`.
|
||||||
|
|
||||||
|
If the workflow fails, check the job logs via the Gitea MCP server's `actions_run_read` tool for diagnostics.
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# Design System Packaging — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-03-18
|
||||||
|
**Status:** Approved
|
||||||
|
**Package:** `@cameleer/design-system`
|
||||||
|
**Registry:** Gitea npm registry at `gitea.siegeln.net`
|
||||||
|
**Repository:** `https://gitea.siegeln.net/cameleer/design-system`
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Package the Cameleer3 design system as a reusable npm library so other React applications in the Cameleer ecosystem can consume it via `npm install`. Publishing is automated via Gitea Actions.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Registry | Gitea built-in npm registry | Already have Gitea infrastructure |
|
||||||
|
| Package scope | `@cameleer/design-system` | Matches the org |
|
||||||
|
| Export style | Single package, flat exports | Simple DX, tree-shaking handles unused code |
|
||||||
|
| What's included | Everything (primitives, composites, layout, providers, utils, tokens) | All consuming apps are Cameleer apps |
|
||||||
|
| Build tool | Vite library mode | Already using Vite, CSS Modules first-class |
|
||||||
|
| Output format | ESM only | All consumers are Vite/ESM |
|
||||||
|
| Versioning | Tag-based releases + snapshot on every main push | Snapshots for dev, tags for milestones |
|
||||||
|
| Runner arch | ARM64 | Gitea runner is ARM64 |
|
||||||
|
| Auth secret | `REGISTRY_TOKEN` (org-level) | Existing all-access token |
|
||||||
|
|
||||||
|
## 1. Library Entry Point
|
||||||
|
|
||||||
|
New file `src/design-system/index.ts` — the single public API. It must import global CSS at the top so that `tokens.css` and `reset.css` are included in the bundled `dist/style.css`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import './tokens.css'
|
||||||
|
import './reset.css'
|
||||||
|
|
||||||
|
export * from './primitives'
|
||||||
|
export * from './composites'
|
||||||
|
export * from './layout'
|
||||||
|
export * from './providers/ThemeProvider'
|
||||||
|
export * from './providers/CommandPaletteProvider'
|
||||||
|
export * from './providers/GlobalFilterProvider'
|
||||||
|
export * from './utils/hashColor'
|
||||||
|
export * from './utils/timePresets'
|
||||||
|
```
|
||||||
|
|
||||||
|
Without the CSS imports, all `var(--*)` tokens used in component CSS Modules would resolve to nothing in consuming apps.
|
||||||
|
|
||||||
|
Consumers import the bundled CSS once at their app root:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import '@cameleer/design-system/style.css'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Vite Library Build Config
|
||||||
|
|
||||||
|
A separate `vite.lib.config.ts` to keep library and app builds independent:
|
||||||
|
|
||||||
|
- **Entry:** `src/design-system/index.ts`
|
||||||
|
- **Output:** `dist/index.es.js` (ESM)
|
||||||
|
- **CSS:** Extracted to `dist/style.css`
|
||||||
|
- **CSS Modules scoping:** `cameleer_[name]_[local]_[hash:5]` — debuggable in consumer devtools, unique enough to avoid collisions
|
||||||
|
- **Externals:** `react`, `react-dom`, `react-router-dom` (peer deps, not bundled)
|
||||||
|
- **Types:** `vite-plugin-dts` generates `dist/index.d.ts` with full TypeScript declarations
|
||||||
|
- **Build script:** `"build:lib": "vite build --config vite.lib.config.ts"`
|
||||||
|
- **New devDependency:** `vite-plugin-dts` must be installed
|
||||||
|
|
||||||
|
`tsconfig.node.json` must be updated to include `vite.lib.config.ts`.
|
||||||
|
|
||||||
|
Output structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/
|
||||||
|
index.es.js # ESM bundle
|
||||||
|
style.css # All CSS (tokens + reset + component modules)
|
||||||
|
index.d.ts # TypeScript declarations
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Package Configuration
|
||||||
|
|
||||||
|
Updates to `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@cameleer/design-system",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.es.js",
|
||||||
|
"module": "./dist/index.es.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.es.js"
|
||||||
|
},
|
||||||
|
"./style.css": "./dist/style.css"
|
||||||
|
},
|
||||||
|
"files": ["dist"],
|
||||||
|
"sideEffects": ["*.css"],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `"private": true` is removed
|
||||||
|
- `"types"` comes first in the exports conditions (TypeScript resolution requirement)
|
||||||
|
- `publishConfig` ensures `npm publish` targets the Gitea registry, not npmjs.org
|
||||||
|
- Existing `scripts`, `dependencies`, and `devDependencies` remain for the app build
|
||||||
|
- `peerDependencies` tells consumers what to provide
|
||||||
|
|
||||||
|
## 4. Gitea Actions CI/CD Pipeline
|
||||||
|
|
||||||
|
Workflow at `.gitea/workflows/publish.yml`:
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
- Push to `main` → publish snapshot (`0.0.0-snapshot.<YYYYMMDD>.<short-sha>`) with `dev` dist-tag
|
||||||
|
- Push tag `v*` → publish stable release (e.g., `1.0.0`) with `latest` dist-tag
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Checkout at ref
|
||||||
|
2. `npm ci` (install deps)
|
||||||
|
3. `npx vitest run` (gate: don't publish broken code)
|
||||||
|
4. `npm run build:lib` (build the library)
|
||||||
|
5. Determine version from tag or generate snapshot version
|
||||||
|
6. Configure `.npmrc` with scoped registry + auth token
|
||||||
|
7. `npm publish --tag <dev|latest>`
|
||||||
|
|
||||||
|
**Runner:** ARM64 with `node:22-bookworm-slim` container image.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Build & Publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: linux-arm64
|
||||||
|
container:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: npm ci
|
||||||
|
- run: npx vitest run
|
||||||
|
- run: npm run build:lib
|
||||||
|
- run: |
|
||||||
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
npm version "$VERSION" --no-git-tag-version
|
||||||
|
TAG="latest"
|
||||||
|
else
|
||||||
|
SHORT_SHA=$(echo "$GITHUB_SHA" | head -c 7)
|
||||||
|
DATE=$(date +%Y%m%d)
|
||||||
|
npm version "0.0.0-snapshot.${DATE}.${SHORT_SHA}" --no-git-tag-version
|
||||||
|
TAG="dev"
|
||||||
|
fi
|
||||||
|
cat > .npmrc << 'NPMRC'
|
||||||
|
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
|
||||||
|
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
NPMRC
|
||||||
|
npm publish --tag "$TAG"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Consumer Setup
|
||||||
|
|
||||||
|
In any consuming app (e.g., `cameleer3-server/ui`):
|
||||||
|
|
||||||
|
**1. Add `.npmrc` to project root:**
|
||||||
|
|
||||||
|
```
|
||||||
|
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/
|
||||||
|
//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${GITEA_TOKEN}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: The consuming app's CI pipeline also needs this `.npmrc` and a `GITEA_TOKEN` secret to fetch the package during `npm ci`.
|
||||||
|
|
||||||
|
**2. Install:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# During development (snapshot builds)
|
||||||
|
npm install @cameleer/design-system@dev
|
||||||
|
|
||||||
|
# For stable releases (later)
|
||||||
|
npm install @cameleer/design-system
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Add fonts to `index.html`:**
|
||||||
|
|
||||||
|
The design system uses DM Sans and JetBrains Mono via Google Fonts. These must be loaded by the consuming app since font `<link>` tags are not part of the library output:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
```
|
||||||
|
|
||||||
|
Without these, `var(--font-body)` and `var(--font-mono)` will fall back to `system-ui` / `monospace`.
|
||||||
|
|
||||||
|
**4. Use:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Import styles once at app root
|
||||||
|
import '@cameleer/design-system/style.css'
|
||||||
|
|
||||||
|
// Import components
|
||||||
|
import { Button, Input, Modal, AppShell, ThemeProvider } from '@cameleer/design-system'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Documentation Updates
|
||||||
|
|
||||||
|
Update `CLAUDE.md` and `COMPONENT_GUIDE.md` in this repo with:
|
||||||
|
|
||||||
|
- The package name and registry URL
|
||||||
|
- How consuming apps should configure `.npmrc` (including CI)
|
||||||
|
- Font loading requirement (Google Fonts link)
|
||||||
|
- Import patterns for consumers (`@cameleer/design-system` instead of relative paths)
|
||||||
|
- Note that `style.css` must be imported once at the app root
|
||||||
|
|
||||||
|
This ensures other AI agents working on consuming Cameleer apps understand how to use the design system.
|
||||||
967
package-lock.json
generated
967
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -1,11 +1,30 @@
|
|||||||
{
|
{
|
||||||
"name": "cameleer3",
|
"name": "@cameleer/design-system",
|
||||||
"private": true,
|
"version": "0.1.0",
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"main": "./dist/index.es.js",
|
||||||
|
"module": "./dist/index.es.js",
|
||||||
|
"types": "./dist/index.es.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.es.d.ts",
|
||||||
|
"import": "./dist/index.es.js"
|
||||||
|
},
|
||||||
|
"./style.css": "./dist/style.css"
|
||||||
|
},
|
||||||
|
"files": ["dist"],
|
||||||
|
"sideEffects": ["*.css"],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.siegeln.net/cameleer/design-system.git"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"build:lib": "vite build --config vite.lib.config.ts",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest"
|
"test": "vitest"
|
||||||
@@ -15,6 +34,11 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.0.0"
|
"react-router-dom": "^7.0.0"
|
||||||
},
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.0.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
@@ -25,6 +49,7 @@
|
|||||||
"happy-dom": "^20.8.4",
|
"happy-dom": "^20.8.4",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"vite": "^6.0.0",
|
"vite": "^6.0.0",
|
||||||
|
"vite-plugin-dts": "^4.5.4",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
76
src/App.tsx
76
src/App.tsx
@@ -1,4 +1,5 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
import { useMemo, useCallback } from 'react'
|
||||||
|
import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'
|
||||||
import { Dashboard } from './pages/Dashboard/Dashboard'
|
import { Dashboard } from './pages/Dashboard/Dashboard'
|
||||||
import { Metrics } from './pages/Metrics/Metrics'
|
import { Metrics } from './pages/Metrics/Metrics'
|
||||||
import { RouteDetail } from './pages/RouteDetail/RouteDetail'
|
import { RouteDetail } from './pages/RouteDetail/RouteDetail'
|
||||||
@@ -8,8 +9,73 @@ import { Inventory } from './pages/Inventory/Inventory'
|
|||||||
import { Admin } from './pages/Admin/Admin'
|
import { Admin } from './pages/Admin/Admin'
|
||||||
import { ApiDocs } from './pages/ApiDocs/ApiDocs'
|
import { ApiDocs } from './pages/ApiDocs/ApiDocs'
|
||||||
|
|
||||||
|
import { CommandPalette } from './design-system/composites/CommandPalette/CommandPalette'
|
||||||
|
import type { SearchResult } from './design-system/composites/CommandPalette/types'
|
||||||
|
import { useCommandPalette } from './design-system/providers/CommandPaletteProvider'
|
||||||
|
import { useGlobalFilters } from './design-system/providers/GlobalFilterProvider'
|
||||||
|
import { buildSearchData } from './mocks/searchData'
|
||||||
|
import { exchanges } from './mocks/exchanges'
|
||||||
|
import { routes } from './mocks/routes'
|
||||||
|
import { agents } from './mocks/agents'
|
||||||
|
import { SIDEBAR_APPS } from './mocks/sidebar'
|
||||||
|
|
||||||
|
/** Compute which sidebar path to reveal for a given search result */
|
||||||
|
function computeSidebarRevealPath(result: SearchResult): string | undefined {
|
||||||
|
if (!result.path) return undefined
|
||||||
|
|
||||||
|
if (result.category === 'application') {
|
||||||
|
// /apps/:id — already a sidebar node path
|
||||||
|
return result.path
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.category === 'route') {
|
||||||
|
// /routes/:id — already a sidebar node path
|
||||||
|
return result.path
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.category === 'agent') {
|
||||||
|
// /agents/:appId/:agentId — already a sidebar node path
|
||||||
|
return result.path
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.category === 'exchange') {
|
||||||
|
// /exchanges/:id — no sidebar entry; resolve to the parent route
|
||||||
|
const exchange = exchanges.find((e) => e.id === result.id)
|
||||||
|
if (exchange) {
|
||||||
|
return `/routes/${exchange.route}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.path
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { open: paletteOpen, setOpen } = useCommandPalette()
|
||||||
|
const { isInTimeRange, statusFilters } = useGlobalFilters()
|
||||||
|
|
||||||
|
const filteredSearchData = useMemo(() => {
|
||||||
|
// Filter exchanges by time range and status
|
||||||
|
let filteredExchanges = exchanges.filter((e) => isInTimeRange(e.timestamp))
|
||||||
|
if (statusFilters.size > 0) {
|
||||||
|
filteredExchanges = filteredExchanges.filter((e) => statusFilters.has(e.status))
|
||||||
|
}
|
||||||
|
return buildSearchData(filteredExchanges, routes, agents)
|
||||||
|
}, [isInTimeRange, statusFilters])
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(result: SearchResult) => {
|
||||||
|
if (result.path) {
|
||||||
|
const sidebarReveal = computeSidebarRevealPath(result)
|
||||||
|
navigate(result.path, { state: sidebarReveal ? { sidebarReveal } : undefined })
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
},
|
||||||
|
[navigate, setOpen],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/apps" replace />} />
|
<Route path="/" element={<Navigate to="/apps" replace />} />
|
||||||
<Route path="/apps" element={<Dashboard />} />
|
<Route path="/apps" element={<Dashboard />} />
|
||||||
@@ -22,5 +88,13 @@ export default function App() {
|
|||||||
<Route path="/api-docs" element={<ApiDocs />} />
|
<Route path="/api-docs" element={<ApiDocs />} />
|
||||||
<Route path="/inventory" element={<Inventory />} />
|
<Route path="/inventory" element={<Inventory />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<CommandPalette
|
||||||
|
open={paletteOpen}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
onOpen={() => setOpen(true)}
|
||||||
|
data={filteredSearchData}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface CommandPaletteProps {
|
|||||||
|
|
||||||
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
||||||
all: 'All',
|
all: 'All',
|
||||||
|
application: 'Applications',
|
||||||
exchange: 'Exchanges',
|
exchange: 'Exchanges',
|
||||||
route: 'Routes',
|
route: 'Routes',
|
||||||
agent: 'Agents',
|
agent: 'Agents',
|
||||||
@@ -23,6 +24,7 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
|||||||
|
|
||||||
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
||||||
'all',
|
'all',
|
||||||
|
'application',
|
||||||
'exchange',
|
'exchange',
|
||||||
'route',
|
'route',
|
||||||
'agent',
|
'agent',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export type SearchCategory = 'exchange' | 'route' | 'agent'
|
export type SearchCategory = 'application' | 'exchange' | 'route' | 'agent'
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
id: string
|
id: string
|
||||||
@@ -10,6 +10,7 @@ export interface SearchResult {
|
|||||||
meta: string
|
meta: string
|
||||||
timestamp?: string
|
timestamp?: string
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
|
path?: string
|
||||||
expandedContent?: string
|
expandedContent?: string
|
||||||
matchRanges?: [number, number][]
|
matchRanges?: [number, number][]
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/design-system/index.ts
Normal file
11
src/design-system/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import './tokens.css'
|
||||||
|
import './reset.css'
|
||||||
|
|
||||||
|
export * from './primitives'
|
||||||
|
export * from './composites'
|
||||||
|
export * from './layout'
|
||||||
|
export * from './providers/ThemeProvider'
|
||||||
|
export * from './providers/CommandPaletteProvider'
|
||||||
|
export * from './providers/GlobalFilterProvider'
|
||||||
|
export * from './utils/hashColor'
|
||||||
|
export * from './utils/timePresets'
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import styles from './Sidebar.module.css'
|
import styles from './Sidebar.module.css'
|
||||||
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
import camelLogoUrl from '../../../assets/camel-logo.svg'
|
||||||
@@ -220,6 +220,31 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
|
const appNodes = useMemo(() => buildAppTreeNodes(apps), [apps])
|
||||||
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
|
const agentNodes = useMemo(() => buildAgentTreeNodes(apps), [apps])
|
||||||
|
|
||||||
|
// Sidebar reveal from Cmd-K navigation (passed via location state)
|
||||||
|
const sidebarRevealPath = (location.state as { sidebarReveal?: string } | null)?.sidebarReveal ?? null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sidebarRevealPath) return
|
||||||
|
|
||||||
|
// Uncollapse Applications section if reveal path matches an apps tree node
|
||||||
|
const matchesAppTree = appNodes.some((node) =>
|
||||||
|
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
|
||||||
|
)
|
||||||
|
if (matchesAppTree && appsCollapsed) {
|
||||||
|
_setAppsCollapsed(false)
|
||||||
|
localStorage.setItem('cameleer:sidebar:apps-collapsed', 'false')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncollapse Agents section if reveal path matches an agents tree node
|
||||||
|
const matchesAgentTree = agentNodes.some((node) =>
|
||||||
|
node.path === sidebarRevealPath || node.children?.some((child) => child.path === sidebarRevealPath),
|
||||||
|
)
|
||||||
|
if (matchesAgentTree && agentsCollapsed) {
|
||||||
|
_setAgentsCollapsed(false)
|
||||||
|
localStorage.setItem('cameleer:sidebar:agents-collapsed', 'false')
|
||||||
|
}
|
||||||
|
}, [sidebarRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Build starred items
|
// Build starred items
|
||||||
const starredItems = useMemo(
|
const starredItems = useMemo(
|
||||||
() => collectStarredItems(apps, starredIds),
|
() => collectStarredItems(apps, starredIds),
|
||||||
@@ -231,6 +256,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
const starredAgents = starredItems.filter((i) => i.type === 'agent')
|
const starredAgents = starredItems.filter((i) => i.type === 'agent')
|
||||||
const hasStarred = starredItems.length > 0
|
const hasStarred = starredItems.length > 0
|
||||||
|
|
||||||
|
// For exchange detail pages, use the reveal path for sidebar selection so
|
||||||
|
// the parent route is highlighted (exchanges have no sidebar entry of their own)
|
||||||
|
const effectiveSelectedPath = location.pathname.startsWith('/exchanges/') && sidebarRevealPath
|
||||||
|
? sidebarRevealPath
|
||||||
|
: location.pathname
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
<aside className={`${styles.sidebar} ${className ?? ''}`}>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
@@ -299,11 +330,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
{!appsCollapsed && (
|
{!appsCollapsed && (
|
||||||
<SidebarTree
|
<SidebarTree
|
||||||
nodes={appNodes}
|
nodes={appNodes}
|
||||||
selectedPath={location.pathname}
|
selectedPath={effectiveSelectedPath}
|
||||||
isStarred={isStarred}
|
isStarred={isStarred}
|
||||||
onToggleStar={toggleStar}
|
onToggleStar={toggleStar}
|
||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:apps"
|
persistKey="cameleer:expanded:apps"
|
||||||
|
autoRevealPath={sidebarRevealPath}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -332,11 +364,12 @@ export function Sidebar({ apps, className }: SidebarProps) {
|
|||||||
{!agentsCollapsed && (
|
{!agentsCollapsed && (
|
||||||
<SidebarTree
|
<SidebarTree
|
||||||
nodes={agentNodes}
|
nodes={agentNodes}
|
||||||
selectedPath={location.pathname}
|
selectedPath={effectiveSelectedPath}
|
||||||
isStarred={isStarred}
|
isStarred={isStarred}
|
||||||
onToggleStar={toggleStar}
|
onToggleStar={toggleStar}
|
||||||
filterQuery={search}
|
filterQuery={search}
|
||||||
persistKey="cameleer:expanded:agents"
|
persistKey="cameleer:expanded:agents"
|
||||||
|
autoRevealPath={sidebarRevealPath}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useRef,
|
useRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
@@ -31,6 +32,7 @@ export interface SidebarTreeProps {
|
|||||||
className?: string
|
className?: string
|
||||||
filterQuery?: string
|
filterQuery?: string
|
||||||
persistKey?: string // sessionStorage key to persist expand state across remounts
|
persistKey?: string // sessionStorage key to persist expand state across remounts
|
||||||
|
autoRevealPath?: string | null // when set, auto-expand the parent of the matching node
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Star icon SVGs ───────────────────────────────────────────────────────────
|
// ── Star icon SVGs ───────────────────────────────────────────────────────────
|
||||||
@@ -138,6 +140,7 @@ export function SidebarTree({
|
|||||||
className,
|
className,
|
||||||
filterQuery,
|
filterQuery,
|
||||||
persistKey,
|
persistKey,
|
||||||
|
autoRevealPath,
|
||||||
}: SidebarTreeProps) {
|
}: SidebarTreeProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
@@ -146,6 +149,27 @@ export function SidebarTree({
|
|||||||
() => persistKey ? readExpandState(persistKey) : new Set(),
|
() => persistKey ? readExpandState(persistKey) : new Set(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Auto-expand parent when autoRevealPath changes (e.g. from Cmd-K navigation)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoRevealPath) return
|
||||||
|
for (const node of nodes) {
|
||||||
|
// Check if a child of this node matches the reveal path
|
||||||
|
if (node.children?.some((child) => child.path === autoRevealPath)) {
|
||||||
|
if (!userExpandedIds.has(node.id)) {
|
||||||
|
setUserExpandedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.add(node.id)
|
||||||
|
if (persistKey) writeExpandState(persistKey, next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Also check if the node itself matches (top-level node, no parent to expand)
|
||||||
|
if (node.path === autoRevealPath) break
|
||||||
|
}
|
||||||
|
}, [autoRevealPath]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Filter
|
// Filter
|
||||||
const { filtered, matchedParentIds } = useMemo(
|
const { filtered, matchedParentIds } = useMemo(
|
||||||
() => filterNodes(nodes, filterQuery ?? ''),
|
() => filterNodes(nodes, filterQuery ?? ''),
|
||||||
|
|||||||
@@ -14,6 +14,14 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Filters group: time range + status pills */
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Center search trigger */
|
/* Center search trigger */
|
||||||
.search {
|
.search {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -28,9 +36,9 @@
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
min-width: 280px;
|
min-width: 180px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 400px;
|
max-width: 280px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,16 +94,6 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shift {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--running-bg);
|
|
||||||
color: var(--running);
|
|
||||||
border: 1px solid var(--running-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user {
|
.user {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import styles from './TopBar.module.css'
|
import styles from './TopBar.module.css'
|
||||||
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||||
import { Avatar } from '../../primitives/Avatar/Avatar'
|
import { Avatar } from '../../primitives/Avatar/Avatar'
|
||||||
|
import { FilterPill } from '../../primitives/FilterPill/FilterPill'
|
||||||
|
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
|
||||||
|
import { useGlobalFilters, type ExchangeStatus } from '../../providers/GlobalFilterProvider'
|
||||||
|
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
|
||||||
|
|
||||||
interface BreadcrumbItem {
|
interface BreadcrumbItem {
|
||||||
label: string
|
label: string
|
||||||
@@ -10,29 +14,51 @@ interface BreadcrumbItem {
|
|||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
breadcrumb: BreadcrumbItem[]
|
breadcrumb: BreadcrumbItem[]
|
||||||
environment?: string
|
environment?: string
|
||||||
shift?: string
|
|
||||||
user?: { name: string }
|
user?: { name: string }
|
||||||
onSearchClick?: () => void
|
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_PILLS: { status: ExchangeStatus; label: string }[] = [
|
||||||
|
{ status: 'completed', label: 'OK' },
|
||||||
|
{ status: 'warning', label: 'Warn' },
|
||||||
|
{ status: 'failed', label: 'Error' },
|
||||||
|
{ status: 'running', label: 'Running' },
|
||||||
|
]
|
||||||
|
|
||||||
export function TopBar({
|
export function TopBar({
|
||||||
breadcrumb,
|
breadcrumb,
|
||||||
environment,
|
environment,
|
||||||
shift,
|
|
||||||
user,
|
user,
|
||||||
onSearchClick,
|
|
||||||
className,
|
className,
|
||||||
}: TopBarProps) {
|
}: TopBarProps) {
|
||||||
|
const globalFilters = useGlobalFilters()
|
||||||
|
const commandPalette = useCommandPalette()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={`${styles.topbar} ${className ?? ''}`}>
|
<header className={`${styles.topbar} ${className ?? ''}`}>
|
||||||
{/* Left: Breadcrumb */}
|
{/* Left: Breadcrumb */}
|
||||||
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
|
<Breadcrumb items={breadcrumb} className={styles.breadcrumb} />
|
||||||
|
|
||||||
|
{/* Filters: time range + status pills */}
|
||||||
|
<div className={styles.filters}>
|
||||||
|
<TimeRangeDropdown
|
||||||
|
value={globalFilters.timeRange}
|
||||||
|
onChange={globalFilters.setTimeRange}
|
||||||
|
/>
|
||||||
|
{STATUS_PILLS.map(({ status, label }) => (
|
||||||
|
<FilterPill
|
||||||
|
key={status}
|
||||||
|
label={label}
|
||||||
|
active={globalFilters.statusFilters.has(status)}
|
||||||
|
onClick={() => globalFilters.toggleStatus(status)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Center: Search trigger */}
|
{/* Center: Search trigger */}
|
||||||
<button
|
<button
|
||||||
className={styles.search}
|
className={styles.search}
|
||||||
onClick={onSearchClick}
|
onClick={() => commandPalette.setOpen(true)}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Open search"
|
aria-label="Open search"
|
||||||
>
|
>
|
||||||
@@ -42,18 +68,15 @@ export function TopBar({
|
|||||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.searchPlaceholder}>Search by Order ID, route, error...</span>
|
<span className={styles.searchPlaceholder}>Search... ⌘K</span>
|
||||||
<span className={styles.kbd}>Ctrl+K</span>
|
<span className={styles.kbd}>Ctrl+K</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Right: env badge, shift, user */}
|
{/* Right: env badge, user */}
|
||||||
<div className={styles.right}>
|
<div className={styles.right}>
|
||||||
{environment && (
|
{environment && (
|
||||||
<span className={styles.env}>{environment}</span>
|
<span className={styles.env}>{environment}</span>
|
||||||
)}
|
)}
|
||||||
{shift && (
|
|
||||||
<span className={styles.shift}>Shift: {shift}</span>
|
|
||||||
)}
|
|
||||||
{user && (
|
{user && (
|
||||||
<div className={styles.user}>
|
<div className={styles.user}>
|
||||||
<span className={styles.userName}>{user.name}</span>
|
<span className={styles.userName}>{user.name}</span>
|
||||||
|
|||||||
@@ -2,53 +2,7 @@ import { useState } from 'react'
|
|||||||
import styles from './DateRangePicker.module.css'
|
import styles from './DateRangePicker.module.css'
|
||||||
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
|
import { DateTimePicker } from '../DateTimePicker/DateTimePicker'
|
||||||
import { FilterPill } from '../FilterPill/FilterPill'
|
import { FilterPill } from '../FilterPill/FilterPill'
|
||||||
|
import { DEFAULT_PRESETS, computePresetRange, type DateRange, type Preset } from '../../utils/timePresets'
|
||||||
interface DateRange {
|
|
||||||
start: Date
|
|
||||||
end: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Preset {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PRESETS: Preset[] = [
|
|
||||||
{ label: 'Last 1h', value: 'last-1h' },
|
|
||||||
{ label: 'Last 6h', value: 'last-6h' },
|
|
||||||
{ label: 'Today', value: 'today' },
|
|
||||||
{ label: 'This shift', value: 'shift' },
|
|
||||||
{ label: 'Last 24h', value: 'last-24h' },
|
|
||||||
{ label: 'Last 7d', value: 'last-7d' },
|
|
||||||
{ label: 'Custom', value: 'custom' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function computePresetRange(preset: string): DateRange {
|
|
||||||
const now = new Date()
|
|
||||||
const end = now
|
|
||||||
|
|
||||||
switch (preset) {
|
|
||||||
case 'last-1h':
|
|
||||||
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
|
|
||||||
case 'last-6h':
|
|
||||||
return { start: new Date(now.getTime() - 6 * 60 * 60 * 1000), end }
|
|
||||||
case 'today': {
|
|
||||||
const start = new Date(now)
|
|
||||||
start.setHours(0, 0, 0, 0)
|
|
||||||
return { start, end }
|
|
||||||
}
|
|
||||||
case 'shift': {
|
|
||||||
// "This shift" = last 8 hours
|
|
||||||
return { start: new Date(now.getTime() - 8 * 60 * 60 * 1000), end }
|
|
||||||
}
|
|
||||||
case 'last-24h':
|
|
||||||
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
|
|
||||||
case 'last-7d':
|
|
||||||
return { start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), end }
|
|
||||||
default:
|
|
||||||
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DateRangePickerProps {
|
interface DateRangePickerProps {
|
||||||
value: DateRange
|
value: DateRange
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
.trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--amber, var(--warning));
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:hover {
|
||||||
|
border-color: var(--text-faint);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caret {
|
||||||
|
font-size: 9px;
|
||||||
|
opacity: 0.7;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.presetList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import styles from './TimeRangeDropdown.module.css'
|
||||||
|
import { Popover } from '../../composites/Popover/Popover'
|
||||||
|
import { FilterPill } from '../FilterPill/FilterPill'
|
||||||
|
import { computePresetRange, PRESET_SHORT_LABELS } from '../../utils/timePresets'
|
||||||
|
import type { TimeRange } from '../../providers/GlobalFilterProvider'
|
||||||
|
|
||||||
|
const DROPDOWN_PRESETS = [
|
||||||
|
{ value: 'last-1h', label: '1h' },
|
||||||
|
{ value: 'last-3h', label: '3h' },
|
||||||
|
{ value: 'last-6h', label: '6h' },
|
||||||
|
{ value: 'today', label: 'Today' },
|
||||||
|
{ value: 'shift', label: 'Shift' },
|
||||||
|
{ value: 'last-24h', label: '24h' },
|
||||||
|
{ value: 'last-7d', label: '7d' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface TimeRangeDropdownProps {
|
||||||
|
value: TimeRange
|
||||||
|
onChange: (range: TimeRange) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeRangeDropdown({ value, onChange, className }: TimeRangeDropdownProps) {
|
||||||
|
const activeLabel = value.preset ? (PRESET_SHORT_LABELS[value.preset] ?? value.preset) : 'Custom'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
className={className}
|
||||||
|
position="bottom"
|
||||||
|
align="start"
|
||||||
|
trigger={
|
||||||
|
<button className={styles.trigger} type="button" aria-label="Select time range">
|
||||||
|
<span className={styles.icon} aria-hidden="true">⏱</span>
|
||||||
|
<span className={styles.label}>{activeLabel}</span>
|
||||||
|
<span className={styles.caret} aria-hidden="true">▾</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
content={
|
||||||
|
<div className={styles.presetList}>
|
||||||
|
{DROPDOWN_PRESETS.map((preset) => (
|
||||||
|
<FilterPill
|
||||||
|
key={preset.value}
|
||||||
|
label={preset.label}
|
||||||
|
active={value.preset === preset.value}
|
||||||
|
onClick={() => {
|
||||||
|
const range = computePresetRange(preset.value)
|
||||||
|
onChange({ ...range, preset: preset.value })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,5 +28,6 @@ export { StatCard } from './StatCard/StatCard'
|
|||||||
export { StatusDot } from './StatusDot/StatusDot'
|
export { StatusDot } from './StatusDot/StatusDot'
|
||||||
export { Tag } from './Tag/Tag'
|
export { Tag } from './Tag/Tag'
|
||||||
export { Textarea } from './Textarea/Textarea'
|
export { Textarea } from './Textarea/Textarea'
|
||||||
|
export { TimeRangeDropdown } from './TimeRangeDropdown/TimeRangeDropdown'
|
||||||
export { Toggle } from './Toggle/Toggle'
|
export { Toggle } from './Toggle/Toggle'
|
||||||
export { Tooltip } from './Tooltip/Tooltip'
|
export { Tooltip } from './Tooltip/Tooltip'
|
||||||
|
|||||||
28
src/design-system/providers/CommandPaletteProvider.tsx
Normal file
28
src/design-system/providers/CommandPaletteProvider.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface CommandPaletteContextValue {
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(null)
|
||||||
|
|
||||||
|
export function CommandPaletteProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [open, setOpenState] = useState(false)
|
||||||
|
|
||||||
|
const setOpen = useCallback((value: boolean) => {
|
||||||
|
setOpenState(value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandPaletteContext.Provider value={{ open, setOpen }}>
|
||||||
|
{children}
|
||||||
|
</CommandPaletteContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommandPalette(): CommandPaletteContextValue {
|
||||||
|
const ctx = useContext(CommandPaletteContext)
|
||||||
|
if (!ctx) throw new Error('useCommandPalette must be used within CommandPaletteProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
79
src/design-system/providers/GlobalFilterProvider.tsx
Normal file
79
src/design-system/providers/GlobalFilterProvider.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'
|
||||||
|
import { computePresetRange } from '../utils/timePresets'
|
||||||
|
|
||||||
|
export interface TimeRange {
|
||||||
|
start: Date
|
||||||
|
end: Date
|
||||||
|
preset: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExchangeStatus = 'completed' | 'failed' | 'running' | 'warning'
|
||||||
|
|
||||||
|
interface GlobalFilterContextValue {
|
||||||
|
timeRange: TimeRange
|
||||||
|
setTimeRange: (range: TimeRange) => void
|
||||||
|
statusFilters: Set<ExchangeStatus>
|
||||||
|
toggleStatus: (status: ExchangeStatus) => void
|
||||||
|
clearStatusFilters: () => void
|
||||||
|
isInTimeRange: (timestamp: Date) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
|
||||||
|
|
||||||
|
const DEFAULT_PRESET = 'last-3h'
|
||||||
|
|
||||||
|
function getDefaultTimeRange(): TimeRange {
|
||||||
|
const { start, end } = computePresetRange(DEFAULT_PRESET)
|
||||||
|
return { start, end, preset: DEFAULT_PRESET }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
|
||||||
|
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
|
||||||
|
|
||||||
|
const setTimeRange = useCallback((range: TimeRange) => {
|
||||||
|
setTimeRangeState(range)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleStatus = useCallback((status: ExchangeStatus) => {
|
||||||
|
setStatusFilters((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(status)) {
|
||||||
|
next.delete(status)
|
||||||
|
} else {
|
||||||
|
next.add(status)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearStatusFilters = useCallback(() => {
|
||||||
|
setStatusFilters(new Set())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const isInTimeRange = useCallback(
|
||||||
|
(timestamp: Date) => {
|
||||||
|
if (timeRange.preset) {
|
||||||
|
// Recompute from now so the window stays fresh
|
||||||
|
const { start } = computePresetRange(timeRange.preset)
|
||||||
|
return timestamp >= start
|
||||||
|
}
|
||||||
|
return timestamp >= timeRange.start && timestamp <= timeRange.end
|
||||||
|
},
|
||||||
|
[timeRange],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GlobalFilterContext.Provider
|
||||||
|
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</GlobalFilterContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalFilters(): GlobalFilterContextValue {
|
||||||
|
const ctx = useContext(GlobalFilterContext)
|
||||||
|
if (!ctx) throw new Error('useGlobalFilters must be used within GlobalFilterProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
59
src/design-system/utils/timePresets.ts
Normal file
59
src/design-system/utils/timePresets.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export interface DateRange {
|
||||||
|
start: Date
|
||||||
|
end: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Preset {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PRESETS: Preset[] = [
|
||||||
|
{ label: 'Last 1h', value: 'last-1h' },
|
||||||
|
{ label: 'Last 6h', value: 'last-6h' },
|
||||||
|
{ label: 'Today', value: 'today' },
|
||||||
|
{ label: 'This shift', value: 'shift' },
|
||||||
|
{ label: 'Last 24h', value: 'last-24h' },
|
||||||
|
{ label: 'Last 7d', value: 'last-7d' },
|
||||||
|
{ label: 'Custom', value: 'custom' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PRESET_SHORT_LABELS: Record<string, string> = {
|
||||||
|
'last-1h': '1h',
|
||||||
|
'last-3h': '3h',
|
||||||
|
'last-6h': '6h',
|
||||||
|
'today': 'Today',
|
||||||
|
'shift': 'Shift',
|
||||||
|
'last-24h': '24h',
|
||||||
|
'last-7d': '7d',
|
||||||
|
'custom': 'Custom',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computePresetRange(preset: string): DateRange {
|
||||||
|
const now = new Date()
|
||||||
|
const end = now
|
||||||
|
|
||||||
|
switch (preset) {
|
||||||
|
case 'last-1h':
|
||||||
|
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
|
||||||
|
case 'last-3h':
|
||||||
|
return { start: new Date(now.getTime() - 3 * 60 * 60 * 1000), end }
|
||||||
|
case 'last-6h':
|
||||||
|
return { start: new Date(now.getTime() - 6 * 60 * 60 * 1000), end }
|
||||||
|
case 'today': {
|
||||||
|
const start = new Date(now)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
case 'shift': {
|
||||||
|
// "This shift" = last 8 hours
|
||||||
|
return { start: new Date(now.getTime() - 8 * 60 * 60 * 1000), end }
|
||||||
|
}
|
||||||
|
case 'last-24h':
|
||||||
|
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end }
|
||||||
|
case 'last-7d':
|
||||||
|
return { start: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), end }
|
||||||
|
default:
|
||||||
|
return { start: new Date(now.getTime() - 60 * 60 * 1000), end }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import { ThemeProvider } from './design-system/providers/ThemeProvider'
|
import { ThemeProvider } from './design-system/providers/ThemeProvider'
|
||||||
|
import { GlobalFilterProvider } from './design-system/providers/GlobalFilterProvider'
|
||||||
|
import { CommandPaletteProvider } from './design-system/providers/CommandPaletteProvider'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
@@ -9,7 +11,11 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<GlobalFilterProvider>
|
||||||
|
<CommandPaletteProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</CommandPaletteProvider>
|
||||||
|
</GlobalFilterProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
98
src/mocks/searchData.tsx
Normal file
98
src/mocks/searchData.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { SearchResult } from '../design-system/composites/CommandPalette/types'
|
||||||
|
import { exchanges, type Exchange } from './exchanges'
|
||||||
|
import { routes } from './routes'
|
||||||
|
import { agents } from './agents'
|
||||||
|
import { SIDEBAR_APPS, type SidebarApp } from './sidebar'
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
||||||
|
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
||||||
|
return `${ms}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: Exchange['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'OK'
|
||||||
|
case 'failed': return 'ERR'
|
||||||
|
case 'running': return 'RUN'
|
||||||
|
case 'warning': return 'WARN'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusToVariant(status: Exchange['status']): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'success'
|
||||||
|
case 'failed': return 'error'
|
||||||
|
case 'running': return 'running'
|
||||||
|
case 'warning': return 'warning'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(date: Date): string {
|
||||||
|
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthToColor(health: SidebarApp['health']): string {
|
||||||
|
switch (health) {
|
||||||
|
case 'live': return 'success'
|
||||||
|
case 'stale': return 'warning'
|
||||||
|
case 'dead': return 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSearchData(
|
||||||
|
exs: Exchange[] = exchanges,
|
||||||
|
rts: typeof routes = routes,
|
||||||
|
ags: typeof agents = agents,
|
||||||
|
apps: SidebarApp[] = SIDEBAR_APPS,
|
||||||
|
): SearchResult[] {
|
||||||
|
const results: SearchResult[] = []
|
||||||
|
|
||||||
|
for (const app of apps) {
|
||||||
|
const liveAgents = app.agents.filter((a) => a.status === 'live').length
|
||||||
|
results.push({
|
||||||
|
id: app.id,
|
||||||
|
category: 'application',
|
||||||
|
title: app.name,
|
||||||
|
badges: [{ label: app.health.toUpperCase(), color: healthToColor(app.health) }],
|
||||||
|
meta: `${app.routes.length} routes · ${app.agents.length} agents (${liveAgents} live) · ${app.exchangeCount.toLocaleString()} exchanges`,
|
||||||
|
path: `/apps/${app.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const exec of exs) {
|
||||||
|
results.push({
|
||||||
|
id: exec.id,
|
||||||
|
category: 'exchange',
|
||||||
|
title: `${exec.orderId} — ${exec.route}`,
|
||||||
|
badges: [{ label: statusLabel(exec.status), color: statusToVariant(exec.status) }],
|
||||||
|
meta: `${exec.correlationId} · ${formatDuration(exec.durationMs)} · ${exec.customer}`,
|
||||||
|
timestamp: formatTimestamp(exec.timestamp),
|
||||||
|
path: `/exchanges/${exec.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const route of rts) {
|
||||||
|
results.push({
|
||||||
|
id: route.id,
|
||||||
|
category: 'route',
|
||||||
|
title: route.name,
|
||||||
|
badges: [{ label: route.group }],
|
||||||
|
meta: `${route.exchangeCount.toLocaleString()} exchanges · ${route.successRate}% success`,
|
||||||
|
path: `/routes/${route.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agent of ags) {
|
||||||
|
results.push({
|
||||||
|
id: agent.id,
|
||||||
|
category: 'agent',
|
||||||
|
title: agent.name,
|
||||||
|
badges: [{ label: agent.status }],
|
||||||
|
meta: `${agent.service} ${agent.version} · ${agent.tps} · ${agent.lastSeen}`,
|
||||||
|
path: `/agents/${agent.appId}/${agent.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ export function Admin() {
|
|||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[{ label: 'Admin' }]}
|
breadcrumb={[{ label: 'Admin' }]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
|||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
||||||
|
|
||||||
|
// Global filters
|
||||||
|
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
|
import { agents, type AgentHealth as AgentHealthData } from '../../mocks/agents'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||||
@@ -117,6 +120,7 @@ function buildBreadcrumb(scope: Scope) {
|
|||||||
export function AgentHealth() {
|
export function AgentHealth() {
|
||||||
const scope = useScope()
|
const scope = useScope()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { isInTimeRange } = useGlobalFilters()
|
||||||
|
|
||||||
// Filter agents by scope
|
// Filter agents by scope
|
||||||
const filteredAgents = useMemo(() => {
|
const filteredAgents = useMemo(() => {
|
||||||
@@ -135,8 +139,8 @@ export function AgentHealth() {
|
|||||||
const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 0)
|
const totalTps = filteredAgents.reduce((s, a) => s + a.tps, 0)
|
||||||
const totalActiveRoutes = filteredAgents.reduce((s, a) => s + a.activeRoutes, 0)
|
const totalActiveRoutes = filteredAgents.reduce((s, a) => s + a.activeRoutes, 0)
|
||||||
|
|
||||||
// Events are a global timeline feed — show all regardless of scope
|
// Filter events by global time range
|
||||||
const filteredEvents = agentEvents
|
const filteredEvents = agentEvents.filter((e) => isInTimeRange(e.timestamp))
|
||||||
|
|
||||||
// Single instance for expanded charts
|
// Single instance for expanded charts
|
||||||
const singleInstance = scope.level === 'instance' ? filteredAgents[0] : null
|
const singleInstance = scope.level === 'instance' ? filteredAgents[0] : null
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function ApiDocs() {
|
|||||||
<TopBar
|
<TopBar
|
||||||
breadcrumb={[{ label: 'API Documentation' }]}
|
breadcrumb={[{ label: 'API Documentation' }]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function AppDetail() {
|
|||||||
{ label: id ?? '' },
|
{ label: id ?? '' },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -8,13 +8,9 @@ import { Sidebar } from '../../design-system/layout/Sidebar/Sidebar'
|
|||||||
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
import { TopBar } from '../../design-system/layout/TopBar/TopBar'
|
||||||
|
|
||||||
// Composites
|
// Composites
|
||||||
import { FilterBar } from '../../design-system/composites/FilterBar/FilterBar'
|
|
||||||
import type { ActiveFilter } from '../../design-system/composites/FilterBar/FilterBar'
|
|
||||||
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
|
import { DataTable } from '../../design-system/composites/DataTable/DataTable'
|
||||||
import type { Column } from '../../design-system/composites/DataTable/types'
|
import type { Column } from '../../design-system/composites/DataTable/types'
|
||||||
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
|
import { DetailPanel } from '../../design-system/composites/DetailPanel/DetailPanel'
|
||||||
import { CommandPalette } from '../../design-system/composites/CommandPalette/CommandPalette'
|
|
||||||
import type { SearchResult } from '../../design-system/composites/CommandPalette/types'
|
|
||||||
import { ShortcutsBar } from '../../design-system/composites/ShortcutsBar/ShortcutsBar'
|
import { ShortcutsBar } from '../../design-system/composites/ShortcutsBar/ShortcutsBar'
|
||||||
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
|
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
|
||||||
|
|
||||||
@@ -24,10 +20,11 @@ import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
|||||||
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
|
|
||||||
|
// Global filters
|
||||||
|
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||||
import { routes } from '../../mocks/routes'
|
|
||||||
import { agents } from '../../mocks/agents'
|
|
||||||
import { kpiMetrics } from '../../mocks/metrics'
|
import { kpiMetrics } from '../../mocks/metrics'
|
||||||
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
import { SIDEBAR_APPS } from '../../mocks/sidebar'
|
||||||
|
|
||||||
@@ -137,58 +134,6 @@ function durationClass(ms: number, status: Exchange['status']): string {
|
|||||||
return styles.durBreach
|
return styles.durBreach
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Build CommandPalette search data ────────────────────────────────────────
|
|
||||||
function buildSearchData(
|
|
||||||
exs: Exchange[],
|
|
||||||
rts: typeof routes,
|
|
||||||
ags: typeof agents,
|
|
||||||
): SearchResult[] {
|
|
||||||
const results: SearchResult[] = []
|
|
||||||
|
|
||||||
for (const exec of exs) {
|
|
||||||
results.push({
|
|
||||||
id: exec.id,
|
|
||||||
category: 'exchange',
|
|
||||||
title: `${exec.orderId} — ${exec.route}`,
|
|
||||||
badges: [{ label: statusLabel(exec.status), color: statusToVariant(exec.status) }],
|
|
||||||
meta: `${exec.correlationId} · ${formatDuration(exec.durationMs)} · ${exec.customer}`,
|
|
||||||
timestamp: formatTimestamp(exec.timestamp),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const route of rts) {
|
|
||||||
results.push({
|
|
||||||
id: route.id,
|
|
||||||
category: 'route',
|
|
||||||
title: route.name,
|
|
||||||
badges: [{ label: route.group }],
|
|
||||||
meta: `${route.exchangeCount.toLocaleString()} exchanges · ${route.successRate}% success`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const agent of ags) {
|
|
||||||
results.push({
|
|
||||||
id: agent.id,
|
|
||||||
category: 'agent',
|
|
||||||
title: agent.name,
|
|
||||||
badges: [{ label: agent.status }],
|
|
||||||
meta: `${agent.service} ${agent.version} · ${agent.tps} · ${agent.lastSeen}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStatusFilters(exs: Exchange[]) {
|
|
||||||
return [
|
|
||||||
{ label: 'All', value: 'all', count: exs.length },
|
|
||||||
{ label: 'OK', value: 'completed', count: exs.filter((e) => e.status === 'completed').length, color: 'success' as const },
|
|
||||||
{ label: 'Warn', value: 'warning', count: exs.filter((e) => e.status === 'warning').length },
|
|
||||||
{ label: 'Error', value: 'failed', count: exs.filter((e) => e.status === 'failed').length, color: 'error' as const },
|
|
||||||
{ label: 'Running', value: 'running', count: exs.filter((e) => e.status === 'running').length, color: 'running' as const },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const SHORTCUTS = [
|
const SHORTCUTS = [
|
||||||
{ keys: 'Ctrl+K', label: 'Search' },
|
{ keys: 'Ctrl+K', label: 'Search' },
|
||||||
{ keys: '↑↓', label: 'Navigate rows' },
|
{ keys: '↑↓', label: 'Navigate rows' },
|
||||||
@@ -199,12 +144,11 @@ const SHORTCUTS = [
|
|||||||
// ─── Dashboard component ──────────────────────────────────────────────────────
|
// ─── Dashboard component ──────────────────────────────────────────────────────
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const { id: appId } = useParams<{ id: string }>()
|
const { id: appId } = useParams<{ id: string }>()
|
||||||
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>([])
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const [selectedId, setSelectedId] = useState<string | undefined>()
|
const [selectedId, setSelectedId] = useState<string | undefined>()
|
||||||
const [panelOpen, setPanelOpen] = useState(false)
|
const [panelOpen, setPanelOpen] = useState(false)
|
||||||
const [selectedExchange, setSelectedExchange] = useState<Exchange | null>(null)
|
const [selectedExchange, setSelectedExchange] = useState<Exchange | null>(null)
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false)
|
|
||||||
|
const { isInTimeRange, statusFilters } = useGlobalFilters()
|
||||||
|
|
||||||
// Build set of route IDs belonging to the selected app (if any)
|
// Build set of route IDs belonging to the selected app (if any)
|
||||||
const appRouteIds = useMemo(() => {
|
const appRouteIds = useMemo(() => {
|
||||||
@@ -214,55 +158,26 @@ export function Dashboard() {
|
|||||||
return new Set(app.routes.map((r) => r.id))
|
return new Set(app.routes.map((r) => r.id))
|
||||||
}, [appId])
|
}, [appId])
|
||||||
|
|
||||||
const selectedApp = appId ? SIDEBAR_APPS.find((a) => a.id === appId) : null
|
|
||||||
|
|
||||||
// Scope all data to the selected app
|
// Scope all data to the selected app
|
||||||
const scopedExchanges = useMemo(() => {
|
const scopedExchanges = useMemo(() => {
|
||||||
if (!appRouteIds) return exchanges
|
if (!appRouteIds) return exchanges
|
||||||
return exchanges.filter((e) => appRouteIds.has(e.route))
|
return exchanges.filter((e) => appRouteIds.has(e.route))
|
||||||
}, [appRouteIds])
|
}, [appRouteIds])
|
||||||
|
|
||||||
const scopedRoutes = useMemo(() => {
|
// Filter exchanges (scoped + global filters)
|
||||||
if (!appRouteIds) return routes
|
|
||||||
return routes.filter((r) => appRouteIds.has(r.id))
|
|
||||||
}, [appRouteIds])
|
|
||||||
|
|
||||||
const scopedAgents = useMemo(() => {
|
|
||||||
if (!selectedApp) return agents
|
|
||||||
const agentIds = new Set(selectedApp.agents.map((a) => a.id))
|
|
||||||
return agents.filter((a) => agentIds.has(a.id))
|
|
||||||
}, [selectedApp])
|
|
||||||
|
|
||||||
// Filter exchanges (scoped + user filters)
|
|
||||||
const filteredExchanges = useMemo(() => {
|
const filteredExchanges = useMemo(() => {
|
||||||
let data = scopedExchanges
|
let data = scopedExchanges
|
||||||
|
|
||||||
const statusFilter = activeFilters.find((f) =>
|
// Time range filter
|
||||||
['completed', 'failed', 'running', 'warning', 'all'].includes(f.value),
|
data = data.filter((e) => isInTimeRange(e.timestamp))
|
||||||
)
|
|
||||||
if (statusFilter && statusFilter.value !== 'all') {
|
|
||||||
data = data.filter((e) => e.status === statusFilter.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search.trim()) {
|
// Status filter
|
||||||
const q = search.toLowerCase()
|
if (statusFilters.size > 0) {
|
||||||
data = data.filter(
|
data = data.filter((e) => statusFilters.has(e.status))
|
||||||
(e) =>
|
|
||||||
e.orderId.toLowerCase().includes(q) ||
|
|
||||||
e.route.toLowerCase().includes(q) ||
|
|
||||||
e.customer.toLowerCase().includes(q) ||
|
|
||||||
e.correlationId.toLowerCase().includes(q) ||
|
|
||||||
(e.errorMessage?.toLowerCase().includes(q) ?? false),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}, [activeFilters, search, scopedExchanges])
|
}, [scopedExchanges, isInTimeRange, statusFilters])
|
||||||
|
|
||||||
const searchData = useMemo(
|
|
||||||
() => buildSearchData(scopedExchanges, scopedRoutes, scopedAgents),
|
|
||||||
[scopedExchanges, scopedRoutes, scopedAgents],
|
|
||||||
)
|
|
||||||
|
|
||||||
function handleRowClick(row: Exchange) {
|
function handleRowClick(row: Exchange) {
|
||||||
setSelectedId(row.id)
|
setSelectedId(row.id)
|
||||||
@@ -392,7 +307,6 @@ export function Dashboard() {
|
|||||||
}
|
}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
onSearchClick={() => setPaletteOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Scrollable content */}
|
{/* Scrollable content */}
|
||||||
@@ -414,17 +328,6 @@ export function Dashboard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter bar */}
|
|
||||||
<FilterBar
|
|
||||||
filters={buildStatusFilters(scopedExchanges)}
|
|
||||||
activeFilters={activeFilters}
|
|
||||||
onFilterChange={setActiveFilters}
|
|
||||||
searchPlaceholder="Search by Order ID, correlation ID, error message..."
|
|
||||||
searchValue={search}
|
|
||||||
onSearchChange={setSearch}
|
|
||||||
className={styles.filterBar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Exchanges table */}
|
{/* Exchanges table */}
|
||||||
<div className={styles.tableSection}>
|
<div className={styles.tableSection}>
|
||||||
<div className={styles.tableHeader}>
|
<div className={styles.tableHeader}>
|
||||||
@@ -459,15 +362,6 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Command palette */}
|
|
||||||
<CommandPalette
|
|
||||||
open={paletteOpen}
|
|
||||||
onClose={() => setPaletteOpen(false)}
|
|
||||||
onSelect={() => setPaletteOpen(false)}
|
|
||||||
data={searchData}
|
|
||||||
onOpen={() => setPaletteOpen(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Shortcuts bar */}
|
{/* Shortcuts bar */}
|
||||||
<ShortcutsBar shortcuts={SHORTCUTS} />
|
<ShortcutsBar shortcuts={SHORTCUTS} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function ExchangeDetail() {
|
|||||||
{ label: id ?? 'Unknown' },
|
{ label: id ?? 'Unknown' },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
@@ -149,7 +149,6 @@ export function ExchangeDetail() {
|
|||||||
{ label: exchange.id },
|
{ label: exchange.id },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -120,9 +120,8 @@ export function LayoutSection() {
|
|||||||
{ label: 'order-ingest' },
|
{ label: 'order-ingest' },
|
||||||
]}
|
]}
|
||||||
environment="production"
|
environment="production"
|
||||||
shift="Morning"
|
|
||||||
user={{ name: 'Hendrik' }}
|
user={{ name: 'Hendrik' }}
|
||||||
onSearchClick={() => undefined}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DemoCard>
|
</DemoCard>
|
||||||
|
|||||||
@@ -7,24 +7,12 @@
|
|||||||
background: var(--bg-body);
|
background: var(--bg-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Date range picker bar */
|
|
||||||
.dateRangeBar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 10px 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.refreshIndicator {
|
.refreshIndicator {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
flex-shrink: 0;
|
margin-bottom: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refreshDot {
|
.refreshDot {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import styles from './Metrics.module.css'
|
import styles from './Metrics.module.css'
|
||||||
|
|
||||||
@@ -16,7 +15,6 @@ import type { Column } from '../../design-system/composites/DataTable/types'
|
|||||||
|
|
||||||
// Primitives
|
// Primitives
|
||||||
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
||||||
import { DateRangePicker } from '../../design-system/primitives/DateRangePicker/DateRangePicker'
|
|
||||||
import { Sparkline } from '../../design-system/primitives/Sparkline/Sparkline'
|
import { Sparkline } from '../../design-system/primitives/Sparkline/Sparkline'
|
||||||
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
@@ -192,10 +190,6 @@ function convertSeries(series: typeof throughputSeries) {
|
|||||||
// ─── Metrics page ─────────────────────────────────────────────────────────────
|
// ─── Metrics page ─────────────────────────────────────────────────────────────
|
||||||
export function Metrics() {
|
export function Metrics() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [dateRange, setDateRange] = useState({
|
|
||||||
start: new Date('2026-03-18T06:00:00'),
|
|
||||||
end: new Date('2026-03-18T09:15:00'),
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
@@ -210,21 +204,17 @@ export function Metrics() {
|
|||||||
{ label: 'Metrics' },
|
{ label: 'Metrics' },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Scrollable content */}
|
{/* Scrollable content */}
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
|
|
||||||
{/* Date range picker bar */}
|
{/* Auto-refresh indicator */}
|
||||||
<div className={styles.dateRangeBar}>
|
|
||||||
<DateRangePicker value={dateRange} onChange={setDateRange} />
|
|
||||||
<div className={styles.refreshIndicator}>
|
<div className={styles.refreshIndicator}>
|
||||||
<span className={styles.refreshDot} />
|
<span className={styles.refreshDot} />
|
||||||
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* KPI stat cards (5) */}
|
{/* KPI stat cards (5) */}
|
||||||
<div className={styles.kpiStrip}>
|
<div className={styles.kpiStrip}>
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function RouteDetail() {
|
|||||||
{ label: id ?? 'Unknown' },
|
{ label: id ?? 'Unknown' },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
@@ -236,7 +236,7 @@ export function RouteDetail() {
|
|||||||
{ label: route.name },
|
{ label: route.name },
|
||||||
]}
|
]}
|
||||||
environment="PRODUCTION"
|
environment="PRODUCTION"
|
||||||
shift="Day (06:00-18:00)"
|
|
||||||
user={{ name: 'hendrik' }}
|
user={{ name: 'hendrik' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -20,5 +20,5 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts", "vite.lib.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
34
vite.lib.config.ts
Normal file
34
vite.lib.config.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import dts from 'vite-plugin-dts'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
dts({
|
||||||
|
include: ['src/design-system'],
|
||||||
|
exclude: ['**/*.test.tsx', '**/*.test.ts'],
|
||||||
|
outDir: 'dist',
|
||||||
|
tsconfigPath: resolve(__dirname, 'tsconfig.app.json'),
|
||||||
|
rollupTypes: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
css: {
|
||||||
|
modules: {
|
||||||
|
localsConvention: 'camelCase',
|
||||||
|
generateScopedName: 'cameleer_[name]_[local]_[hash:5]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, 'src/design-system/index.ts'),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: () => 'index.es.js',
|
||||||
|
cssFileName: 'style',
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['react', 'react-dom', 'react-router-dom', 'react/jsx-runtime'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user