feat: migrate UI to @cameleer/design-system, add backend endpoints
Backend: - Add agent_events table (V5) and lifecycle event recording - Add route catalog endpoint (GET /routes/catalog) - Add route metrics endpoint (GET /routes/metrics) - Add agent events endpoint (GET /agents/events-log) - Enrich AgentInstanceResponse with tps, errorRate, activeRoutes, uptimeSeconds - Add TimescaleDB retention/compression policies (V6) Frontend: - Replace custom Mission Control UI with @cameleer/design-system components - Rebuild all pages: Dashboard, ExchangeDetail, RoutesMetrics, AgentHealth, AgentInstance, RBAC, AuditLog, OIDC, DatabaseAdmin, OpenSearchAdmin, Swagger - New LayoutShell with design system AppShell, Sidebar, TopBar, CommandPalette - Consume design system from Gitea npm registry (@cameleer/design-system@0.0.1) - Add .npmrc for scoped registry, update Dockerfile with REGISTRY_TOKEN arg CI: - Pass REGISTRY_TOKEN build-arg to UI Docker build step Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
84
ui/src/components/LayoutShell.tsx
Normal file
84
ui/src/components/LayoutShell.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router';
|
||||
import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, useCommandPalette } from '@cameleer/design-system';
|
||||
import { useRouteCatalog } from '../api/queries/catalog';
|
||||
import { useAuthStore } from '../auth/auth-store';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import type { SidebarApp } from '@cameleer/design-system';
|
||||
|
||||
function LayoutContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { data: catalog } = useRouteCatalog();
|
||||
const { username, roles } = useAuthStore();
|
||||
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
||||
|
||||
const sidebarApps: SidebarApp[] = useMemo(() => {
|
||||
if (!catalog) return [];
|
||||
return catalog.map((app: any) => ({
|
||||
id: app.appId,
|
||||
name: app.appId,
|
||||
health: app.health as 'live' | 'stale' | 'dead',
|
||||
exchangeCount: app.exchangeCount,
|
||||
routes: (app.routes || []).map((r: any) => ({
|
||||
id: r.routeId,
|
||||
name: r.routeId,
|
||||
exchangeCount: r.exchangeCount,
|
||||
})),
|
||||
agents: (app.agents || []).map((a: any) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
status: a.status as 'live' | 'stale' | 'dead',
|
||||
tps: a.tps,
|
||||
})),
|
||||
}));
|
||||
}, [catalog]);
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
const parts = location.pathname.split('/').filter(Boolean);
|
||||
return parts.map((part, i) => ({
|
||||
label: part,
|
||||
href: '/' + parts.slice(0, i + 1).join('/'),
|
||||
}));
|
||||
}, [location.pathname]);
|
||||
|
||||
const handlePaletteSelect = useCallback((result: any) => {
|
||||
if (result.path) navigate(result.path);
|
||||
setPaletteOpen(false);
|
||||
}, [navigate, setPaletteOpen]);
|
||||
|
||||
const isAdmin = roles.includes('ADMIN');
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
<Sidebar
|
||||
apps={sidebarApps}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TopBar
|
||||
breadcrumb={breadcrumb}
|
||||
user={username ? { name: username } : undefined}
|
||||
/>
|
||||
<CommandPalette
|
||||
open={paletteOpen}
|
||||
onClose={() => setPaletteOpen(false)}
|
||||
onSelect={handlePaletteSelect}
|
||||
data={[]}
|
||||
/>
|
||||
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
export function LayoutShell() {
|
||||
return (
|
||||
<CommandPaletteProvider>
|
||||
<GlobalFilterProvider>
|
||||
<LayoutContent />
|
||||
</GlobalFilterProvider>
|
||||
</CommandPaletteProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--amber-dim);
|
||||
box-shadow: 0 0 0 3px var(--amber-glow);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.btnCancel {
|
||||
padding: 8px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btnCancel:hover {
|
||||
border-color: var(--amber-dim);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btnDelete {
|
||||
padding: 8px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
border: 1px solid var(--rose-dim);
|
||||
color: var(--rose);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btnDelete:hover:not(:disabled) {
|
||||
background: var(--rose-glow);
|
||||
}
|
||||
|
||||
.btnDelete:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import styles from './ConfirmDeleteDialog.module.css';
|
||||
|
||||
interface ConfirmDeleteDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
resourceName: string;
|
||||
resourceType: string;
|
||||
}
|
||||
|
||||
export function ConfirmDeleteDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
resourceName,
|
||||
resourceType,
|
||||
}: ConfirmDeleteDialogProps) {
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const canDelete = confirmText === resourceName;
|
||||
|
||||
function handleClose() {
|
||||
setConfirmText('');
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
if (!canDelete) return;
|
||||
setConfirmText('');
|
||||
onConfirm();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.overlay} onClick={handleClose}>
|
||||
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className={styles.title}>Confirm Deletion</h3>
|
||||
<p className={styles.message}>
|
||||
Delete {resourceType} ‘{resourceName}’? This cannot be undone.
|
||||
</p>
|
||||
<label className={styles.label}>
|
||||
Type <strong>{resourceName}</strong> to confirm:
|
||||
</label>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder={resourceName}
|
||||
autoFocus
|
||||
/>
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.btnCancel} onClick={handleClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.btnDelete}
|
||||
onClick={handleConfirm}
|
||||
disabled={!canDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.headerClickable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.headerClickable:hover {
|
||||
background: var(--bg-hover);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.autoIndicator {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 99px;
|
||||
padding: 1px 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.refreshBtn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.refreshBtn:hover {
|
||||
border-color: var(--amber-dim);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.refreshBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.refreshing {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import styles from './RefreshableCard.module.css';
|
||||
|
||||
interface RefreshableCardProps {
|
||||
title: string;
|
||||
onRefresh?: () => void;
|
||||
isRefreshing?: boolean;
|
||||
autoRefresh?: boolean;
|
||||
collapsible?: boolean;
|
||||
defaultCollapsed?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RefreshableCard({
|
||||
title,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
autoRefresh,
|
||||
collapsible,
|
||||
defaultCollapsed,
|
||||
children,
|
||||
}: RefreshableCardProps) {
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
|
||||
|
||||
const headerProps = collapsible
|
||||
? {
|
||||
onClick: () => setCollapsed((c) => !c),
|
||||
className: `${styles.header} ${styles.headerClickable}`,
|
||||
role: 'button' as const,
|
||||
tabIndex: 0,
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setCollapsed((c) => !c);
|
||||
}
|
||||
},
|
||||
}
|
||||
: { className: styles.header };
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div {...headerProps}>
|
||||
<div className={styles.titleRow}>
|
||||
{collapsible && (
|
||||
<span className={`${styles.chevron} ${collapsed ? '' : styles.chevronOpen}`}>
|
||||
▶
|
||||
</span>
|
||||
)}
|
||||
<h3 className={styles.title}>{title}</h3>
|
||||
{autoRefresh && <span className={styles.autoIndicator}>auto</span>}
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.refreshBtn} ${isRefreshing ? styles.refreshing : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRefresh();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && <div className={styles.body}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.healthy {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: #eab308;
|
||||
}
|
||||
|
||||
.critical {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.unknown {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import styles from './StatusBadge.module.css';
|
||||
|
||||
export type Status = 'healthy' | 'warning' | 'critical' | 'unknown';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: Status;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, label }: StatusBadgeProps) {
|
||||
return (
|
||||
<span className={styles.badge}>
|
||||
<span className={`${styles.dot} ${styles[status]}`} />
|
||||
{label && <span className={styles.label}>{label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useRef, useEffect, useMemo } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import { baseOpts, chartColors } from './theme';
|
||||
import type { TimeseriesBucket } from '../../api/types';
|
||||
|
||||
interface DurationHistogramProps {
|
||||
buckets: TimeseriesBucket[];
|
||||
}
|
||||
|
||||
export function DurationHistogram({ buckets }: DurationHistogramProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<uPlot | null>(null);
|
||||
|
||||
// Build histogram bins from avg durations
|
||||
const histData = useMemo(() => {
|
||||
const durations = buckets.map((b) => b.avgDurationMs ?? 0).filter((d) => d > 0);
|
||||
if (durations.length < 2) return null;
|
||||
|
||||
const min = Math.min(...durations);
|
||||
const max = Math.max(...durations);
|
||||
const range = max - min || 1;
|
||||
const binCount = Math.min(20, durations.length);
|
||||
const binSize = range / binCount;
|
||||
|
||||
const bins = new Array(binCount).fill(0);
|
||||
const labels = new Array(binCount).fill(0);
|
||||
for (let i = 0; i < binCount; i++) {
|
||||
labels[i] = Math.round(min + binSize * i + binSize / 2);
|
||||
}
|
||||
for (const d of durations) {
|
||||
const idx = Math.min(Math.floor((d - min) / binSize), binCount - 1);
|
||||
bins[idx]++;
|
||||
}
|
||||
|
||||
return { xs: labels, counts: bins };
|
||||
}, [buckets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !histData) return;
|
||||
const el = containerRef.current;
|
||||
const w = el.clientWidth || 600;
|
||||
|
||||
const opts: uPlot.Options = {
|
||||
...baseOpts(w, 220),
|
||||
width: w,
|
||||
height: 220,
|
||||
scales: {
|
||||
x: { time: false },
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
stroke: chartColors.axis,
|
||||
grid: { show: false },
|
||||
ticks: { show: false },
|
||||
font: '11px JetBrains Mono, monospace',
|
||||
gap: 8,
|
||||
values: (_, ticks) => ticks.map((v) => `${Math.round(v)}ms`),
|
||||
},
|
||||
{
|
||||
stroke: chartColors.axis,
|
||||
grid: { stroke: chartColors.grid, width: 1, dash: [2, 4] },
|
||||
ticks: { show: false },
|
||||
font: '11px JetBrains Mono, monospace',
|
||||
size: 40,
|
||||
gap: 8,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{ label: 'Duration (ms)' },
|
||||
{
|
||||
label: 'Count',
|
||||
stroke: chartColors.cyan,
|
||||
fill: `${chartColors.cyan}30`,
|
||||
width: 2,
|
||||
paths: (u, seriesIdx, idx0, idx1) => {
|
||||
const path = new Path2D();
|
||||
const fillPath = new Path2D();
|
||||
const barWidth = Math.max(2, (u.bbox.width / (idx1 - idx0 + 1)) * 0.7);
|
||||
const yBase = u.valToPos(0, 'y', true);
|
||||
|
||||
fillPath.moveTo(u.valToPos(0, 'x', true), yBase);
|
||||
for (let i = idx0; i <= idx1; i++) {
|
||||
const x = u.valToPos(u.data[0][i], 'x', true) - barWidth / 2;
|
||||
const y = u.valToPos(u.data[seriesIdx][i] ?? 0, 'y', true);
|
||||
path.rect(x, y, barWidth, yBase - y);
|
||||
fillPath.rect(x, y, barWidth, yBase - y);
|
||||
}
|
||||
|
||||
return { stroke: path, fill: fillPath };
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = new uPlot(opts, [histData.xs, histData.counts], el);
|
||||
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, [histData]);
|
||||
|
||||
if (!histData) return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Not enough data for histogram</div>;
|
||||
|
||||
return <div ref={containerRef} />;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import { baseOpts, chartColors } from './theme';
|
||||
import type { TimeseriesBucket } from '../../api/types';
|
||||
|
||||
interface LatencyHeatmapProps {
|
||||
buckets: TimeseriesBucket[];
|
||||
}
|
||||
|
||||
export function LatencyHeatmap({ buckets }: LatencyHeatmapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<uPlot | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || buckets.length < 2) return;
|
||||
const el = containerRef.current;
|
||||
const w = el.clientWidth || 600;
|
||||
|
||||
const xs = buckets.map((b) => new Date(b.time!).getTime() / 1000);
|
||||
const avgDurations = buckets.map((b) => b.avgDurationMs ?? 0);
|
||||
const p99Durations = buckets.map((b) => b.p99DurationMs ?? 0);
|
||||
|
||||
const opts: uPlot.Options = {
|
||||
...baseOpts(w, 220),
|
||||
width: w,
|
||||
height: 220,
|
||||
series: [
|
||||
{ label: 'Time' },
|
||||
{
|
||||
label: 'Avg Duration',
|
||||
stroke: chartColors.cyan,
|
||||
width: 2,
|
||||
dash: [4, 2],
|
||||
},
|
||||
{
|
||||
label: 'P99 Duration',
|
||||
stroke: chartColors.amber,
|
||||
fill: `${chartColors.amber}15`,
|
||||
width: 2,
|
||||
},
|
||||
],
|
||||
axes: [
|
||||
{
|
||||
stroke: chartColors.axis,
|
||||
grid: { show: false },
|
||||
ticks: { show: false },
|
||||
font: '11px JetBrains Mono, monospace',
|
||||
gap: 8,
|
||||
},
|
||||
{
|
||||
stroke: chartColors.axis,
|
||||
grid: { stroke: chartColors.grid, width: 1, dash: [2, 4] },
|
||||
ticks: { show: false },
|
||||
font: '11px JetBrains Mono, monospace',
|
||||
size: 50,
|
||||
gap: 8,
|
||||
values: (_, ticks) => ticks.map((v) => `${Math.round(v)}ms`),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = new uPlot(opts, [xs, avgDurations, p99Durations], el);
|
||||
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, [buckets]);
|
||||
|
||||
if (buckets.length < 2) return null;
|
||||
|
||||
return <div ref={containerRef} />;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useRef, useEffect, useMemo } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import { sparkOpts, accentHex } from './theme';
|
||||
|
||||
interface MiniChartProps {
|
||||
data: number[];
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function MiniChart({ data, color }: MiniChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<uPlot | null>(null);
|
||||
|
||||
// Trim first/last buckets (partial time windows) like the old Sparkline
|
||||
const trimmed = useMemo(() => (data.length > 4 ? data.slice(1, -1) : data), [data]);
|
||||
|
||||
const resolvedColor = color.startsWith('#') || color.startsWith('rgb')
|
||||
? color
|
||||
: accentHex(color);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || trimmed.length < 2) return;
|
||||
|
||||
const el = containerRef.current;
|
||||
const w = el.clientWidth || 200;
|
||||
const h = 24;
|
||||
|
||||
// x-axis: simple index values
|
||||
const xs = Float64Array.from(trimmed, (_, i) => i);
|
||||
const ys = Float64Array.from(trimmed);
|
||||
|
||||
const opts: uPlot.Options = {
|
||||
...sparkOpts(w, h),
|
||||
width: w,
|
||||
height: h,
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
stroke: resolvedColor,
|
||||
width: 1.5,
|
||||
fill: `${resolvedColor}30`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
}
|
||||
|
||||
chartRef.current = new uPlot(opts, [xs as unknown as number[], ys as unknown as number[]], el);
|
||||
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, [trimmed, resolvedColor]);
|
||||
|
||||
if (trimmed.length < 2) return null;
|
||||
|
||||
return <div ref={containerRef} style={{ marginTop: 10, height: 24, width: '100%' }} />;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import { baseOpts, chartColors } from './theme';
|
||||
import type { TimeseriesBucket } from '../../api/types';
|
||||
|
||||
interface ThroughputChartProps {
|
||||
buckets: TimeseriesBucket[];
|
||||
}
|
||||
|
||||
export function ThroughputChart({ buckets }: ThroughputChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<uPlot | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || buckets.length < 2) return;
|
||||
const el = containerRef.current;
|
||||
const w = el.clientWidth || 600;
|
||||
|
||||
const xs = buckets.map((b) => new Date(b.time!).getTime() / 1000);
|
||||
const totals = buckets.map((b) => b.totalCount ?? 0);
|
||||
const failed = buckets.map((b) => b.failedCount ?? 0);
|
||||
|
||||
const opts: uPlot.Options = {
|
||||
...baseOpts(w, 220),
|
||||
width: w,
|
||||
height: 220,
|
||||
series: [
|
||||
{ label: 'Time' },
|
||||
{
|
||||
label: 'Total',
|
||||
stroke: chartColors.amber,
|
||||
fill: `${chartColors.amber}20`,
|
||||
width: 2,
|
||||
},
|
||||
{
|
||||
label: 'Failed',
|
||||
stroke: chartColors.rose,
|
||||
fill: `${chartColors.rose}20`,
|
||||
width: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = new uPlot(opts, [xs, totals, failed], el);
|
||||
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, [buckets]);
|
||||
|
||||
if (buckets.length < 2) return null;
|
||||
|
||||
return <div ref={containerRef} />;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
/** Shared uPlot color tokens matching Cameleer3 design system */
|
||||
export const chartColors = {
|
||||
amber: '#f0b429',
|
||||
cyan: '#22d3ee',
|
||||
rose: '#f43f5e',
|
||||
green: '#10b981',
|
||||
blue: '#3b82f6',
|
||||
purple: '#a855f7',
|
||||
grid: 'rgba(30, 45, 61, 0.18)',
|
||||
axis: '#4a5e7a',
|
||||
text: '#8b9cb6',
|
||||
bg: '#111827',
|
||||
cursor: 'rgba(240, 180, 41, 0.15)',
|
||||
} as const;
|
||||
|
||||
export type AccentColor = keyof typeof chartColors;
|
||||
|
||||
/** Resolve an accent name to a CSS hex color */
|
||||
export function accentHex(accent: string): string {
|
||||
return (chartColors as Record<string, string>)[accent] ?? chartColors.amber;
|
||||
}
|
||||
|
||||
/** Base uPlot options shared across all Cameleer3 charts */
|
||||
export function baseOpts(width: number, height: number): Partial<uPlot.Options> {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
cursor: {
|
||||
show: true,
|
||||
x: true,
|
||||
y: false,
|
||||
},
|
||||
legend: { show: false },
|
||||
axes: [
|
||||
{
|
||||
stroke: chartColors.axis,
|
||||
grid: { show: false },
|
||||
ticks: { show: false },
|
||||
font: '11px JetBrains Mono, monospace',
|
||||
gap: 8,
|
||||
},
|
||||
{
|
||||
stroke: chartColors.axis,
|
||||
grid: { stroke: chartColors.grid, width: 1, dash: [2, 4] },
|
||||
ticks: { show: false },
|
||||
font: '11px JetBrains Mono, monospace',
|
||||
size: 50,
|
||||
gap: 8,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Mini sparkline chart options (no axes, no cursor) */
|
||||
export function sparkOpts(width: number, height: number): Partial<uPlot.Options> {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
cursor: { show: false },
|
||||
legend: { show: false },
|
||||
axes: [
|
||||
{ show: false },
|
||||
{ show: false },
|
||||
],
|
||||
scales: {
|
||||
x: { time: false },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,495 +0,0 @@
|
||||
/* ── Overlay ── */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(6, 10, 19, 0.75);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 12vh;
|
||||
animation: fadeIn 0.12s ease-out;
|
||||
}
|
||||
|
||||
[data-theme="light"] .overlay {
|
||||
background: rgba(247, 245, 242, 0.75);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(16px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes slideInResult {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal {
|
||||
width: 680px;
|
||||
max-height: 520px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 16px 72px rgba(0, 0, 0, 0.5), 0 0 40px rgba(240, 180, 41, 0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: slideUp 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* ── Input Area ── */
|
||||
.inputWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--amber);
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 0 6px var(--amber-glow));
|
||||
}
|
||||
|
||||
.chipList {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
background: var(--amber-glow);
|
||||
color: var(--amber);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.chipKey {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chipRemove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--amber);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
padding: 0 0 0 2px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.chipRemove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 16px;
|
||||
font-family: var(--font-body);
|
||||
color: var(--text-primary);
|
||||
caret-color: var(--amber);
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.inputHint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ── Scope Tabs ── */
|
||||
.scopeTabs {
|
||||
display: flex;
|
||||
padding: 8px 18px 0;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.scopeTab {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
border: none;
|
||||
background: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scopeTab:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.scopeTabActive {
|
||||
composes: scopeTab;
|
||||
color: var(--amber);
|
||||
border-bottom-color: var(--amber);
|
||||
}
|
||||
|
||||
.scopeCount {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
background: var(--bg-raised);
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.scopeTabActive .scopeCount {
|
||||
background: var(--amber-glow);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.scopeTabDisabled {
|
||||
composes: scopeTab;
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Results ── */
|
||||
.results {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 8px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
.results::-webkit-scrollbar { width: 6px; }
|
||||
.results::-webkit-scrollbar-track { background: transparent; }
|
||||
.results::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
|
||||
.groupLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
color: var(--text-muted);
|
||||
padding: 10px 12px 4px;
|
||||
}
|
||||
|
||||
.resultItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
animation: slideInResult 0.2s ease-out both;
|
||||
}
|
||||
|
||||
.resultItem:nth-child(2) { animation-delay: 0.03s; }
|
||||
.resultItem:nth-child(3) { animation-delay: 0.06s; }
|
||||
.resultItem:nth-child(4) { animation-delay: 0.09s; }
|
||||
.resultItem:nth-child(5) { animation-delay: 0.12s; }
|
||||
|
||||
.resultItem:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.resultItemSelected {
|
||||
composes: resultItem;
|
||||
background: var(--amber-glow);
|
||||
outline: 1px solid rgba(240, 180, 41, 0.2);
|
||||
}
|
||||
|
||||
.resultItemSelected:hover {
|
||||
background: var(--amber-glow);
|
||||
}
|
||||
|
||||
/* ── Result Icon ── */
|
||||
.resultIcon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.resultIcon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.iconExecution {
|
||||
composes: resultIcon;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.iconAgent {
|
||||
composes: resultIcon;
|
||||
background: var(--green-glow);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.iconError {
|
||||
composes: resultIcon;
|
||||
background: var(--rose-glow);
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
.iconRoute {
|
||||
composes: resultIcon;
|
||||
background: rgba(168, 85, 247, 0.12);
|
||||
color: var(--purple);
|
||||
}
|
||||
|
||||
/* ── Result Body ── */
|
||||
.resultBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.resultTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: var(--amber);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.resultMeta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sep {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Badges ── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badgeCompleted {
|
||||
composes: badge;
|
||||
background: var(--green-glow);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.badgeFailed {
|
||||
composes: badge;
|
||||
background: var(--rose-glow);
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
.badgeRunning {
|
||||
composes: badge;
|
||||
background: rgba(240, 180, 41, 0.12);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.badgeDuration {
|
||||
composes: badge;
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.badgeRoute {
|
||||
composes: badge;
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
color: var(--purple);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
}
|
||||
|
||||
.badgeLive {
|
||||
composes: badge;
|
||||
background: var(--green-glow);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.badgeStale {
|
||||
composes: badge;
|
||||
background: rgba(240, 180, 41, 0.12);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.badgeDead {
|
||||
composes: badge;
|
||||
background: var(--rose-glow);
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
.resultRight {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.resultTime {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Empty / Loading ── */
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
color: var(--text-muted);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.emptyHint {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.loadingDots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 24px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loadingDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.loadingDot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.loadingDot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 18px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--bg-raised);
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
}
|
||||
|
||||
.footerHints {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.footerHint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.footerBrand {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 768px) {
|
||||
.modal {
|
||||
width: calc(100vw - 32px);
|
||||
max-height: 70vh;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useCommandPalette } from './use-command-palette';
|
||||
|
||||
/**
|
||||
* Headless component: only registers the global Cmd+K / Ctrl+K keyboard shortcut.
|
||||
* The palette UI itself is rendered inline within SearchFilters.
|
||||
*/
|
||||
export function CommandPalette() {
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
const store = useCommandPalette.getState();
|
||||
if (store.isOpen) {
|
||||
store.close();
|
||||
store.reset();
|
||||
} else {
|
||||
store.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import styles from './CommandPalette.module.css';
|
||||
|
||||
export function PaletteFooter() {
|
||||
return (
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.footerHints}>
|
||||
<span className={styles.footerHint}>
|
||||
<kbd className={styles.kbd}>↑</kbd>
|
||||
<kbd className={styles.kbd}>↓</kbd> navigate
|
||||
</span>
|
||||
<span className={styles.footerHint}>
|
||||
<kbd className={styles.kbd}>↵</kbd> open
|
||||
</span>
|
||||
<span className={styles.footerHint}>
|
||||
<kbd className={styles.kbd}>tab</kbd> scope
|
||||
</span>
|
||||
<span className={styles.footerHint}>
|
||||
<kbd className={styles.kbd}>esc</kbd> close
|
||||
</span>
|
||||
</div>
|
||||
<span className={styles.footerBrand}>cameleer3</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useCommandPalette } from './use-command-palette';
|
||||
import { parseFilterPrefix, checkTrailingFilter } from './utils';
|
||||
import styles from './CommandPalette.module.css';
|
||||
|
||||
export function PaletteInput() {
|
||||
const { query, filters, setQuery, addFilter, removeLastFilter, removeFilter } =
|
||||
useCommandPalette();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
function handleChange(value: string) {
|
||||
// Check if user typed a filter prefix like "status:failed "
|
||||
const parsed = parseFilterPrefix(value);
|
||||
if (parsed) {
|
||||
addFilter(parsed.filter);
|
||||
setQuery(parsed.remaining);
|
||||
return;
|
||||
}
|
||||
const trailing = checkTrailingFilter(value);
|
||||
if (trailing) {
|
||||
addFilter(trailing);
|
||||
setQuery('');
|
||||
return;
|
||||
}
|
||||
setQuery(value);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Backspace' && query === '' && filters.length > 0) {
|
||||
e.preventDefault();
|
||||
removeLastFilter();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.inputWrap}>
|
||||
<svg className={styles.searchIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
{filters.length > 0 && (
|
||||
<div className={styles.chipList}>
|
||||
{filters.map((f, i) => (
|
||||
<span key={f.key} className={styles.chip}>
|
||||
<span className={styles.chipKey}>{f.key}:</span>
|
||||
{f.value}
|
||||
<button className={styles.chipRemove} onClick={() => removeFilter(i)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={styles.input}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={filters.length > 0 ? 'Refine search...' : 'Search executions, agents...'}
|
||||
/>
|
||||
<div className={styles.inputHint}>
|
||||
<kbd className={styles.kbd}>esc</kbd> close
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import type { ExecutionSummary, AgentInstance } from '../../api/types';
|
||||
import type { PaletteResult, RouteInfo } from './use-palette-search';
|
||||
import { highlightMatch, formatRelativeTime } from './utils';
|
||||
import { AppBadge } from '../shared/AppBadge';
|
||||
import styles from './CommandPalette.module.css';
|
||||
|
||||
interface ResultItemProps {
|
||||
result: PaletteResult;
|
||||
selected: boolean;
|
||||
query: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function HighlightedText({ text, query }: { text: string; query: string }) {
|
||||
const parts = highlightMatch(text, query);
|
||||
return (
|
||||
<>
|
||||
{parts.map((p, i) =>
|
||||
typeof p === 'string' ? (
|
||||
<span key={i}>{p}</span>
|
||||
) : (
|
||||
<span key={i} className={styles.highlight}>{p.highlight}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
switch (status.toUpperCase()) {
|
||||
case 'COMPLETED': return styles.badgeCompleted;
|
||||
case 'FAILED': return styles.badgeFailed;
|
||||
case 'RUNNING': return styles.badgeRunning;
|
||||
default: return styles.badge;
|
||||
}
|
||||
}
|
||||
|
||||
function stateBadgeClass(state: string): string {
|
||||
switch (state) {
|
||||
case 'LIVE': return styles.badgeLive;
|
||||
case 'STALE': return styles.badgeStale;
|
||||
case 'DEAD': return styles.badgeDead;
|
||||
default: return styles.badge;
|
||||
}
|
||||
}
|
||||
|
||||
function ExecutionResult({ data, query }: { data: ExecutionSummary; query: string }) {
|
||||
const isFailed = data.status === 'FAILED';
|
||||
return (
|
||||
<>
|
||||
<div className={isFailed ? styles.iconError : styles.iconExecution}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className={styles.resultBody}>
|
||||
<div className={styles.resultTitle}>
|
||||
<HighlightedText text={data.routeId} query={query} />
|
||||
<span className={statusBadgeClass(data.status)}>{data.status}</span>
|
||||
<span className={styles.badgeDuration}>{data.durationMs}ms</span>
|
||||
</div>
|
||||
<div className={styles.resultMeta}>
|
||||
<AppBadge name={data.agentId} />
|
||||
<span className={styles.sep} />
|
||||
<HighlightedText text={data.executionId.slice(0, 16)} query={query} />
|
||||
{data.errorMessage && (
|
||||
<>
|
||||
<span className={styles.sep} />
|
||||
<span style={{ color: 'var(--rose)' }}>
|
||||
{data.errorMessage.slice(0, 60)}
|
||||
{data.errorMessage.length > 60 ? '...' : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.resultRight}>
|
||||
<span className={styles.resultTime}>{formatRelativeTime(data.startTime)}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ApplicationResult({ data, query }: { data: AgentInstance; query: string }) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.iconAgent}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
|
||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className={styles.resultBody}>
|
||||
<div className={styles.resultTitle}>
|
||||
<HighlightedText text={data.id} query={query} />
|
||||
<span className={stateBadgeClass(data.status)}>{data.status}</span>
|
||||
</div>
|
||||
<div className={styles.resultMeta}>
|
||||
<span>group: {data.group}</span>
|
||||
<span className={styles.sep} />
|
||||
<span>last heartbeat: {formatRelativeTime(data.lastHeartbeat)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.resultRight}>
|
||||
<span className={styles.resultTime}>Application</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteResult({ data, query }: { data: RouteInfo; query: string }) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.iconRoute}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="6" cy="19" r="3" />
|
||||
<path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15" />
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className={styles.resultBody}>
|
||||
<div className={styles.resultTitle}>
|
||||
<HighlightedText text={data.routeId} query={query} />
|
||||
</div>
|
||||
<div className={styles.resultMeta}>
|
||||
<span>{data.agentIds.length} {data.agentIds.length === 1 ? 'application' : 'applications'}</span>
|
||||
<span className={styles.sep} />
|
||||
{data.agentIds.map((id) => <AppBadge key={id} name={id} />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.resultRight}>
|
||||
<span className={styles.resultTime}>Route</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResultItem({ result, selected, query, onClick }: ResultItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={selected ? styles.resultItemSelected : styles.resultItem}
|
||||
onClick={onClick}
|
||||
data-palette-item
|
||||
>
|
||||
{result.type === 'execution' && (
|
||||
<ExecutionResult data={result.data as ExecutionSummary} query={query} />
|
||||
)}
|
||||
{result.type === 'application' && (
|
||||
<ApplicationResult data={result.data as AgentInstance} query={query} />
|
||||
)}
|
||||
{result.type === 'route' && (
|
||||
<RouteResult data={result.data as RouteInfo} query={query} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useCommandPalette } from './use-command-palette';
|
||||
import type { PaletteResult } from './use-palette-search';
|
||||
import { ResultItem } from './ResultItem';
|
||||
import styles from './CommandPalette.module.css';
|
||||
|
||||
interface ResultsListProps {
|
||||
results: PaletteResult[];
|
||||
isLoading: boolean;
|
||||
onSelect: (result: PaletteResult) => void;
|
||||
}
|
||||
|
||||
export function ResultsList({ results, isLoading, onSelect }: ResultsListProps) {
|
||||
const { selectedIndex, query } = useCommandPalette();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const items = listRef.current?.querySelectorAll('[data-palette-item]');
|
||||
items?.[selectedIndex]?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (isLoading && results.length === 0) {
|
||||
return (
|
||||
<div className={styles.results}>
|
||||
<div className={styles.loadingDots}>
|
||||
<div className={styles.loadingDot} />
|
||||
<div className={styles.loadingDot} />
|
||||
<div className={styles.loadingDot} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className={styles.results}>
|
||||
<div className={styles.emptyState}>
|
||||
<svg className={styles.emptyIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<span className={styles.emptyText}>No results found</span>
|
||||
<span className={styles.emptyHint}>
|
||||
Try a different search or use filters like status:failed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Group results by type
|
||||
const executions = results.filter((r) => r.type === 'execution');
|
||||
const applications = results.filter((r) => r.type === 'application');
|
||||
const routes = results.filter((r) => r.type === 'route');
|
||||
|
||||
let globalIndex = 0;
|
||||
|
||||
return (
|
||||
<div className={styles.results} ref={listRef}>
|
||||
{executions.length > 0 && (
|
||||
<>
|
||||
<div className={styles.groupLabel}>Executions</div>
|
||||
{executions.map((r) => {
|
||||
const idx = globalIndex++;
|
||||
return (
|
||||
<ResultItem
|
||||
key={r.id}
|
||||
result={r}
|
||||
selected={idx === selectedIndex}
|
||||
query={query}
|
||||
onClick={() => onSelect(r)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{applications.length > 0 && (
|
||||
<>
|
||||
<div className={styles.groupLabel}>Applications</div>
|
||||
{applications.map((r) => {
|
||||
const idx = globalIndex++;
|
||||
return (
|
||||
<ResultItem
|
||||
key={r.id}
|
||||
result={r}
|
||||
selected={idx === selectedIndex}
|
||||
query={query}
|
||||
onClick={() => onSelect(r)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{routes.length > 0 && (
|
||||
<>
|
||||
<div className={styles.groupLabel}>Routes</div>
|
||||
{routes.map((r) => {
|
||||
const idx = globalIndex++;
|
||||
return (
|
||||
<ResultItem
|
||||
key={r.id}
|
||||
result={r}
|
||||
selected={idx === selectedIndex}
|
||||
query={query}
|
||||
onClick={() => onSelect(r)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useCommandPalette, type PaletteScope } from './use-command-palette';
|
||||
import styles from './CommandPalette.module.css';
|
||||
|
||||
interface ScopeTabsProps {
|
||||
executionCount: number;
|
||||
applicationCount: number;
|
||||
routeCount: number;
|
||||
}
|
||||
|
||||
const SCOPES: { key: PaletteScope; label: string }[] = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'executions', label: 'Executions' },
|
||||
{ key: 'applications', label: 'Applications' },
|
||||
{ key: 'routes', label: 'Routes' },
|
||||
];
|
||||
|
||||
export function ScopeTabs({ executionCount, applicationCount, routeCount }: ScopeTabsProps) {
|
||||
const { scope, setScope } = useCommandPalette();
|
||||
|
||||
function getCount(key: PaletteScope): number {
|
||||
if (key === 'all') return executionCount + applicationCount + routeCount;
|
||||
if (key === 'executions') return executionCount;
|
||||
if (key === 'applications') return applicationCount;
|
||||
if (key === 'routes') return routeCount;
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.scopeTabs}>
|
||||
{SCOPES.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
className={scope === s.key ? styles.scopeTabActive : styles.scopeTab}
|
||||
onClick={() => setScope(s.key)}
|
||||
>
|
||||
{s.label}
|
||||
<span className={styles.scopeCount}>{getCount(s.key)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type PaletteScope = 'all' | 'executions' | 'applications' | 'routes';
|
||||
|
||||
export interface PaletteFilter {
|
||||
key: 'status' | 'route' | 'agent' | 'processor';
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface CommandPaletteState {
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
scope: PaletteScope;
|
||||
filters: PaletteFilter[];
|
||||
selectedIndex: number;
|
||||
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
setQuery: (q: string) => void;
|
||||
setScope: (s: PaletteScope) => void;
|
||||
addFilter: (f: PaletteFilter) => void;
|
||||
removeLastFilter: () => void;
|
||||
removeFilter: (index: number) => void;
|
||||
setSelectedIndex: (i: number) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useCommandPalette = create<CommandPaletteState>((set) => ({
|
||||
isOpen: false,
|
||||
query: '',
|
||||
scope: 'all',
|
||||
filters: [],
|
||||
selectedIndex: 0,
|
||||
|
||||
open: () => set({ isOpen: true }),
|
||||
close: () => set({ isOpen: false, selectedIndex: 0 }),
|
||||
setQuery: (q) => set({ query: q, selectedIndex: 0 }),
|
||||
setScope: (s) => set({ scope: s, selectedIndex: 0 }),
|
||||
addFilter: (f) =>
|
||||
set((state) => ({
|
||||
filters: [...state.filters.filter((x) => x.key !== f.key), f],
|
||||
query: '',
|
||||
selectedIndex: 0,
|
||||
})),
|
||||
removeLastFilter: () =>
|
||||
set((state) => ({
|
||||
filters: state.filters.slice(0, -1),
|
||||
selectedIndex: 0,
|
||||
})),
|
||||
removeFilter: (index) =>
|
||||
set((state) => ({
|
||||
filters: state.filters.filter((_, i) => i !== index),
|
||||
selectedIndex: 0,
|
||||
})),
|
||||
setSelectedIndex: (i) => set({ selectedIndex: i }),
|
||||
reset: () => set({ query: '', scope: 'all', filters: [], selectedIndex: 0 }),
|
||||
}));
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../../api/client';
|
||||
import type { ExecutionSummary, AgentInstance } from '../../api/types';
|
||||
import { useCommandPalette, type PaletteScope } from './use-command-palette';
|
||||
import { useDebouncedValue } from './utils';
|
||||
|
||||
export interface RouteInfo {
|
||||
routeId: string;
|
||||
agentIds: string[];
|
||||
}
|
||||
|
||||
export interface PaletteResult {
|
||||
type: 'execution' | 'application' | 'route';
|
||||
id: string;
|
||||
data: ExecutionSummary | AgentInstance | RouteInfo;
|
||||
}
|
||||
|
||||
function isExecutionScope(scope: PaletteScope) {
|
||||
return scope === 'all' || scope === 'executions';
|
||||
}
|
||||
|
||||
function isApplicationScope(scope: PaletteScope) {
|
||||
return scope === 'all' || scope === 'applications';
|
||||
}
|
||||
|
||||
function isRouteScope(scope: PaletteScope) {
|
||||
return scope === 'all' || scope === 'routes';
|
||||
}
|
||||
|
||||
export function usePaletteSearch() {
|
||||
const { query, scope, filters, isOpen } = useCommandPalette();
|
||||
const debouncedQuery = useDebouncedValue(query, 300);
|
||||
|
||||
const statusFilter = filters.find((f) => f.key === 'status')?.value;
|
||||
const routeFilter = filters.find((f) => f.key === 'route')?.value;
|
||||
const agentFilter = filters.find((f) => f.key === 'agent')?.value;
|
||||
const processorFilter = filters.find((f) => f.key === 'processor')?.value;
|
||||
|
||||
const executionsQuery = useQuery({
|
||||
queryKey: ['palette', 'executions', debouncedQuery, statusFilter, routeFilter, agentFilter, processorFilter],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.POST('/search/executions', {
|
||||
body: {
|
||||
text: debouncedQuery || undefined,
|
||||
status: statusFilter || undefined,
|
||||
routeId: routeFilter || undefined,
|
||||
agentId: agentFilter || undefined,
|
||||
processorType: processorFilter || undefined,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
if (error) throw new Error('Search failed');
|
||||
return data!;
|
||||
},
|
||||
enabled: isOpen && isExecutionScope(scope),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ['agents'],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET('/agents', {
|
||||
params: { query: {} },
|
||||
});
|
||||
if (error) throw new Error('Failed to load agents');
|
||||
return data!;
|
||||
},
|
||||
enabled: isOpen && (isApplicationScope(scope) || isRouteScope(scope)),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const executionResults: PaletteResult[] = (executionsQuery.data?.data ?? []).map((e) => ({
|
||||
type: 'execution' as const,
|
||||
id: e.executionId,
|
||||
data: e,
|
||||
}));
|
||||
|
||||
const filteredAgents = (agentsQuery.data ?? []).filter((a) => {
|
||||
if (!debouncedQuery) return true;
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
return a.id.toLowerCase().includes(q) || a.group.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
const applicationResults: PaletteResult[] = filteredAgents.slice(0, 10).map((a) => ({
|
||||
type: 'application' as const,
|
||||
id: a.id,
|
||||
data: a,
|
||||
}));
|
||||
|
||||
// Derive unique routes from all agents
|
||||
const routeMap = new Map<string, string[]>();
|
||||
for (const agent of agentsQuery.data ?? []) {
|
||||
for (const routeId of agent.routeIds ?? []) {
|
||||
const existing = routeMap.get(routeId);
|
||||
if (existing) {
|
||||
if (!existing.includes(agent.id)) existing.push(agent.id);
|
||||
} else {
|
||||
routeMap.set(routeId, [agent.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allRoutes: RouteInfo[] = Array.from(routeMap.entries()).map(([routeId, agentIds]) => ({
|
||||
routeId,
|
||||
agentIds,
|
||||
}));
|
||||
|
||||
const filteredRoutes = allRoutes.filter((r) => {
|
||||
if (!debouncedQuery) return true;
|
||||
const q = debouncedQuery.toLowerCase();
|
||||
return r.routeId.toLowerCase().includes(q) || r.agentIds.some((a) => a.toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const routeResults: PaletteResult[] = filteredRoutes.slice(0, 10).map((r) => ({
|
||||
type: 'route' as const,
|
||||
id: r.routeId,
|
||||
data: r,
|
||||
}));
|
||||
|
||||
let results: PaletteResult[] = [];
|
||||
if (scope === 'all') results = [...executionResults, ...applicationResults, ...routeResults];
|
||||
else if (scope === 'executions') results = executionResults;
|
||||
else if (scope === 'applications') results = applicationResults;
|
||||
else if (scope === 'routes') results = routeResults;
|
||||
|
||||
return {
|
||||
results,
|
||||
executionCount: executionsQuery.data?.total ?? 0,
|
||||
applicationCount: filteredAgents.length,
|
||||
routeCount: filteredRoutes.length,
|
||||
isLoading: executionsQuery.isFetching || agentsQuery.isFetching,
|
||||
};
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { PaletteFilter } from './use-command-palette';
|
||||
|
||||
const FILTER_PREFIXES = ['status:', 'route:', 'agent:', 'processor:'] as const;
|
||||
|
||||
type FilterKey = PaletteFilter['key'];
|
||||
|
||||
const PREFIX_TO_KEY: Record<string, FilterKey> = {
|
||||
'status:': 'status',
|
||||
'route:': 'route',
|
||||
'agent:': 'agent',
|
||||
'processor:': 'processor',
|
||||
};
|
||||
|
||||
export function parseFilterPrefix(
|
||||
input: string,
|
||||
): { filter: PaletteFilter; remaining: string } | null {
|
||||
for (const prefix of FILTER_PREFIXES) {
|
||||
if (input.startsWith(prefix)) {
|
||||
const value = input.slice(prefix.length).trim();
|
||||
if (value && value.includes(' ')) {
|
||||
const spaceIdx = value.indexOf(' ');
|
||||
return {
|
||||
filter: { key: PREFIX_TO_KEY[prefix], value: value.slice(0, spaceIdx) },
|
||||
remaining: value.slice(spaceIdx + 1).trim(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function checkTrailingFilter(input: string): PaletteFilter | null {
|
||||
for (const prefix of FILTER_PREFIXES) {
|
||||
if (input.endsWith(' ') && input.trimEnd().length > prefix.length) {
|
||||
const trimmed = input.trimEnd();
|
||||
for (const p of FILTER_PREFIXES) {
|
||||
const idx = trimmed.lastIndexOf(p);
|
||||
if (idx !== -1 && idx === trimmed.length - p.length - (trimmed.length - trimmed.lastIndexOf(p) - p.length)) {
|
||||
// This is getting complex, let's use a simpler approach
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Simple approach: check if last word matches prefix:value pattern
|
||||
const words = input.trimEnd().split(/\s+/);
|
||||
const lastWord = words[words.length - 1];
|
||||
for (const prefix of FILTER_PREFIXES) {
|
||||
if (lastWord.startsWith(prefix) && lastWord.length > prefix.length && input.endsWith(' ')) {
|
||||
return {
|
||||
key: PREFIX_TO_KEY[prefix],
|
||||
value: lastWord.slice(prefix.length),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function highlightMatch(text: string, query: string): (string | { highlight: string })[] {
|
||||
if (!query) return [text];
|
||||
const lower = text.toLowerCase();
|
||||
const qLower = query.toLowerCase();
|
||||
const idx = lower.indexOf(qLower);
|
||||
if (idx === -1) return [text];
|
||||
return [
|
||||
text.slice(0, idx),
|
||||
{ highlight: text.slice(idx, idx + query.length) },
|
||||
text.slice(idx + query.length),
|
||||
].filter((s) => (typeof s === 'string' ? s.length > 0 : true));
|
||||
}
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
export function formatRelativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
.layout {
|
||||
display: flex;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
import { TopNav } from './TopNav';
|
||||
import { AppSidebar } from './AppSidebar';
|
||||
import { CommandPalette } from '../command-palette/CommandPalette';
|
||||
import styles from './AppShell.module.css';
|
||||
|
||||
const COLLAPSED_KEY = 'cameleer-sidebar-collapsed';
|
||||
|
||||
export function AppShell() {
|
||||
const [collapsed, setCollapsed] = useState(() => {
|
||||
try { return localStorage.getItem(COLLAPSED_KEY) === 'true'; }
|
||||
catch { return false; }
|
||||
});
|
||||
|
||||
// Auto-collapse on small screens
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(max-width: 1024px)');
|
||||
function handleChange(e: MediaQueryListEvent | MediaQueryList) {
|
||||
if (e.matches) setCollapsed(true);
|
||||
}
|
||||
handleChange(mq);
|
||||
mq.addEventListener('change', handleChange);
|
||||
return () => mq.removeEventListener('change', handleChange);
|
||||
}, []);
|
||||
|
||||
function toggleSidebar() {
|
||||
setCollapsed((prev) => {
|
||||
const next = !prev;
|
||||
try { localStorage.setItem(COLLAPSED_KEY, String(next)); }
|
||||
catch { /* ignore */ }
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopNav onToggleSidebar={toggleSidebar} />
|
||||
<div className={styles.layout}>
|
||||
<AppSidebar collapsed={collapsed} />
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
<CommandPalette />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
/* ─── Sidebar Container ─── */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
height: calc(100vh - 56px);
|
||||
position: sticky;
|
||||
top: 56px;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebarCollapsed {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
/* ─── Search ─── */
|
||||
.search {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.sidebarCollapsed .search {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.searchInput:focus {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
/* ─── App List ─── */
|
||||
.appList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* ─── Section Divider ─── */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border-subtle);
|
||||
margin: 4px 12px;
|
||||
}
|
||||
|
||||
.sidebarCollapsed .divider {
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
/* ─── App Item ─── */
|
||||
.appItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.appItem:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.appItemActive {
|
||||
background: var(--amber-glow);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.sidebarCollapsed .appItem {
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ─── Health Dot ─── */
|
||||
.healthDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dotLive { background: var(--green); }
|
||||
.dotStale { background: var(--amber); }
|
||||
.dotDead { background: var(--text-muted); }
|
||||
|
||||
/* ─── App Info (hidden when collapsed) ─── */
|
||||
.appInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebarCollapsed .appInfo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.appName {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.appMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ─── All Item icon ─── */
|
||||
.allIcon {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.appItemActive .allIcon {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
/* ─── Bottom Section ─── */
|
||||
.bottom {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.bottomItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bottomItem:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bottomItemActive {
|
||||
color: var(--amber);
|
||||
background: var(--amber-glow);
|
||||
}
|
||||
|
||||
.sidebarCollapsed .bottomItem {
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.bottomLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebarCollapsed .bottomLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bottomIcon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── Admin Sub-Menu ─── */
|
||||
.adminChevron {
|
||||
margin-left: 6px;
|
||||
font-size: 8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.adminSubMenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.adminSubItem {
|
||||
display: block;
|
||||
padding: 6px 16px 6px 42px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: all 0.1s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.adminSubItem:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.adminSubItemActive {
|
||||
color: var(--amber);
|
||||
background: var(--amber-glow);
|
||||
}
|
||||
|
||||
.sidebarCollapsed .adminSubMenu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.sidebar .search {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar .appInfo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar .appItem {
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sidebar .divider {
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
.sidebar .bottomItem {
|
||||
padding: 8px 0;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sidebar .bottomLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar .adminSubMenu {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { NavLink, useParams, useLocation } from 'react-router';
|
||||
import { useAgents } from '../../api/queries/agents';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import type { AgentInstance } from '../../api/types';
|
||||
import styles from './AppSidebar.module.css';
|
||||
|
||||
interface GroupInfo {
|
||||
group: string;
|
||||
agents: AgentInstance[];
|
||||
liveCount: number;
|
||||
staleCount: number;
|
||||
deadCount: number;
|
||||
}
|
||||
|
||||
function healthStatus(g: GroupInfo): 'live' | 'stale' | 'dead' {
|
||||
if (g.liveCount > 0) return 'live';
|
||||
if (g.staleCount > 0) return 'stale';
|
||||
return 'dead';
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export function AppSidebar({ collapsed }: AppSidebarProps) {
|
||||
const { group: activeGroup } = useParams<{ group: string }>();
|
||||
const { data: agents } = useAgents();
|
||||
const { roles } = useAuthStore();
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (!agents) return [];
|
||||
const map = new Map<string, GroupInfo>();
|
||||
for (const agent of agents) {
|
||||
const key = agent.group ?? 'default';
|
||||
let entry = map.get(key);
|
||||
if (!entry) {
|
||||
entry = { group: key, agents: [], liveCount: 0, staleCount: 0, deadCount: 0 };
|
||||
map.set(key, entry);
|
||||
}
|
||||
entry.agents.push(agent);
|
||||
if (agent.status === 'LIVE') entry.liveCount++;
|
||||
else if (agent.status === 'STALE') entry.staleCount++;
|
||||
else entry.deadCount++;
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.group.localeCompare(b.group));
|
||||
}, [agents]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter) return groups;
|
||||
const lower = filter.toLowerCase();
|
||||
return groups.filter((g) => g.group.toLowerCase().includes(lower));
|
||||
}, [groups, filter]);
|
||||
|
||||
const sidebarClass = `${styles.sidebar} ${collapsed ? styles.sidebarCollapsed : ''}`;
|
||||
|
||||
return (
|
||||
<aside className={sidebarClass}>
|
||||
{/* Search */}
|
||||
<div className={styles.search}>
|
||||
<input
|
||||
className={styles.searchInput}
|
||||
type="text"
|
||||
placeholder="Filter apps..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* App List */}
|
||||
<div className={styles.appList}>
|
||||
{/* All (unscoped) */}
|
||||
<NavLink
|
||||
to="/executions"
|
||||
className={({ isActive }) =>
|
||||
`${styles.appItem} ${isActive && !activeGroup ? styles.appItemActive : ''}`
|
||||
}
|
||||
title="All Applications"
|
||||
>
|
||||
<span className={styles.allIcon}>*</span>
|
||||
<span className={styles.appInfo}>
|
||||
<span className={styles.appName}>All</span>
|
||||
</span>
|
||||
</NavLink>
|
||||
|
||||
<div className={styles.divider} />
|
||||
|
||||
{/* App entries */}
|
||||
{filtered.map((g) => {
|
||||
const status = healthStatus(g);
|
||||
const isActive = activeGroup === g.group;
|
||||
return (
|
||||
<NavLink
|
||||
key={g.group}
|
||||
to={`/apps/${encodeURIComponent(g.group)}`}
|
||||
className={`${styles.appItem} ${isActive ? styles.appItemActive : ''}`}
|
||||
title={g.group}
|
||||
>
|
||||
<span className={`${styles.healthDot} ${styles[`dot${status.charAt(0).toUpperCase()}${status.slice(1)}`]}`} />
|
||||
<span className={styles.appInfo}>
|
||||
<span className={styles.appName}>{g.group}</span>
|
||||
<span className={styles.appMeta}>
|
||||
{g.agents.length} agent{g.agents.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom: Admin */}
|
||||
{roles.includes('ADMIN') && (
|
||||
<div className={styles.bottom}>
|
||||
<AdminSubMenu collapsed={collapsed} />
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const ADMIN_LINKS = [
|
||||
{ to: '/admin/database', label: 'Database' },
|
||||
{ to: '/admin/opensearch', label: 'OpenSearch' },
|
||||
{ to: '/admin/audit', label: 'Audit Log' },
|
||||
{ to: '/admin/oidc', label: 'OIDC' },
|
||||
{ to: '/admin/rbac', label: 'User Management' },
|
||||
];
|
||||
|
||||
function AdminSubMenu({ collapsed: sidebarCollapsed }: { collapsed: boolean }) {
|
||||
const location = useLocation();
|
||||
const isAdminActive = location.pathname.startsWith('/admin');
|
||||
|
||||
const [open, setOpen] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem('cameleer-admin-sidebar-open') === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
try {
|
||||
localStorage.setItem('cameleer-admin-sidebar-open', String(next));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.bottomItem} ${isAdminActive ? styles.bottomItemActive : ''}`}
|
||||
onClick={toggle}
|
||||
title="Admin"
|
||||
>
|
||||
<span className={styles.bottomIcon}>⚙</span>
|
||||
<span className={styles.bottomLabel}>
|
||||
Admin
|
||||
{!sidebarCollapsed && (
|
||||
<span className={styles.adminChevron}>
|
||||
{open ? '\u25BC' : '\u25B6'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{open && !sidebarCollapsed && (
|
||||
<div className={styles.adminSubMenu}>
|
||||
{ADMIN_LINKS.map((link) => (
|
||||
<NavLink
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={({ isActive }) =>
|
||||
`${styles.adminSubItem} ${isActive ? styles.adminSubItemActive : ''}`
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
.topnav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: var(--topnav-bg);
|
||||
backdrop-filter: blur(20px) saturate(1.2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hamburger:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: var(--amber);
|
||||
letter-spacing: -0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logo:hover { color: var(--amber); }
|
||||
|
||||
/* ─── Search Bar ─── */
|
||||
.searchBar {
|
||||
flex: 1;
|
||||
max-width: 480px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.searchBar:hover {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchPlaceholder {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.searchKbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Right Section ─── */
|
||||
.navRight {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.utilLink {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 99px;
|
||||
border: 1px solid var(--border);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.utilLink:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.utilLinkActive {
|
||||
composes: utilLink;
|
||||
color: var(--amber);
|
||||
border-color: rgba(245, 158, 11, 0.3);
|
||||
background: var(--amber-glow);
|
||||
}
|
||||
|
||||
.envBadge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 99px;
|
||||
background: var(--green-glow);
|
||||
color: var(--green);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.themeToggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.themeToggle:hover {
|
||||
border-color: var(--text-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.logoutBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 4px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.logoutBtn:hover {
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 768px) {
|
||||
.searchBar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { NavLink } from 'react-router';
|
||||
import { useThemeStore } from '../../theme/theme-store';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
import { useCommandPalette } from '../command-palette/use-command-palette';
|
||||
import styles from './TopNav.module.css';
|
||||
|
||||
interface TopNavProps {
|
||||
onToggleSidebar: () => void;
|
||||
}
|
||||
|
||||
export function TopNav({ onToggleSidebar }: TopNavProps) {
|
||||
const { theme, toggle } = useThemeStore();
|
||||
const { username, logout } = useAuthStore();
|
||||
const openPalette = useCommandPalette((s) => s.open);
|
||||
|
||||
return (
|
||||
<nav className={styles.topnav}>
|
||||
<button className={styles.hamburger} onClick={onToggleSidebar} title="Toggle sidebar">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<NavLink to="/" className={styles.logo}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
cameleer3
|
||||
</NavLink>
|
||||
|
||||
{/* Visible search bar */}
|
||||
<div className={styles.searchBar} onClick={openPalette} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') openPalette(); }}>
|
||||
<svg className={styles.searchIcon} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
<span className={styles.searchPlaceholder}>Search executions, orders...</span>
|
||||
<kbd className={styles.searchKbd}>⌘K</kbd>
|
||||
</div>
|
||||
|
||||
<div className={styles.navRight}>
|
||||
<NavLink to="/swagger" className={({ isActive }) => isActive ? styles.utilLinkActive : styles.utilLink} title="API Documentation">
|
||||
API
|
||||
</NavLink>
|
||||
<span className={styles.envBadge}>{import.meta.env.VITE_ENV_NAME || 'DEV'}</span>
|
||||
<button className={styles.themeToggle} onClick={toggle} title="Toggle theme">
|
||||
{theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19'}
|
||||
</button>
|
||||
{username && (
|
||||
<span className={styles.userInfo}>
|
||||
{username}
|
||||
<button className={styles.logoutBtn} onClick={logout} title="Sign out">
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import styles from './shared.module.css';
|
||||
|
||||
const COLORS = ['#3b82f6', '#f0b429', '#10b981', '#a855f7', '#f43f5e', '#22d3ee', '#ec4899'];
|
||||
|
||||
function hashColor(name: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return COLORS[Math.abs(hash) % COLORS.length];
|
||||
}
|
||||
|
||||
export function AppBadge({ name }: { name: string }) {
|
||||
return (
|
||||
<span className={styles.appBadge}>
|
||||
<span className={styles.appDot} style={{ background: hashColor(name) }} />
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import styles from './shared.module.css';
|
||||
|
||||
function durationClass(ms: number) {
|
||||
if (ms < 100) return styles.barFast;
|
||||
if (ms < 1000) return styles.barMedium;
|
||||
return styles.barSlow;
|
||||
}
|
||||
|
||||
function durationColor(ms: number) {
|
||||
if (ms < 100) return 'var(--green)';
|
||||
if (ms < 1000) return 'var(--amber)';
|
||||
return 'var(--rose)';
|
||||
}
|
||||
|
||||
export function DurationBar({ duration }: { duration: number }) {
|
||||
const widthPct = Math.min(100, (duration / 5000) * 100);
|
||||
return (
|
||||
<div className={styles.durationBar}>
|
||||
<span className="mono" style={{ color: durationColor(duration) }}>
|
||||
{duration.toLocaleString()}ms
|
||||
</span>
|
||||
<div className={styles.bar}>
|
||||
<div
|
||||
className={`${styles.barFill} ${durationClass(duration)}`}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import styles from './shared.module.css';
|
||||
|
||||
interface FilterChipProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
accent?: 'green' | 'rose' | 'blue';
|
||||
count?: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function FilterChip({ label, active, accent, count, onClick }: FilterChipProps) {
|
||||
const accentClass = accent ? styles[`chip${accent.charAt(0).toUpperCase()}${accent.slice(1)}`] : '';
|
||||
return (
|
||||
<span
|
||||
className={`${styles.chip} ${active ? styles.chipActive : ''} ${accentClass}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{accent && <span className={styles.chipDot} />}
|
||||
{label}
|
||||
{count !== undefined && <span className={styles.chipCount}>{count.toLocaleString()}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import styles from './shared.module.css';
|
||||
|
||||
interface PaginationProps {
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
onChange: (offset: number) => void;
|
||||
}
|
||||
|
||||
export function Pagination({ total, offset, limit, onChange }: PaginationProps) {
|
||||
const currentPage = Math.floor(offset / limit) + 1;
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit));
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const pages: (number | '...')[] = [];
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
} else {
|
||||
pages.push(1);
|
||||
if (currentPage > 3) pages.push('...');
|
||||
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
if (currentPage < totalPages - 2) pages.push('...');
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
className={`${styles.pageBtn} ${currentPage === 1 ? styles.pageBtnDisabled : ''}`}
|
||||
onClick={() => currentPage > 1 && onChange((currentPage - 2) * limit)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
{pages.map((p, i) =>
|
||||
p === '...' ? (
|
||||
<span key={`e${i}`} className={styles.pageEllipsis}>…</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
className={`${styles.pageBtn} ${p === currentPage ? styles.pageBtnActive : ''}`}
|
||||
onClick={() => onChange((p - 1) * limit)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
className={`${styles.pageBtn} ${currentPage === totalPages ? styles.pageBtnDisabled : ''}`}
|
||||
onClick={() => currentPage < totalPages && onChange(currentPage * limit)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
interface ResizableDividerProps {
|
||||
/** Current panel width in pixels */
|
||||
panelWidth: number;
|
||||
/** Called with new width */
|
||||
onResize: (width: number) => void;
|
||||
/** Min panel width */
|
||||
minWidth?: number;
|
||||
/** Max panel width */
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
export function ResizableDivider({
|
||||
panelWidth,
|
||||
onResize,
|
||||
minWidth = 200,
|
||||
maxWidth = 600,
|
||||
}: ResizableDividerProps) {
|
||||
const dragging = useRef(false);
|
||||
const startX = useRef(0);
|
||||
const startWidth = useRef(0);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragging.current = true;
|
||||
startX.current = e.clientX;
|
||||
startWidth.current = panelWidth;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}, [panelWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (!dragging.current) return;
|
||||
// Dragging left increases panel width (panel is on the right)
|
||||
const delta = startX.current - e.clientX;
|
||||
const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidth.current + delta));
|
||||
onResize(newWidth);
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
if (!dragging.current) return;
|
||||
dragging.current = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [onResize, minWidth, maxWidth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
width: 6,
|
||||
cursor: 'col-resize',
|
||||
background: 'var(--border-subtle)',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
zIndex: 5,
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--amber)'; }}
|
||||
onMouseLeave={(e) => { if (!dragging.current) (e.currentTarget as HTMLElement).style.background = 'var(--border-subtle)'; }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import styles from './shared.module.css';
|
||||
import { MiniChart } from '../charts/MiniChart';
|
||||
|
||||
const ACCENT_COLORS: Record<string, string> = {
|
||||
amber: 'var(--amber)',
|
||||
cyan: 'var(--cyan)',
|
||||
rose: 'var(--rose)',
|
||||
green: 'var(--green)',
|
||||
blue: 'var(--blue)',
|
||||
};
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
accent: 'amber' | 'cyan' | 'rose' | 'green' | 'blue';
|
||||
change?: string;
|
||||
changeDirection?: 'up' | 'down' | 'neutral';
|
||||
sparkData?: number[];
|
||||
}
|
||||
|
||||
export function StatCard({ label, value, accent, change, changeDirection = 'neutral', sparkData }: StatCardProps) {
|
||||
return (
|
||||
<div className={`${styles.statCard} ${styles[accent]}`}>
|
||||
<div className={styles.statLabel}>{label}</div>
|
||||
<div className={styles.statValue}>{value}</div>
|
||||
{change && (
|
||||
<div className={`${styles.statChange} ${styles[changeDirection]}`}>{change}</div>
|
||||
)}
|
||||
{sparkData && sparkData.length >= 2 && (
|
||||
<MiniChart data={sparkData} color={ACCENT_COLORS[accent] ?? ACCENT_COLORS.amber} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import styles from './shared.module.css';
|
||||
|
||||
const STATUS_MAP = {
|
||||
COMPLETED: { className: styles.pillCompleted, label: 'Completed' },
|
||||
FAILED: { className: styles.pillFailed, label: 'Failed' },
|
||||
RUNNING: { className: styles.pillRunning, label: 'Running' },
|
||||
} as const;
|
||||
|
||||
export function StatusPill({ status }: { status: string }) {
|
||||
const info = STATUS_MAP[status as keyof typeof STATUS_MAP] ?? STATUS_MAP.COMPLETED;
|
||||
return (
|
||||
<span className={`${styles.statusPill} ${info.className}`}>
|
||||
<span className={styles.statusDot} />
|
||||
{info.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
/* ─── Status Pill ─── */
|
||||
.statusPill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 99px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.pillCompleted { background: var(--green-glow); color: var(--green); }
|
||||
.pillFailed { background: var(--rose-glow); color: var(--rose); }
|
||||
.pillRunning { background: rgba(59, 130, 246, 0.12); color: var(--blue); }
|
||||
.pillRunning .statusDot { animation: livePulse 1.5s ease-in-out infinite; }
|
||||
|
||||
/* ─── Duration Bar ─── */
|
||||
.durationBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
background: var(--bg-base);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.barFill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.barFast { background: var(--green); }
|
||||
.barMedium { background: var(--amber); }
|
||||
.barSlow { background: var(--rose); }
|
||||
|
||||
/* ─── Stat Card ─── */
|
||||
.statCard {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.statCard:hover { border-color: var(--border); }
|
||||
|
||||
.statCard::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.amber::before { background: linear-gradient(90deg, var(--amber), transparent); }
|
||||
.cyan::before { background: linear-gradient(90deg, var(--cyan), transparent); }
|
||||
.rose::before { background: linear-gradient(90deg, var(--rose), transparent); }
|
||||
.green::before { background: linear-gradient(90deg, var(--green), transparent); }
|
||||
.blue::before { background: linear-gradient(90deg, var(--blue), transparent); }
|
||||
|
||||
.statLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.amber .statValue { color: var(--amber); }
|
||||
.cyan .statValue { color: var(--cyan); }
|
||||
.rose .statValue { color: var(--rose); }
|
||||
.green .statValue { color: var(--green); }
|
||||
.blue .statValue { color: var(--blue); }
|
||||
|
||||
.statChange {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.up { color: var(--rose); }
|
||||
.down { color: var(--green); }
|
||||
.neutral { color: var(--text-muted); }
|
||||
|
||||
/* ─── App Badge ─── */
|
||||
.appBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.appDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ─── Filter Chip ─── */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 99px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip:hover { border-color: var(--text-muted); color: var(--text-primary); }
|
||||
|
||||
.chipActive { background: var(--amber-glow); border-color: var(--amber-dim); color: var(--amber); }
|
||||
.chipActive.chipGreen { background: var(--green-glow); border-color: rgba(16, 185, 129, 0.3); color: var(--green); }
|
||||
.chipActive.chipRose { background: var(--rose-glow); border-color: rgba(244, 63, 94, 0.3); color: var(--rose); }
|
||||
.chipActive.chipBlue { background: rgba(59, 130, 246, 0.12); border-color: rgba(59, 130, 246, 0.3); color: var(--blue); }
|
||||
|
||||
.chipDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.chipCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ─── Pagination ─── */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pageBtn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.pageBtn:hover:not(:disabled) { border-color: var(--border); background: var(--bg-raised); }
|
||||
.pageBtnActive { background: var(--amber-glow); border-color: var(--amber-dim); color: var(--amber); }
|
||||
.pageBtnDisabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.pageEllipsis {
|
||||
color: var(--text-muted);
|
||||
padding: 0 4px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
Reference in New Issue
Block a user