Compare commits

...

9 Commits

Author SHA1 Message Date
hsiegeln
dfac0db564 chore: bump version to 0.1.44
All checks were successful
Build & Publish / publish (push) Successful in 1m32s
SonarQube Analysis / sonarqube (push) Successful in 2m33s
2026-04-11 10:59:10 +02:00
hsiegeln
bb8e6d9d65 fix: error toasts persist until manually dismissed
All checks were successful
Build & Publish / publish (push) Successful in 1m11s
Error variant toasts no longer auto-close after 5 seconds. Duration 0
means "no auto-dismiss". Other variants unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 10:56:39 +02:00
hsiegeln
fd08d7a552 feat: add FileInput primitive — drag-and-drop file picker
All checks were successful
Build & Publish / publish (push) Successful in 1m24s
Dashed-border dropzone with icon, filename display, clear button,
and imperative handle (file/clear). Replaces native file inputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 08:00:21 +02:00
hsiegeln
6ea2a29a7c fix: remove double-border on TopBar environment slot
All checks were successful
Build & Publish / publish (push) Successful in 1m38s
SonarQube Analysis / sonarqube (push) Successful in 2m24s
The .env wrapper had its own border/background/padding which caused a
nested border appearance when wrapping a Select component. Simplified
to a plain flex container — the child component handles its own styling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 20:59:24 +02:00
hsiegeln
51d5d9337a fix: add @keyframes spin to Spinner CSS module
All checks were successful
Build & Publish / publish (push) Successful in 1m35s
CSS Modules scopes animation names, so the global @keyframes spin in
reset.css was not visible. The spinner rendered but never animated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:36:34 +02:00
hsiegeln
cd00a2e0fa chore: bump version to 0.1.7
All checks were successful
Build & Publish / publish (push) Successful in 1m42s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:03:01 +02:00
hsiegeln
b443fc787e refactor: decompose TopBar into composable shell with children slot
TopBar was a monolith that hardcoded server-specific controls (status
filters, time range, auto-refresh, search trigger). Extract these into
standalone SearchTrigger and AutoRefreshToggle components. TopBar now
accepts children for the center slot, letting consumers compose their
own controls. Fixes cameleer/cameleer-saas#53.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:00:57 +02:00
hsiegeln
4a6e6dea96 chore: add GitNexus config to .gitignore and CLAUDE.md
All checks were successful
Build & Publish / publish (push) Successful in 1m10s
SonarQube Analysis / sonarqube (push) Successful in 2m13s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:31:04 +02:00
hsiegeln
638b868649 refactor: extract duplicated utility functions into shared format-utils
All checks were successful
Build & Publish / publish (push) Successful in 1m12s
Consolidate formatDuration, statusToVariant, statusLabel, formatTimestamp,
toRouteNodeType, and durationClass from 5 page/component files into one
shared utils module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:28:32 +02:00
21 changed files with 414 additions and 223 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ dist/
test-results/ test-results/
screenshots/ screenshots/
.playwright-mcp/ .playwright-mcp/
.gitnexus

102
CLAUDE.md
View File

@@ -128,3 +128,105 @@ import logo512 from '@cameleer/design-system/assets/cameleer3-512.png' // PWA
import logoSvg from '@cameleer/design-system/assets/cameleer3-logo.svg' // high-detail SVG logo import logoSvg from '@cameleer/design-system/assets/cameleer3-logo.svg' // high-detail SVG logo
import camelSvg from '@cameleer/design-system/assets/camel-logo.svg' // simplified camel SVG import camelSvg from '@cameleer/design-system/assets/camel-logo.svg' // simplified camel SVG
``` ```
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **design-system** (1461 symbols, 2336 relationships, 23 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/design-system/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/design-system/context` | Codebase overview, check index freshness |
| `gitnexus://repo/design-system/clusters` | All functional areas |
| `gitnexus://repo/design-system/processes` | All execution flows |
| `gitnexus://repo/design-system/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@cameleer/design-system", "name": "@cameleer/design-system",
"version": "0.1.6", "version": "0.1.44",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@cameleer/design-system", "name": "@cameleer/design-system",
"version": "0.1.6", "version": "0.1.44",
"dependencies": { "dependencies": {
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"react": "^19.0.0", "react": "^19.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@cameleer/design-system", "name": "@cameleer/design-system",
"version": "0.1.6", "version": "0.1.44",
"type": "module", "type": "module",
"main": "./dist/index.es.js", "main": "./dist/index.es.js",
"module": "./dist/index.es.js", "module": "./dist/index.es.js",

View File

@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-react' import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-react'
import styles from './RouteFlow.module.css' import styles from './RouteFlow.module.css'
import { Dropdown } from '../Dropdown/Dropdown' import { Dropdown } from '../Dropdown/Dropdown'
import { formatDuration } from '../../../utils/format-utils'
export interface NodeBadge { export interface NodeBadge {
label: string label: string
@@ -42,12 +43,6 @@ interface RouteFlowProps {
className?: string className?: string
} }
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 durationClass(ms: number, status: string): string { function durationClass(ms: number, status: string): string {
if (status === 'fail') return styles.durBreach if (status === 'fail') return styles.durBreach
if (ms < 50) return styles.durFast if (ms < 50) return styles.durFast

View File

@@ -81,8 +81,9 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const toast = useCallback( const toast = useCallback(
(options: ToastOptions): string => { (options: ToastOptions): string => {
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}` const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
const duration = options.duration ?? DEFAULT_DURATION
const variant = options.variant ?? 'info' const variant = options.variant ?? 'info'
// Error toasts persist until manually dismissed; others auto-close after DEFAULT_DURATION
const duration = options.duration ?? (variant === 'error' ? 0 : DEFAULT_DURATION)
const newToast: ToastItem = { const newToast: ToastItem = {
id, id,
@@ -99,11 +100,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return next.slice(-MAX_TOASTS) return next.slice(-MAX_TOASTS)
}) })
// Schedule auto-dismiss // Schedule auto-dismiss (duration 0 = persist until manual dismiss)
const timer = setTimeout(() => { if (duration > 0) {
dismiss(id) const timer = setTimeout(() => {
}, duration) dismiss(id)
timersRef.current.set(id, timer) }, duration)
timersRef.current.set(id, timer)
}
return id return id
}, },

View File

@@ -0,0 +1,22 @@
import styles from './TopBar.module.css'
interface AutoRefreshToggleProps {
active: boolean
onChange: (active: boolean) => void
className?: string
}
export function AutoRefreshToggle({ active, onChange, className }: AutoRefreshToggleProps) {
return (
<button
className={`${styles.liveToggle} ${active ? styles.liveToggleActive : ''} ${className ?? ''}`}
onClick={() => onChange(!active)}
type="button"
aria-label={active ? 'Disable auto-refresh' : 'Enable auto-refresh'}
title={active ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
>
<span className={styles.liveDot} />
{active ? 'AUTO' : 'MANUAL'}
</button>
)
}

View File

@@ -0,0 +1,24 @@
import { Search } from 'lucide-react'
import styles from './TopBar.module.css'
interface SearchTriggerProps {
onClick: () => void
className?: string
}
export function SearchTrigger({ onClick, className }: SearchTriggerProps) {
return (
<button
className={`${styles.search} ${className ?? ''}`}
onClick={onClick}
type="button"
aria-label="Open search"
>
<span className={styles.searchIcon} aria-hidden="true">
<Search size={13} />
</span>
<span className={styles.searchPlaceholder}>Search... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span>
</button>
)
}

View File

@@ -155,17 +155,6 @@
.env { .env {
display: flex; display: flex;
align-items: center; align-items: center;
height: 30px;
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.user { .user {

View File

@@ -1,14 +1,9 @@
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { Search, Moon, Sun, Power } from 'lucide-react' import { Moon, Sun, Power } from 'lucide-react'
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 { Dropdown } from '../../composites/Dropdown/Dropdown' import { Dropdown } from '../../composites/Dropdown/Dropdown'
import { Avatar } from '../../primitives/Avatar/Avatar' import { Avatar } from '../../primitives/Avatar/Avatar'
import { ButtonGroup } from '../../primitives/ButtonGroup/ButtonGroup'
import type { ButtonGroupItem } from '../../primitives/ButtonGroup/ButtonGroup'
import { TimeRangeDropdown } from '../../primitives/TimeRangeDropdown/TimeRangeDropdown'
import { useGlobalFilters } from '../../providers/GlobalFilterProvider'
import { useCommandPalette } from '../../providers/CommandPaletteProvider'
import { useTheme } from '../../providers/ThemeProvider' import { useTheme } from '../../providers/ThemeProvider'
import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider' import { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider' import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
@@ -20,15 +15,9 @@ interface TopBarProps {
userMenuItems?: import('../../composites/Dropdown/Dropdown').DropdownItem[] userMenuItems?: import('../../composites/Dropdown/Dropdown').DropdownItem[]
onLogout?: () => void onLogout?: () => void
className?: string className?: string
children?: ReactNode
} }
const STATUS_ITEMS: ButtonGroupItem[] = [
{ value: 'completed', label: 'OK', color: 'var(--success)' },
{ value: 'warning', label: 'Warn', color: 'var(--warning)' },
{ value: 'failed', label: 'Error', color: 'var(--error)' },
{ value: 'running', label: 'Running', color: 'var(--running)' },
]
export function TopBar({ export function TopBar({
breadcrumb, breadcrumb,
environment, environment,
@@ -36,9 +25,8 @@ export function TopBar({
userMenuItems, userMenuItems,
onLogout, onLogout,
className, className,
children,
}: TopBarProps) { }: TopBarProps) {
const globalFilters = useGlobalFilters()
const commandPalette = useCommandPalette()
const { theme, toggleTheme } = useTheme() const { theme, toggleTheme } = useTheme()
const breadcrumbOverride = useBreadcrumbOverride() const breadcrumbOverride = useBreadcrumbOverride()
@@ -47,54 +35,11 @@ export function TopBar({
{/* Left: Breadcrumb */} {/* Left: Breadcrumb */}
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} /> <Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
{/* Search trigger */} {/* Center: consumer-provided controls */}
<button {children}
className={styles.search}
onClick={() => commandPalette.setOpen(true)}
type="button"
aria-label="Open search"
>
<span className={styles.searchIcon} aria-hidden="true">
<Search size={13} />
</span>
<span className={styles.searchPlaceholder}>Search... &#8984;K</span>
<span className={styles.kbd}>Ctrl+K</span>
</button>
{/* Status filter group */} {/* Right: theme toggle, env badge, user */}
<ButtonGroup
items={STATUS_ITEMS}
value={globalFilters.statusFilters}
onChange={(selected) => {
// Sync with global filter by toggling the diff
const current = globalFilters.statusFilters
for (const v of selected) {
if (!current.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
for (const v of current) {
if (!selected.has(v)) globalFilters.toggleStatus(v as 'completed' | 'warning' | 'failed' | 'running')
}
}}
/>
{/* Time range pills */}
<TimeRangeDropdown
value={globalFilters.timeRange}
onChange={globalFilters.setTimeRange}
/>
{/* Right: auto-refresh toggle, theme toggle, env badge, user */}
<div className={styles.right}> <div className={styles.right}>
<button
className={`${styles.liveToggle} ${globalFilters.autoRefresh ? styles.liveToggleActive : ''}`}
onClick={() => globalFilters.setAutoRefresh(!globalFilters.autoRefresh)}
type="button"
aria-label={globalFilters.autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh'}
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
>
<span className={styles.liveDot} />
{globalFilters.autoRefresh ? 'AUTO' : 'MANUAL'}
</button>
<button <button
className={styles.themeToggle} className={styles.themeToggle}
onClick={toggleTheme} onClick={toggleTheme}

View File

@@ -4,3 +4,5 @@ export { SidebarTree } from './Sidebar/SidebarTree'
export type { SidebarTreeNode } from './Sidebar/SidebarTree' export type { SidebarTreeNode } from './Sidebar/SidebarTree'
export { useStarred } from './Sidebar/useStarred' export { useStarred } from './Sidebar/useStarred'
export { TopBar } from './TopBar/TopBar' export { TopBar } from './TopBar/TopBar'
export { SearchTrigger } from './TopBar/SearchTrigger'
export { AutoRefreshToggle } from './TopBar/AutoRefreshToggle'

View File

@@ -0,0 +1,64 @@
.wrap {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1.5px dashed var(--border);
border-radius: var(--radius-sm);
background: var(--bg-inset);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.wrap:hover {
border-color: var(--text-muted);
}
.dragOver {
border-color: var(--amber);
background: var(--amber-bg);
}
.icon {
color: var(--text-faint);
flex-shrink: 0;
line-height: 0;
}
.label {
font-family: var(--font-body);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.placeholder {
color: var(--text-faint);
}
.fileName {
color: var(--text-primary);
}
.clearBtn {
margin-left: auto;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
background: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-sm);
padding: 0;
transition: color 0.1s, background 0.1s;
}
.clearBtn:hover {
color: var(--text-primary);
background: var(--bg-hover);
}

View File

@@ -0,0 +1,99 @@
import styles from './FileInput.module.css'
import { forwardRef, useRef, useState, useImperativeHandle, type ReactNode } from 'react'
import { X } from 'lucide-react'
export interface FileInputProps {
/** File type filter, e.g. ".pem,.crt,.cer" */
accept?: string
/** Icon rendered before the label */
icon?: ReactNode
/** Placeholder text when no file is selected */
placeholder?: string
/** Additional CSS class */
className?: string
/** Called when a file is selected or cleared */
onChange?: (file: File | null) => void
}
export interface FileInputHandle {
/** The currently selected File, or null */
file: File | null
/** Programmatically clear the selection */
clear: () => void
}
export const FileInput = forwardRef<FileInputHandle, FileInputProps>(
({ accept, icon, placeholder = 'Drop file or click to browse', className, onChange }, ref) => {
const inputRef = useRef<HTMLInputElement>(null)
const [fileName, setFileName] = useState<string | null>(null)
const [dragOver, setDragOver] = useState(false)
const fileRef = useRef<File | null>(null)
function select(file: File | null) {
fileRef.current = file
setFileName(file?.name ?? null)
onChange?.(file)
}
function handleInputChange() {
select(inputRef.current?.files?.[0] ?? null)
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer.files[0]
if (file && inputRef.current) {
const dt = new DataTransfer()
dt.items.add(file)
inputRef.current.files = dt.files
select(file)
}
}
function handleClear(e: React.MouseEvent) {
e.stopPropagation()
if (inputRef.current) inputRef.current.value = ''
select(null)
}
useImperativeHandle(ref, () => ({
get file() { return fileRef.current },
clear() {
if (inputRef.current) inputRef.current.value = ''
select(null)
},
}))
const wrapClass = [styles.wrap, dragOver && styles.dragOver, className].filter(Boolean).join(' ')
return (
<div
className={wrapClass}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
{icon && <span className={styles.icon}>{icon}</span>}
<span className={`${styles.label} ${fileName ? styles.fileName : styles.placeholder}`}>
{fileName ?? placeholder}
</span>
{fileName && (
<button type="button" className={styles.clearBtn} onClick={handleClear} aria-label="Clear file">
<X size={12} />
</button>
)}
<input
ref={inputRef}
type="file"
accept={accept}
onChange={handleInputChange}
style={{ display: 'none' }}
/>
</div>
)
},
)
FileInput.displayName = 'FileInput'

View File

@@ -5,3 +5,8 @@
border-radius: 50%; border-radius: 50%;
animation: spin 0.6s linear infinite; animation: spin 0.6s linear infinite;
} }
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@@ -11,6 +11,8 @@ export { Collapsible } from './Collapsible/Collapsible'
export { DateRangePicker } from './DateRangePicker/DateRangePicker' export { DateRangePicker } from './DateRangePicker/DateRangePicker'
export { DateTimePicker } from './DateTimePicker/DateTimePicker' export { DateTimePicker } from './DateTimePicker/DateTimePicker'
export { EmptyState } from './EmptyState/EmptyState' export { EmptyState } from './EmptyState/EmptyState'
export { FileInput } from './FileInput/FileInput'
export type { FileInputProps, FileInputHandle } from './FileInput/FileInput'
export { FilterPill } from './FilterPill/FilterPill' export { FilterPill } from './FilterPill/FilterPill'
export { FormField } from './FormField/FormField' export { FormField } from './FormField/FormField'
export { InfoCallout } from './InfoCallout/InfoCallout' export { InfoCallout } from './InfoCallout/InfoCallout'

View File

@@ -3,34 +3,7 @@ import { exchanges, type Exchange } from './exchanges'
import { routes } from './routes' import { routes } from './routes'
import { agents } from './agents' import { agents } from './agents'
import { SIDEBAR_APPS, buildRouteToAppMap, type SidebarApp } from './sidebar' import { SIDEBAR_APPS, buildRouteToAppMap, type SidebarApp } from './sidebar'
import { formatDuration, statusLabel, statusToVariant, formatTimestamp } from '../utils/format-utils'
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 { function healthToColor(health: SidebarApp['health']): string {
switch (health) { switch (health) {

View File

@@ -25,6 +25,9 @@ import { Badge } from '../../design-system/primitives/Badge/Badge'
// Global filters // Global filters
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider' import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
// Utils
import { formatDuration, statusToVariant, statusLabel, toRouteNodeType, durationClass } from '../../utils/format-utils'
// Mock data // Mock data
import { exchanges, type Exchange } from '../../mocks/exchanges' import { exchanges, type Exchange } from '../../mocks/exchanges'
import { kpiMetrics, type KpiMetric } from '../../mocks/metrics' import { kpiMetrics, type KpiMetric } from '../../mocks/metrics'
@@ -66,12 +69,6 @@ const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
})) }))
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
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 formatTimestamp(date: Date): string { function formatTimestamp(date: Date): string {
const y = date.getFullYear() const y = date.getFullYear()
const mo = String(date.getMonth() + 1).padStart(2, '0') const mo = String(date.getMonth() + 1).padStart(2, '0')
@@ -82,24 +79,6 @@ function formatTimestamp(date: Date): string {
return `${y}-${mo}-${d} ${h}:${mi}:${s}` return `${y}-${mo}-${d} ${h}:${mi}:${s}`
} }
function statusToVariant(status: Exchange['status']): 'success' | 'error' | 'running' | 'warning' {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'running': return 'running'
case 'warning': return 'warning'
}
}
function statusLabel(status: Exchange['status']): string {
switch (status) {
case 'completed': return 'OK'
case 'failed': return 'ERR'
case 'running': return 'RUN'
case 'warning': return 'WARN'
}
}
// ─── Table columns (base, without navigate action) ────────────────────────── // ─── Table columns (base, without navigate action) ──────────────────────────
const BASE_COLUMNS: Column<Exchange>[] = [ const BASE_COLUMNS: Column<Exchange>[] = [
{ {
@@ -150,7 +129,7 @@ const BASE_COLUMNS: Column<Exchange>[] = [
header: 'Duration', header: 'Duration',
sortable: true, sortable: true,
render: (_, row) => ( render: (_, row) => (
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}> <MonoText size="sm" className={durationClass(row.durationMs, row.status, styles)}>
{formatDuration(row.durationMs)} {formatDuration(row.durationMs)}
</MonoText> </MonoText>
), ),
@@ -167,14 +146,6 @@ const BASE_COLUMNS: Column<Exchange>[] = [
}, },
] ]
function durationClass(ms: number, status: Exchange['status']): string {
if (status === 'failed') return styles.durBreach
if (ms < 100) return styles.durFast
if (ms < 200) return styles.durNormal
if (ms < 300) return styles.durSlow
return styles.durBreach
}
const SHORTCUTS = [ const SHORTCUTS = [
{ keys: 'Ctrl+K', label: 'Search' }, { keys: 'Ctrl+K', label: 'Search' },
{ keys: '↑↓', label: 'Navigate rows' }, { keys: '↑↓', label: 'Navigate rows' },
@@ -257,16 +228,6 @@ export function Dashboard() {
return undefined return undefined
} }
// Map processor types to RouteNode types
function toRouteNodeType(procType: string): RouteNode['type'] {
switch (procType) {
case 'consumer': return 'from'
case 'transform': return 'process'
case 'enrich': return 'process'
default: return procType as RouteNode['type']
}
}
// Build RouteFlow nodes from exchange processors // Build RouteFlow nodes from exchange processors
const routeNodes: RouteNode[] = selectedExchange const routeNodes: RouteNode[] = selectedExchange
? selectedExchange.processors.map((p) => ({ ? selectedExchange.processors.map((p) => ({

View File

@@ -18,6 +18,9 @@ import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock' import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout' import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
// Utils
import { formatDuration, statusToVariant, toRouteNodeType } from '../../utils/format-utils'
// Mock data // Mock data
import { exchanges } from '../../mocks/exchanges' import { exchanges } from '../../mocks/exchanges'
import { buildRouteToAppMap } from '../../mocks/sidebar' import { buildRouteToAppMap } from '../../mocks/sidebar'
@@ -25,21 +28,6 @@ import { buildRouteToAppMap } from '../../mocks/sidebar'
const ROUTE_TO_APP = buildRouteToAppMap() const ROUTE_TO_APP = buildRouteToAppMap()
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
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 statusToVariant(status: 'completed' | 'failed' | 'running' | 'warning'): 'success' | 'error' | 'running' | 'warning' {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'running': return 'running'
case 'warning': return 'warning'
}
}
function statusToLabel(status: 'completed' | 'failed' | 'running' | 'warning'): string { function statusToLabel(status: 'completed' | 'failed' | 'running' | 'warning'): string {
switch (status) { switch (status) {
case 'completed': return 'COMPLETED' case 'completed': return 'COMPLETED'
@@ -145,16 +133,6 @@ function generateExchangeSnapshotOut(
} }
} }
// Map processor types to RouteNode types
function toRouteNodeType(procType: string): RouteNode['type'] {
switch (procType) {
case 'consumer': return 'from'
case 'transform': return 'process'
case 'enrich': return 'process'
default: return procType as RouteNode['type']
}
}
// ─── ExchangeDetail component ───────────────────────────────────────────────── // ─── ExchangeDetail component ─────────────────────────────────────────────────
export function ExchangeDetail() { export function ExchangeDetail() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()

View File

@@ -5,6 +5,8 @@ import type { SidebarTreeNode } from '../../../design-system/layout/Sidebar/Side
import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot' import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot'
import { Box, Settings, FileText, ChevronRight } from 'lucide-react' import { Box, Settings, FileText, ChevronRight } from 'lucide-react'
import { TopBar } from '../../../design-system/layout/TopBar/TopBar' import { TopBar } from '../../../design-system/layout/TopBar/TopBar'
import { SearchTrigger } from '../../../design-system/layout/TopBar/SearchTrigger'
import { AutoRefreshToggle } from '../../../design-system/layout/TopBar/AutoRefreshToggle'
// ── DemoCard helper ────────────────────────────────────────────────────────── // ── DemoCard helper ──────────────────────────────────────────────────────────
@@ -122,7 +124,7 @@ export function LayoutSection() {
<DemoCard <DemoCard
id="topbar" id="topbar"
title="TopBar" title="TopBar"
description="Top navigation bar with breadcrumb, search trigger, status filters, time range, environment badge, and user avatar." description="Composable top navigation bar. Consumers pass children for the center slot (search, filters, etc.). Shell provides breadcrumb, theme toggle, env badge, and user menu."
> >
<div className={styles.topbarPreview}> <div className={styles.topbarPreview}>
<TopBar <TopBar
@@ -132,9 +134,11 @@ export function LayoutSection() {
{ label: 'order-ingest' }, { label: 'order-ingest' },
]} ]}
environment="production" environment="production"
user={{ name: 'Hendrik' }} user={{ name: 'Hendrik' }}
/> >
<SearchTrigger onClick={() => {}} />
<AutoRefreshToggle active={true} onChange={() => {}} />
</TopBar>
</div> </div>
</DemoCard> </DemoCard>
</section> </section>

View File

@@ -17,39 +17,14 @@ 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 { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout' import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
// Utils
import { formatDuration, statusToVariant, statusLabel, formatTimestamp, durationClass } from '../../utils/format-utils'
// Mock data // Mock data
import { routes } from '../../mocks/routes' import { routes } from '../../mocks/routes'
import { exchanges, type Exchange } from '../../mocks/exchanges' import { exchanges, type Exchange } from '../../mocks/exchanges'
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
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 formatTimestamp(date: Date): string {
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function statusToVariant(status: Exchange['status']): 'success' | 'error' | 'running' | 'warning' {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'running': return 'running'
case 'warning': return 'warning'
}
}
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 routeStatusVariant(status: 'healthy' | 'degraded' | 'down'): 'success' | 'warning' | 'error' { function routeStatusVariant(status: 'healthy' | 'degraded' | 'down'): 'success' | 'warning' | 'error' {
switch (status) { switch (status) {
case 'healthy': return 'success' case 'healthy': return 'success'
@@ -58,14 +33,6 @@ function routeStatusVariant(status: 'healthy' | 'degraded' | 'down'): 'success'
} }
} }
function durationClass(ms: number, status: Exchange['status']): string {
if (status === 'failed') return styles.durBreach
if (ms < 100) return styles.durFast
if (ms < 200) return styles.durNormal
if (ms < 300) return styles.durSlow
return styles.durBreach
}
// ─── Columns for exchanges table ──────────────────────────────────────────── // ─── Columns for exchanges table ────────────────────────────────────────────
const EXCHANGE_COLUMNS: Column<Exchange>[] = [ const EXCHANGE_COLUMNS: Column<Exchange>[] = [
{ {
@@ -106,7 +73,7 @@ const EXCHANGE_COLUMNS: Column<Exchange>[] = [
header: 'Duration', header: 'Duration',
sortable: true, sortable: true,
render: (_, row) => ( render: (_, row) => (
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}> <MonoText size="sm" className={durationClass(row.durationMs, row.status, styles)}>
{formatDuration(row.durationMs)} {formatDuration(row.durationMs)}
</MonoText> </MonoText>
), ),

55
src/utils/format-utils.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { RouteNode } from '../design-system/composites/RouteFlow/RouteFlow'
export 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`
}
export function statusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
switch (status) {
case 'completed': return 'success'
case 'failed': return 'error'
case 'running': return 'running'
case 'warning': return 'warning'
default: return 'warning'
}
}
export function statusLabel(s: string): string {
switch (s) {
case 'completed': return 'OK'
case 'failed': return 'ERR'
case 'running': return 'RUN'
case 'warning': return 'WARN'
default: return s
}
}
export function formatTimestamp(date: Date): string {
return date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
export function toRouteNodeType(procType: string): RouteNode['type'] {
switch (procType) {
case 'consumer': return 'from'
case 'transform': return 'process'
case 'enrich': return 'process'
default: return procType as RouteNode['type']
}
}
interface DurationStyles {
durBreach: string
durFast: string
durNormal: string
durSlow: string
}
export function durationClass(ms: number, status: string, styles: DurationStyles): string {
if (status === 'failed') return styles.durBreach
if (ms < 100) return styles.durFast
if (ms < 200) return styles.durNormal
if (ms < 300) return styles.durSlow
return styles.durBreach
}