Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfac0db564 | ||
|
|
bb8e6d9d65 | ||
|
|
fd08d7a552 | ||
|
|
6ea2a29a7c | ||
|
|
51d5d9337a | ||
|
|
cd00a2e0fa | ||
|
|
b443fc787e | ||
|
|
4a6e6dea96 | ||
|
|
638b868649 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ dist/
|
||||
test-results/
|
||||
screenshots/
|
||||
.playwright-mcp/
|
||||
.gitnexus
|
||||
|
||||
102
CLAUDE.md
102
CLAUDE.md
@@ -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 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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@cameleer/design-system",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.44",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@cameleer/design-system",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.44",
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cameleer/design-system",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.44",
|
||||
"type": "module",
|
||||
"main": "./dist/index.es.js",
|
||||
"module": "./dist/index.es.js",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
|
||||
import { Play, Cog, Square, Diamond, AlertTriangle, EllipsisVertical } from 'lucide-react'
|
||||
import styles from './RouteFlow.module.css'
|
||||
import { Dropdown } from '../Dropdown/Dropdown'
|
||||
import { formatDuration } from '../../../utils/format-utils'
|
||||
|
||||
export interface NodeBadge {
|
||||
label: string
|
||||
@@ -42,12 +43,6 @@ interface RouteFlowProps {
|
||||
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 {
|
||||
if (status === 'fail') return styles.durBreach
|
||||
if (ms < 50) return styles.durFast
|
||||
|
||||
@@ -81,8 +81,9 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const toast = useCallback(
|
||||
(options: ToastOptions): string => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
||||
const duration = options.duration ?? DEFAULT_DURATION
|
||||
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 = {
|
||||
id,
|
||||
@@ -99,11 +100,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
return next.slice(-MAX_TOASTS)
|
||||
})
|
||||
|
||||
// Schedule auto-dismiss
|
||||
const timer = setTimeout(() => {
|
||||
dismiss(id)
|
||||
}, duration)
|
||||
timersRef.current.set(id, timer)
|
||||
// Schedule auto-dismiss (duration 0 = persist until manual dismiss)
|
||||
if (duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
dismiss(id)
|
||||
}, duration)
|
||||
timersRef.current.set(id, timer)
|
||||
}
|
||||
|
||||
return id
|
||||
},
|
||||
|
||||
22
src/design-system/layout/TopBar/AutoRefreshToggle.tsx
Normal file
22
src/design-system/layout/TopBar/AutoRefreshToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
src/design-system/layout/TopBar/SearchTrigger.tsx
Normal file
24
src/design-system/layout/TopBar/SearchTrigger.tsx
Normal 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... ⌘K</span>
|
||||
<span className={styles.kbd}>Ctrl+K</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -155,17 +155,6 @@
|
||||
.env {
|
||||
display: flex;
|
||||
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 {
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
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 { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||
import { Dropdown } from '../../composites/Dropdown/Dropdown'
|
||||
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 { useBreadcrumbOverride } from '../../providers/BreadcrumbProvider'
|
||||
import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
|
||||
@@ -20,15 +15,9 @@ interface TopBarProps {
|
||||
userMenuItems?: import('../../composites/Dropdown/Dropdown').DropdownItem[]
|
||||
onLogout?: () => void
|
||||
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({
|
||||
breadcrumb,
|
||||
environment,
|
||||
@@ -36,9 +25,8 @@ export function TopBar({
|
||||
userMenuItems,
|
||||
onLogout,
|
||||
className,
|
||||
children,
|
||||
}: TopBarProps) {
|
||||
const globalFilters = useGlobalFilters()
|
||||
const commandPalette = useCommandPalette()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const breadcrumbOverride = useBreadcrumbOverride()
|
||||
|
||||
@@ -47,54 +35,11 @@ export function TopBar({
|
||||
{/* Left: Breadcrumb */}
|
||||
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
|
||||
|
||||
{/* Search trigger */}
|
||||
<button
|
||||
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... ⌘K</span>
|
||||
<span className={styles.kbd}>Ctrl+K</span>
|
||||
</button>
|
||||
{/* Center: consumer-provided controls */}
|
||||
{children}
|
||||
|
||||
{/* Status filter group */}
|
||||
<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 */}
|
||||
{/* Right: theme toggle, env badge, user */}
|
||||
<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
|
||||
className={styles.themeToggle}
|
||||
onClick={toggleTheme}
|
||||
|
||||
@@ -4,3 +4,5 @@ export { SidebarTree } from './Sidebar/SidebarTree'
|
||||
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
|
||||
export { useStarred } from './Sidebar/useStarred'
|
||||
export { TopBar } from './TopBar/TopBar'
|
||||
export { SearchTrigger } from './TopBar/SearchTrigger'
|
||||
export { AutoRefreshToggle } from './TopBar/AutoRefreshToggle'
|
||||
|
||||
64
src/design-system/primitives/FileInput/FileInput.module.css
Normal file
64
src/design-system/primitives/FileInput/FileInput.module.css
Normal 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);
|
||||
}
|
||||
99
src/design-system/primitives/FileInput/FileInput.tsx
Normal file
99
src/design-system/primitives/FileInput/FileInput.tsx
Normal 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'
|
||||
@@ -5,3 +5,8 @@
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export { Collapsible } from './Collapsible/Collapsible'
|
||||
export { DateRangePicker } from './DateRangePicker/DateRangePicker'
|
||||
export { DateTimePicker } from './DateTimePicker/DateTimePicker'
|
||||
export { EmptyState } from './EmptyState/EmptyState'
|
||||
export { FileInput } from './FileInput/FileInput'
|
||||
export type { FileInputProps, FileInputHandle } from './FileInput/FileInput'
|
||||
export { FilterPill } from './FilterPill/FilterPill'
|
||||
export { FormField } from './FormField/FormField'
|
||||
export { InfoCallout } from './InfoCallout/InfoCallout'
|
||||
|
||||
@@ -3,34 +3,7 @@ import { exchanges, type Exchange } from './exchanges'
|
||||
import { routes } from './routes'
|
||||
import { agents } from './agents'
|
||||
import { SIDEBAR_APPS, buildRouteToAppMap, 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' })
|
||||
}
|
||||
import { formatDuration, statusLabel, statusToVariant, formatTimestamp } from '../utils/format-utils'
|
||||
|
||||
function healthToColor(health: SidebarApp['health']): string {
|
||||
switch (health) {
|
||||
|
||||
@@ -25,6 +25,9 @@ import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||
// Global filters
|
||||
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
||||
|
||||
// Utils
|
||||
import { formatDuration, statusToVariant, statusLabel, toRouteNodeType, durationClass } from '../../utils/format-utils'
|
||||
|
||||
// Mock data
|
||||
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||
import { kpiMetrics, type KpiMetric } from '../../mocks/metrics'
|
||||
@@ -66,12 +69,6 @@ const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
|
||||
}))
|
||||
|
||||
// ─── 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 {
|
||||
const y = date.getFullYear()
|
||||
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}`
|
||||
}
|
||||
|
||||
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) ──────────────────────────
|
||||
const BASE_COLUMNS: Column<Exchange>[] = [
|
||||
{
|
||||
@@ -150,7 +129,7 @@ const BASE_COLUMNS: Column<Exchange>[] = [
|
||||
header: 'Duration',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}>
|
||||
<MonoText size="sm" className={durationClass(row.durationMs, row.status, styles)}>
|
||||
{formatDuration(row.durationMs)}
|
||||
</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 = [
|
||||
{ keys: 'Ctrl+K', label: 'Search' },
|
||||
{ keys: '↑↓', label: 'Navigate rows' },
|
||||
@@ -257,16 +228,6 @@ export function Dashboard() {
|
||||
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
|
||||
const routeNodes: RouteNode[] = selectedExchange
|
||||
? selectedExchange.processors.map((p) => ({
|
||||
|
||||
@@ -18,6 +18,9 @@ import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
|
||||
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
|
||||
|
||||
// Utils
|
||||
import { formatDuration, statusToVariant, toRouteNodeType } from '../../utils/format-utils'
|
||||
|
||||
// Mock data
|
||||
import { exchanges } from '../../mocks/exchanges'
|
||||
import { buildRouteToAppMap } from '../../mocks/sidebar'
|
||||
@@ -25,21 +28,6 @@ import { buildRouteToAppMap } from '../../mocks/sidebar'
|
||||
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||
|
||||
// ─── 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 {
|
||||
switch (status) {
|
||||
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 ─────────────────────────────────────────────────
|
||||
export function ExchangeDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { SidebarTreeNode } from '../../../design-system/layout/Sidebar/Side
|
||||
import { StatusDot } from '../../../design-system/primitives/StatusDot/StatusDot'
|
||||
import { Box, Settings, FileText, ChevronRight } from 'lucide-react'
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,7 +124,7 @@ export function LayoutSection() {
|
||||
<DemoCard
|
||||
id="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}>
|
||||
<TopBar
|
||||
@@ -132,9 +134,11 @@ export function LayoutSection() {
|
||||
{ label: 'order-ingest' },
|
||||
]}
|
||||
environment="production"
|
||||
|
||||
user={{ name: 'Hendrik' }}
|
||||
/>
|
||||
>
|
||||
<SearchTrigger onClick={() => {}} />
|
||||
<AutoRefreshToggle active={true} onChange={() => {}} />
|
||||
</TopBar>
|
||||
</div>
|
||||
</DemoCard>
|
||||
</section>
|
||||
|
||||
@@ -17,39 +17,14 @@ import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
||||
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||
import { InfoCallout } from '../../design-system/primitives/InfoCallout/InfoCallout'
|
||||
|
||||
// Utils
|
||||
import { formatDuration, statusToVariant, statusLabel, formatTimestamp, durationClass } from '../../utils/format-utils'
|
||||
|
||||
// Mock data
|
||||
import { routes } from '../../mocks/routes'
|
||||
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||
|
||||
// ─── 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' {
|
||||
switch (status) {
|
||||
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 ────────────────────────────────────────────
|
||||
const EXCHANGE_COLUMNS: Column<Exchange>[] = [
|
||||
{
|
||||
@@ -106,7 +73,7 @@ const EXCHANGE_COLUMNS: Column<Exchange>[] = [
|
||||
header: 'Duration',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}>
|
||||
<MonoText size="sm" className={durationClass(row.durationMs, row.status, styles)}>
|
||||
{formatDuration(row.durationMs)}
|
||||
</MonoText>
|
||||
),
|
||||
|
||||
55
src/utils/format-utils.ts
Normal file
55
src/utils/format-utils.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user