mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-04 08:19:16 +00:00
feat(ui): redesign dashboard logs page with virtualized 3-pane shell
Replace the ornamental logs page (radial-gradient overlays, faux-HUD captions, scale-on-click animations, two competing tab shells, zero virtualization) with a calm, accessible, performant surface. - Calm 48px header + tabs + 3-pane shell (filters | list | detail) - List virtualized via react-virtuoso; trace-grouped rows by requestId with expandable per-stage timeline and per-stage latencyMs - Filters split into primary (search / level / source) + advanced (module / stage / requestId / time-window) with 250ms debounce and in-flight react-query cancellation on filter change - Live-tail pause / resume with rotation-safe id-set diff for the new-entries pill; visibility-aware polling - Detail panel: Overview / Context / Raw tabs; Copy JSON, Copy requestId, Show-trace jump; redaction-aware rendering - Designed empty / loading / error states (4 empty variants, retry on error) - Keyboard nav (j / k / Enter / Esc / / / Space / ?) with visible focus rings; reduced-motion respected; AA-tuned level palette - New header button for keyboard-shortcuts discoverability (alongside ? keypress) -- pairs with the shortcuts dialog - Optional fields added to LogsEntry to consume the #1141 contract; UI ships against fixture (?mock=logs URL flag) so backend isn't a hard prerequisite. Dev-only assertLogsEntryShape warns once on drift - Deleted dead logs-overview-cards.tsx (was unmounted) Closes #1142 Refs #1138, #1141
This commit is contained in:
@@ -0,0 +1,167 @@
|
|||||||
|
import type { LogsEntry, LogsLevel } from '@/lib/api-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic fixture for the redesigned logs surface.
|
||||||
|
*
|
||||||
|
* Mix:
|
||||||
|
* - 30 traces of 3-6 stages each (~140 entries) sharing a `requestId`.
|
||||||
|
* - ~60 standalone entries (no `requestId`) including pre-intake / system rows.
|
||||||
|
* - All 4 levels present; partial traces (incomplete stage list) included.
|
||||||
|
*
|
||||||
|
* Used in dev mode via `?mock=logs` URL flag (see `use-logs.ts`). Tree-shaken
|
||||||
|
* from production builds because the import site is guarded by
|
||||||
|
* `import.meta.env.DEV`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STAGES = ['intake', 'auth', 'route', 'handler', 'provider', 'response'] as const;
|
||||||
|
|
||||||
|
const MODULES = [
|
||||||
|
'api.gateway',
|
||||||
|
'auth.oauth',
|
||||||
|
'cliproxy.router',
|
||||||
|
'profile.runtime',
|
||||||
|
'config.loader',
|
||||||
|
'doctor.healthcheck',
|
||||||
|
'cli.exec',
|
||||||
|
'dashboard.api',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SOURCES = ['ccs-cli', 'cliproxy', 'dashboard'] as const;
|
||||||
|
|
||||||
|
const EVENTS = [
|
||||||
|
'request.received',
|
||||||
|
'request.dispatched',
|
||||||
|
'provider.invoked',
|
||||||
|
'provider.responded',
|
||||||
|
'response.flushed',
|
||||||
|
'profile.activated',
|
||||||
|
'config.read',
|
||||||
|
'token.refreshed',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Deterministic PRNG so the fixture is stable across reloads.
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
a = (a + 0x6d2b79f5) >>> 0;
|
||||||
|
let t = a;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rng = mulberry32(1138_1142);
|
||||||
|
|
||||||
|
function pick<T>(items: readonly T[]): T {
|
||||||
|
return items[Math.floor(rng() * items.length)] as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLevel(weights = { debug: 0.5, info: 0.3, warn: 0.15, error: 0.05 }): LogsLevel {
|
||||||
|
const r = rng();
|
||||||
|
let acc = 0;
|
||||||
|
for (const [level, w] of Object.entries(weights) as Array<[LogsLevel, number]>) {
|
||||||
|
acc += w;
|
||||||
|
if (r <= acc) return level;
|
||||||
|
}
|
||||||
|
return 'debug';
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_TS = Date.parse('2026-04-30T17:00:00Z');
|
||||||
|
|
||||||
|
let entryCounter = 0;
|
||||||
|
function nextId(prefix: string): string {
|
||||||
|
entryCounter += 1;
|
||||||
|
return `${prefix}-${entryCounter.toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTrace(traceIndex: number): LogsEntry[] {
|
||||||
|
const requestId = `req_${(traceIndex + 1).toString().padStart(4, '0')}`;
|
||||||
|
const traceStart = BASE_TS - traceIndex * 47_000 - Math.floor(rng() * 1500);
|
||||||
|
const stageCount = 3 + Math.floor(rng() * 4); // 3..6
|
||||||
|
const moduleName = pick(MODULES);
|
||||||
|
const source = pick(SOURCES);
|
||||||
|
const stages = STAGES.slice(0, stageCount);
|
||||||
|
// ~12% of traces are partial -- drop a middle stage to simulate gaps
|
||||||
|
const partial = rng() < 0.12 && stages.length > 3;
|
||||||
|
const effectiveStages = partial
|
||||||
|
? stages.filter((_, idx) => idx !== Math.floor(stages.length / 2))
|
||||||
|
: stages;
|
||||||
|
|
||||||
|
let cursor = traceStart;
|
||||||
|
return effectiveStages.map((stage, idx) => {
|
||||||
|
const latencyMs = 5 + Math.floor(rng() * 220);
|
||||||
|
cursor += latencyMs + Math.floor(rng() * 6);
|
||||||
|
const level = idx === effectiveStages.length - 1 && rng() < 0.18 ? 'error' : pickLevel();
|
||||||
|
const event = pick(EVENTS);
|
||||||
|
const ts = new Date(cursor).toISOString();
|
||||||
|
return {
|
||||||
|
id: nextId('trace'),
|
||||||
|
timestamp: ts,
|
||||||
|
level,
|
||||||
|
source,
|
||||||
|
event,
|
||||||
|
message: `${moduleName} ${stage} ${level === 'error' ? 'failed' : 'ok'}`,
|
||||||
|
processId: 4000 + (traceIndex % 8),
|
||||||
|
runId: `run_${(traceIndex % 12).toString().padStart(3, '0')}`,
|
||||||
|
context: undefined,
|
||||||
|
requestId,
|
||||||
|
module: moduleName,
|
||||||
|
stage,
|
||||||
|
latencyMs,
|
||||||
|
metadata: {
|
||||||
|
attempt: idx + 1,
|
||||||
|
traceIndex,
|
||||||
|
cacheHit: rng() < 0.3,
|
||||||
|
upstream: source,
|
||||||
|
},
|
||||||
|
error:
|
||||||
|
level === 'error'
|
||||||
|
? {
|
||||||
|
message: `${moduleName}.${stage} timed out after ${latencyMs}ms`,
|
||||||
|
code: 'ERR_TIMEOUT',
|
||||||
|
stack: `at ${moduleName}.${stage}\n at handler.run`,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
} satisfies LogsEntry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStandalone(index: number): LogsEntry {
|
||||||
|
const ts = new Date(BASE_TS - index * 11_000 - Math.floor(rng() * 4000)).toISOString();
|
||||||
|
const level = pickLevel({ debug: 0.55, info: 0.3, warn: 0.1, error: 0.05 });
|
||||||
|
const moduleName = pick(MODULES);
|
||||||
|
const source = pick(SOURCES);
|
||||||
|
const event = pick(EVENTS);
|
||||||
|
return {
|
||||||
|
id: nextId('solo'),
|
||||||
|
timestamp: ts,
|
||||||
|
level,
|
||||||
|
source,
|
||||||
|
event,
|
||||||
|
message: `${moduleName} ${event.replace('.', ' ')}`,
|
||||||
|
processId: 4000 + (index % 8),
|
||||||
|
runId: null,
|
||||||
|
context: undefined,
|
||||||
|
requestId: undefined,
|
||||||
|
module: moduleName,
|
||||||
|
stage: undefined,
|
||||||
|
latencyMs: rng() < 0.6 ? Math.floor(rng() * 80) : undefined,
|
||||||
|
metadata: { standalone: true, source },
|
||||||
|
error: undefined,
|
||||||
|
} satisfies LogsEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
const traces: LogsEntry[] = [];
|
||||||
|
for (let i = 0; i < 30; i += 1) {
|
||||||
|
traces.push(...buildTrace(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
const standalones: LogsEntry[] = [];
|
||||||
|
for (let i = 0; i < 60; i += 1) {
|
||||||
|
standalones.push(buildStandalone(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STRUCTURED_LOG_ENTRIES: LogsEntry[] = [...traces, ...standalones].sort((a, b) =>
|
||||||
|
b.timestamp.localeCompare(a.timestamp)
|
||||||
|
);
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { LogsEntry, LogsLevel } from '@/lib/api-client';
|
||||||
|
import type { TraceGroup } from './logs-trace-row';
|
||||||
|
|
||||||
|
const LEVEL_RANK: Record<LogsLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||||
|
|
||||||
|
export interface LeafItem {
|
||||||
|
kind: 'leaf';
|
||||||
|
entry: LogsEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DerivedItem = LeafItem | TraceGroup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure helper: derive a flat list of either standalone leaves or trace groups
|
||||||
|
* (entries sharing a `requestId`). Stable, single-pass, O(n).
|
||||||
|
*
|
||||||
|
* Sort within a group is `ts asc` (defensive — backend may not guarantee it).
|
||||||
|
* Group ts = min child ts, used to slot the group amongst standalone leaves.
|
||||||
|
* Standalone leaves keep their original `ts` slot — never silently dropped.
|
||||||
|
*/
|
||||||
|
export function deriveTraceGroups(entries: LogsEntry[]): DerivedItem[] {
|
||||||
|
const groups = new Map<string, LogsEntry[]>();
|
||||||
|
const leaves: LeafItem[] = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.requestId) {
|
||||||
|
const bucket = groups.get(entry.requestId);
|
||||||
|
if (bucket) bucket.push(entry);
|
||||||
|
else groups.set(entry.requestId, [entry]);
|
||||||
|
} else {
|
||||||
|
leaves.push({ kind: 'leaf', entry });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupItems: TraceGroup[] = [];
|
||||||
|
for (const [requestId, children] of groups) {
|
||||||
|
const sorted = [...children].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
||||||
|
let maxLevel: LogsLevel = 'debug';
|
||||||
|
let total = 0;
|
||||||
|
for (const c of sorted) {
|
||||||
|
if (LEVEL_RANK[c.level] > LEVEL_RANK[maxLevel]) maxLevel = c.level;
|
||||||
|
if (typeof c.latencyMs === 'number') total += c.latencyMs;
|
||||||
|
}
|
||||||
|
const head = sorted[0];
|
||||||
|
if (!head) continue;
|
||||||
|
groupItems.push({
|
||||||
|
kind: 'trace',
|
||||||
|
requestId,
|
||||||
|
module: head.module ?? head.source,
|
||||||
|
source: head.source,
|
||||||
|
ts: head.timestamp,
|
||||||
|
maxLevel,
|
||||||
|
totalLatencyMs: total,
|
||||||
|
children: sorted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...groupItems, ...leaves].sort((a, b) => {
|
||||||
|
const at = a.kind === 'trace' ? a.ts : a.entry.timestamp;
|
||||||
|
const bt = b.kind === 'trace' ? b.ts : b.entry.timestamp;
|
||||||
|
return bt.localeCompare(at);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Pause, Play, ArrowDownToLine } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { FOCUS_RING } from './tokens';
|
||||||
|
|
||||||
|
export interface LiveTailControlsProps {
|
||||||
|
isPaused: boolean;
|
||||||
|
pendingCount: number;
|
||||||
|
onTogglePause: () => void;
|
||||||
|
onJumpToBottom?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LiveTailControls({
|
||||||
|
isPaused,
|
||||||
|
pendingCount,
|
||||||
|
onTogglePause,
|
||||||
|
onJumpToBottom,
|
||||||
|
}: LiveTailControlsProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-pressed={isPaused}
|
||||||
|
onClick={onTogglePause}
|
||||||
|
className={cn('h-7 gap-1.5 px-2 text-xs font-medium', FOCUS_RING)}
|
||||||
|
>
|
||||||
|
{isPaused ? (
|
||||||
|
<Play className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Pause className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{isPaused ? 'Resume tail' : 'Pause tail'}
|
||||||
|
</Button>
|
||||||
|
{isPaused && pendingCount > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTogglePause}
|
||||||
|
aria-live="polite"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-7 items-center gap-1.5 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 text-[11px] font-medium text-amber-700 dark:text-amber-300',
|
||||||
|
FOCUS_RING
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden="true" />
|
||||||
|
{pendingCount} new {pendingCount === 1 ? 'entry' : 'entries'} · click to resume
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{onJumpToBottom ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onJumpToBottom}
|
||||||
|
className={cn('h-7 gap-1.5 px-2 text-xs', FOCUS_RING)}
|
||||||
|
>
|
||||||
|
<ArrowDownToLine className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
Jump to bottom
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,21 @@
|
|||||||
import type { LogsLevel } from '@/lib/api-client';
|
import type { LogsLevel } from '@/lib/api-client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getLevelLabel } from './utils';
|
import { getLevelLabel } from './utils';
|
||||||
|
import { LEVEL_TOKENS } from './tokens';
|
||||||
const LEVEL_STYLES: Record<LogsLevel, string> = {
|
|
||||||
error: 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300',
|
|
||||||
warn: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300',
|
|
||||||
info: 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300',
|
|
||||||
debug: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function LogLevelBadge({ level, className }: { level: LogsLevel; className?: string }) {
|
export function LogLevelBadge({ level, className }: { level: LogsLevel; className?: string }) {
|
||||||
|
const token = LEVEL_TOKENS[level];
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium uppercase tracking-[0.12em]',
|
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium',
|
||||||
LEVEL_STYLES[level],
|
token.border,
|
||||||
|
token.bg,
|
||||||
|
token.fg,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<span className={cn('h-1.5 w-1.5 rounded-full', token.dot)} aria-hidden="true" />
|
||||||
{getLevelLabel(level)}
|
{getLevelLabel(level)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Save, Settings2, ShieldAlert, RotateCcw, Activity } from 'lucide-react';
|
import { RotateCcw, Save } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -12,29 +12,25 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import type { LogsConfig, UpdateLogsConfigPayload } from '@/lib/api-client';
|
import type { LogsConfig, UpdateLogsConfigPayload } from '@/lib/api-client';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready
|
|
||||||
|
|
||||||
function parseInteger(value: string, fallback: number) {
|
function parseInteger(value: string, fallback: number): number {
|
||||||
const parsed = Number.parseInt(value, 10);
|
const parsed = Number.parseInt(value, 10);
|
||||||
if (Number.isNaN(parsed)) {
|
if (Number.isNaN(parsed)) return fallback;
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.max(0, parsed);
|
return Math.max(0, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LogsConfigCard({
|
export interface LogsConfigCardProps {
|
||||||
config,
|
|
||||||
onSave,
|
|
||||||
isPending,
|
|
||||||
}: {
|
|
||||||
config: LogsConfig;
|
config: LogsConfig;
|
||||||
onSave: (payload: UpdateLogsConfigPayload) => void;
|
onSave: (payload: UpdateLogsConfigPayload) => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
}) {
|
}
|
||||||
// TODO i18n: uncomment when keys for Commit Changes, Rollback Draft, etc. are added
|
|
||||||
// const { t } = useTranslation();
|
/**
|
||||||
|
* Logging policy form. Calm chrome, designed to live inside a `Sheet`.
|
||||||
|
* Renamed semantically to "form" -- the export name remains `LogsConfigCard`
|
||||||
|
* for consumer compatibility.
|
||||||
|
*/
|
||||||
|
export function LogsConfigCard({ config, onSave, isPending }: LogsConfigCardProps) {
|
||||||
const [draft, setDraft] = useState(config);
|
const [draft, setDraft] = useState(config);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,244 +40,123 @@ export function LogsConfigCard({
|
|||||||
const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(config), [config, draft]);
|
const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(config), [config, draft]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group relative overflow-hidden rounded-2xl border-2 border-border/60 bg-card/40 p-1 shadow-lg transition-all hover:border-border">
|
<form
|
||||||
<div className="flex items-center justify-between border-b border-border bg-muted/30 px-5 py-3">
|
className="space-y-5"
|
||||||
<div className="flex items-center gap-3">
|
onSubmit={(e) => {
|
||||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/5 border border-primary/20">
|
e.preventDefault();
|
||||||
<Settings2 className="h-3.5 w-3.5 text-primary" />
|
onSave(draft);
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded border border-border bg-background px-3 py-2">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<h3 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground">
|
<Label htmlFor="logs-enabled" className="text-sm font-medium">
|
||||||
Logging Policy
|
Enabled
|
||||||
</h3>
|
</Label>
|
||||||
<p className="text-[10px] font-medium uppercase tracking-[0.1em] text-foreground/45">
|
<p className="text-xs text-muted-foreground">Capture structured log entries.</p>
|
||||||
Retention and privacy
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="logs-enabled"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(checked) => setDraft((c) => ({ ...c, enabled: checked }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded border border-border bg-background px-3 py-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="logs-redact" className="text-sm font-medium">
|
||||||
|
Redact sensitive values
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Hide payload values until explicitly revealed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="logs-redact"
|
||||||
|
checked={draft.redact}
|
||||||
|
onCheckedChange={(checked) => setDraft((c) => ({ ...c, redact: checked }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'h-1.5 w-1.5 rounded-full',
|
|
||||||
config.enabled ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-zinc-500'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6 p-5">
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<Label htmlFor="logs-config-level" className="text-sm font-medium">
|
||||||
<div className="rounded-xl border border-border/40 bg-background/50 p-3 flex flex-col gap-1">
|
Minimum level
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">
|
</Label>
|
||||||
Active Status
|
<Select
|
||||||
</span>
|
value={draft.level}
|
||||||
<div className="flex items-center gap-2">
|
onValueChange={(value) =>
|
||||||
<span
|
setDraft((c) => ({ ...c, level: value as LogsConfig['level'] }))
|
||||||
className={cn(
|
}
|
||||||
'text-[11px] font-semibold uppercase tracking-[0.1em]',
|
>
|
||||||
config.enabled ? 'text-emerald-500' : 'text-zinc-500'
|
<SelectTrigger id="logs-config-level" className="h-9">
|
||||||
)}
|
<SelectValue />
|
||||||
>
|
</SelectTrigger>
|
||||||
{config.enabled ? 'Live' : 'Off'}
|
<SelectContent>
|
||||||
</span>
|
<SelectItem value="error">Error only</SelectItem>
|
||||||
</div>
|
<SelectItem value="warn">Warn and above</SelectItem>
|
||||||
</div>
|
<SelectItem value="info">Info and above</SelectItem>
|
||||||
<div className="rounded-xl border border-border/40 bg-background/50 p-3 flex flex-col gap-1">
|
<SelectItem value="debug">Full debug</SelectItem>
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">
|
</SelectContent>
|
||||||
Redaction
|
</Select>
|
||||||
</span>
|
</div>
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'text-[11px] font-semibold uppercase tracking-[0.1em]',
|
|
||||||
config.redact ? 'text-primary' : 'text-muted-foreground/40'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{config.redact ? 'Enforced' : 'Plain'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between gap-4 rounded-xl border border-border/40 bg-background/20 px-4 py-3 transition-colors hover:bg-background/40">
|
<Label htmlFor="logs-rotate-mb" className="text-sm font-medium">
|
||||||
<div className="space-y-0.5">
|
Rotation (MB)
|
||||||
<Label
|
</Label>
|
||||||
htmlFor="logs-enabled"
|
<Input
|
||||||
className="text-[12px] font-semibold uppercase tracking-[0.12em]"
|
id="logs-rotate-mb"
|
||||||
>
|
type="number"
|
||||||
Pipeline
|
min={1}
|
||||||
</Label>
|
value={draft.rotate_mb}
|
||||||
<p className="text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground/55">
|
onChange={(e) =>
|
||||||
Enable structured logging
|
setDraft((c) => ({
|
||||||
</p>
|
...c,
|
||||||
</div>
|
rotate_mb: parseInteger(e.target.value, c.rotate_mb),
|
||||||
<Switch
|
}))
|
||||||
id="logs-enabled"
|
}
|
||||||
checked={draft.enabled}
|
className="h-9"
|
||||||
onCheckedChange={(checked) =>
|
/>
|
||||||
setDraft((current) => ({ ...current, enabled: checked }))
|
|
||||||
}
|
|
||||||
className="data-[state=checked]:bg-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4 rounded-xl border border-border/40 bg-background/20 px-4 py-3 transition-colors hover:bg-background/40">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<Label
|
|
||||||
htmlFor="logs-redact"
|
|
||||||
className="text-[12px] font-semibold uppercase tracking-[0.12em]"
|
|
||||||
>
|
|
||||||
Masking
|
|
||||||
</Label>
|
|
||||||
<p className="text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground/55">
|
|
||||||
Sanitize payload data
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="logs-redact"
|
|
||||||
checked={draft.redact}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setDraft((current) => ({ ...current, redact: checked }))
|
|
||||||
}
|
|
||||||
className="data-[state=checked]:bg-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="space-y-4 pt-2">
|
<Label htmlFor="logs-retain-days" className="text-sm font-medium">
|
||||||
<div className="space-y-2">
|
Retention (days)
|
||||||
<div className="flex items-center gap-2 px-1">
|
</Label>
|
||||||
<ShieldAlert className="h-3 w-3 text-primary/40" />
|
<Input
|
||||||
<Label
|
id="logs-retain-days"
|
||||||
htmlFor="logs-config-level"
|
type="number"
|
||||||
className="text-[10px] font-semibold uppercase tracking-[0.12em] text-foreground/70"
|
min={1}
|
||||||
>
|
value={draft.retain_days}
|
||||||
Minimum Operational Threshold
|
onChange={(e) =>
|
||||||
</Label>
|
setDraft((c) => ({
|
||||||
</div>
|
...c,
|
||||||
<Select
|
retain_days: parseInteger(e.target.value, c.retain_days),
|
||||||
value={draft.level}
|
}))
|
||||||
onValueChange={(value) =>
|
}
|
||||||
setDraft((current) => ({ ...current, level: value as LogsConfig['level'] }))
|
className="h-9"
|
||||||
}
|
/>
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
id="logs-config-level"
|
|
||||||
className="h-10 rounded-xl border-2 border-border/40 bg-background/50 text-[12px] font-semibold uppercase tracking-[0.1em] focus:ring-0"
|
|
||||||
>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="border-2 border-border bg-card">
|
|
||||||
<SelectItem
|
|
||||||
value="error"
|
|
||||||
className="text-[11px] font-semibold uppercase tracking-[0.1em]"
|
|
||||||
>
|
|
||||||
Error Only
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
value="warn"
|
|
||||||
className="text-[11px] font-semibold uppercase tracking-[0.1em]"
|
|
||||||
>
|
|
||||||
Warn + Above
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
value="info"
|
|
||||||
className="text-[11px] font-semibold uppercase tracking-[0.1em]"
|
|
||||||
>
|
|
||||||
Info + Above
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem
|
|
||||||
value="debug"
|
|
||||||
className="text-[11px] font-semibold uppercase tracking-[0.1em]"
|
|
||||||
>
|
|
||||||
Full Debug
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="logs-rotate-mb"
|
|
||||||
className="px-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-foreground/50"
|
|
||||||
>
|
|
||||||
Rotation (MB)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="logs-rotate-mb"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
className="h-10 rounded-xl border-2 border-border/40 bg-background/50 font-mono text-[12px] font-medium focus-visible:ring-0"
|
|
||||||
value={draft.rotate_mb}
|
|
||||||
onChange={(event) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
rotate_mb: parseInteger(event.target.value, current.rotate_mb),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label
|
|
||||||
htmlFor="logs-retain-days"
|
|
||||||
className="px-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-foreground/50"
|
|
||||||
>
|
|
||||||
Retain (Days)
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="logs-retain-days"
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
className="h-10 rounded-xl border-2 border-border/40 bg-background/50 font-mono text-[12px] font-medium focus-visible:ring-0"
|
|
||||||
value={draft.retain_days}
|
|
||||||
onChange={(event) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
retain_days: parseInteger(event.target.value, current.retain_days),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 pt-4 border-t border-border">
|
|
||||||
<Button
|
|
||||||
onClick={() => onSave(draft)}
|
|
||||||
disabled={!isDirty || isPending}
|
|
||||||
className="h-10 w-full gap-2 rounded-xl bg-primary text-[11px] font-semibold uppercase tracking-[0.14em] shadow-lg shadow-primary/20 transition-all hover:scale-[1.02] active:scale-[0.98]"
|
|
||||||
>
|
|
||||||
<Save className="h-3.5 w-3.5" />
|
|
||||||
{/* TODO i18n: missing key for "Commit Changes" */}
|
|
||||||
Commit Changes
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setDraft(config)}
|
|
||||||
disabled={!isDirty || isPending}
|
|
||||||
className="h-9 gap-2 text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/45 hover:text-foreground"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-3 w-3" />
|
|
||||||
{/* TODO i18n: missing key for "Rollback Draft" */}
|
|
||||||
Rollback Draft
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between bg-muted/20 px-5 py-2">
|
<div className="flex items-center gap-2 border-t border-border pt-3">
|
||||||
<div className="flex items-center gap-1.5">
|
<Button type="submit" disabled={!isDirty || isPending} size="sm" className="gap-1.5">
|
||||||
<Activity className="h-2.5 w-2.5 text-primary/40" />
|
<Save className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
<span className="text-[9px] font-medium uppercase tracking-[0.12em] text-foreground/30">
|
Save changes
|
||||||
Operational Logic v3.4
|
</Button>
|
||||||
</span>
|
<Button
|
||||||
</div>
|
type="button"
|
||||||
{isDirty && (
|
variant="ghost"
|
||||||
<div className="flex items-center gap-1">
|
size="sm"
|
||||||
<div className="h-1 w-1 rounded-full bg-amber-500 animate-pulse" />
|
disabled={!isDirty || isPending}
|
||||||
<span className="text-[9px] font-medium uppercase tracking-[0.12em] text-amber-500/70">
|
onClick={() => setDraft(config)}
|
||||||
Pending
|
className="gap-1.5"
|
||||||
</span>
|
>
|
||||||
</div>
|
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
)}
|
Reset
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,271 +1,269 @@
|
|||||||
import {
|
import { useMemo, useState } from 'react';
|
||||||
FileJson,
|
import { Copy, Eye, EyeOff, GitBranch } from 'lucide-react';
|
||||||
Info,
|
import { Button } from '@/components/ui/button';
|
||||||
ShieldCheck,
|
|
||||||
Terminal,
|
|
||||||
Fingerprint,
|
|
||||||
Database,
|
|
||||||
Cpu,
|
|
||||||
Activity,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import type { LogsEntry } from '@/lib/api-client';
|
import type { LogsEntry } from '@/lib/api-client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { LogLevelBadge } from './log-level-badge';
|
import { LogLevelBadge } from './log-level-badge';
|
||||||
|
import { LogsEmpty } from './logs-empty';
|
||||||
import { formatJson } from './utils';
|
import { formatJson } from './utils';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { FOCUS_RING, MONO_NUMERIC } from './tokens';
|
||||||
|
|
||||||
function MetaRow({
|
export interface LogsDetailPanelProps {
|
||||||
label,
|
entry: LogsEntry | null;
|
||||||
value,
|
sourceLabel?: string;
|
||||||
icon: Icon,
|
/** Optional: when provided, "Show trace" button surfaces & calls this. */
|
||||||
}: {
|
onShowTrace?: (requestId: string) => void;
|
||||||
|
/** When true, redact metadata leaves until user reveals. */
|
||||||
|
redact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewRow {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value: string | number | null | undefined;
|
||||||
icon?: LucideIcon;
|
mono?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverviewRows(entry: LogsEntry, sourceLabel?: string): OverviewRow[] {
|
||||||
|
return [
|
||||||
|
{ label: 'Time', value: new Date(entry.timestamp).toISOString(), mono: true },
|
||||||
|
{ label: 'Level', value: entry.level },
|
||||||
|
{ label: 'Module', value: entry.module ?? '—' },
|
||||||
|
{ label: 'Stage', value: entry.stage ?? '—' },
|
||||||
|
{ label: 'Request ID', value: entry.requestId ?? '—', mono: true },
|
||||||
|
{
|
||||||
|
label: 'Latency',
|
||||||
|
value:
|
||||||
|
entry.latencyMs !== undefined && entry.latencyMs !== null ? `${entry.latencyMs}ms` : '—',
|
||||||
|
mono: true,
|
||||||
|
},
|
||||||
|
{ label: 'Source', value: sourceLabel ?? entry.source },
|
||||||
|
{ label: 'Run ID', value: entry.runId ?? '—', mono: true },
|
||||||
|
{ label: 'Process ID', value: entry.processId ?? '—', mono: true },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaTree({
|
||||||
|
value,
|
||||||
|
redact,
|
||||||
|
depth = 0,
|
||||||
|
}: {
|
||||||
|
value: unknown;
|
||||||
|
redact: boolean;
|
||||||
|
depth?: number;
|
||||||
}) {
|
}) {
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
if (depth > 8) return <span className="text-muted-foreground">…</span>;
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return <span className="text-muted-foreground">null</span>;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||||
|
if (redact && typeof value === 'string' && value.length > 0 && !revealed) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground">redacted</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRevealed(true)}
|
||||||
|
aria-label="Reveal value"
|
||||||
|
className={cn('rounded p-0.5 text-muted-foreground hover:text-foreground', FOCUS_RING)}
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className={cn('text-foreground/90', typeof value !== 'string' && MONO_NUMERIC)}>
|
||||||
|
{String(value)}
|
||||||
|
</span>
|
||||||
|
{redact && typeof value === 'string' && revealed ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRevealed(false)}
|
||||||
|
aria-label="Hide value"
|
||||||
|
className={cn('rounded p-0.5 text-muted-foreground hover:text-foreground', FOCUS_RING)}
|
||||||
|
>
|
||||||
|
<EyeOff className="h-3 w-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return (
|
||||||
|
<ul className="border-l border-border/60 pl-3">
|
||||||
|
{value.slice(0, 50).map((v, i) => (
|
||||||
|
<li key={i} className="text-[12px]">
|
||||||
|
<span className="text-muted-foreground">[{i}]</span>{' '}
|
||||||
|
<MetaTree value={v} redact={redact} depth={depth + 1} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{value.length > 50 ? (
|
||||||
|
<li className="text-[12px] text-muted-foreground">… +{value.length - 50} more</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// object
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const keys = Object.keys(obj);
|
||||||
return (
|
return (
|
||||||
<div className="group relative flex flex-col gap-1.5 rounded-xl border border-border/40 bg-background/40 p-3 transition-all hover:bg-background/80 hover:shadow-lg hover:shadow-black/5">
|
<ul className="border-l border-border/60 pl-3">
|
||||||
<div className="flex items-center justify-between gap-2">
|
{keys.map((k) => (
|
||||||
<div className="flex items-center gap-2">
|
<li key={k} className="text-[12px]">
|
||||||
{Icon && (
|
<span className="font-medium text-foreground/80">{k}</span>:{' '}
|
||||||
<Icon className="h-3 w-3 text-primary/40 group-hover:text-primary transition-colors" />
|
<MetaTree value={obj[k]} redact={redact} depth={depth + 1} />
|
||||||
)}
|
</li>
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70 transition-colors group-hover:text-primary/60">
|
))}
|
||||||
{label}
|
</ul>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-1 w-1 rounded-full bg-border/40 group-hover:bg-primary/40 transition-colors" />
|
|
||||||
</div>
|
|
||||||
<p className="truncate font-mono text-[13px] font-medium tracking-tight text-foreground/85 transition-colors group-hover:text-foreground">
|
|
||||||
{value}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyText(text: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch {
|
||||||
|
// ignore — copy is best effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function LogsDetailPanel({
|
export function LogsDetailPanel({
|
||||||
entry,
|
entry,
|
||||||
sourceLabel,
|
sourceLabel,
|
||||||
}: {
|
onShowTrace,
|
||||||
entry: LogsEntry | null;
|
redact = false,
|
||||||
sourceLabel?: string;
|
}: LogsDetailPanelProps) {
|
||||||
}) {
|
const overviewRows = useMemo(
|
||||||
const { t } = useTranslation();
|
() => (entry ? buildOverviewRows(entry, sourceLabel) : []),
|
||||||
|
[entry, sourceLabel]
|
||||||
|
);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return (
|
return <LogsEmpty variant="selection" />;
|
||||||
<div className="flex h-full flex-col items-center justify-center p-8 text-center animate-in fade-in duration-1000">
|
|
||||||
<div className="relative mb-8">
|
|
||||||
<div className="absolute inset-0 animate-ping rounded-full bg-primary/5 p-12" />
|
|
||||||
<div className="relative rounded-full border-2 border-dashed border-border/40 p-10 bg-muted/5">
|
|
||||||
<Terminal className="h-10 w-10 text-muted-foreground/20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="max-w-xs space-y-3">
|
|
||||||
<h3 className="text-[15px] font-semibold uppercase tracking-[0.14em] text-foreground/65">
|
|
||||||
{/* TODO i18n: missing key for "Inspector Standby" */}
|
|
||||||
Inspector Standby
|
|
||||||
</h3>
|
|
||||||
<p className="text-[13px] leading-relaxed text-muted-foreground/55 font-medium">
|
|
||||||
Select a telemetry node from the active data queue to perform deep analysis of its
|
|
||||||
operational context.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawJson = formatJson(entry);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-card/30 backdrop-blur-sm animate-in fade-in slide-in-from-right-4 duration-500">
|
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||||
{/* Tactical Inspector Header */}
|
<header className="flex shrink-0 flex-col gap-2 border-b border-border px-4 py-3">
|
||||||
<div className="relative shrink-0 border-b border-border bg-card/60 p-6 shadow-sm overflow-hidden">
|
<div className="flex items-center gap-2">
|
||||||
{/* Pattern Overlay */}
|
<LogLevelBadge level={entry.level} />
|
||||||
<div className="absolute inset-0 opacity-[0.03] pointer-events-none [background-image:radial-gradient(circle_at_center,var(--primary)_1px,transparent_0)] [background-size:16px_16px]" />
|
<span className="truncate text-[12px] text-muted-foreground">{entry.event}</span>
|
||||||
|
|
||||||
<div className="relative space-y-6">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<LogLevelBadge level={entry.level} className="h-5 px-3 shadow-lg shadow-black/5" />
|
|
||||||
<div className="h-4 w-px bg-border/60" />
|
|
||||||
<div className="flex items-center gap-2 rounded-full border border-border bg-background/50 px-3 py-1 shadow-inner">
|
|
||||||
<Database className="h-3 w-3 text-primary/60" />
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-[0.12em] text-foreground/75">
|
|
||||||
{sourceLabel ?? entry.source}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Activity className="h-3 w-3 animate-pulse text-emerald-500" />
|
|
||||||
<span className="font-mono text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground/45">
|
|
||||||
{new Date(entry.timestamp).toLocaleTimeString(undefined, {
|
|
||||||
hour12: false,
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-1 w-4 rounded-full bg-primary/40" />
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-primary/65">
|
|
||||||
Event
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-[24px] font-semibold tracking-tight text-foreground leading-tight break-words">
|
|
||||||
{entry.event}
|
|
||||||
</h2>
|
|
||||||
<div className="rounded-xl border-l-4 border-primary/20 bg-muted/20 p-4 shadow-inner">
|
|
||||||
<p className="text-[14px] font-medium leading-relaxed text-foreground/85 selection:bg-primary/20">
|
|
||||||
{entry.message}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className="truncate text-sm font-medium text-foreground">{entry.message}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
<ScrollArea className="flex-1">
|
<Button
|
||||||
<div className="p-6">
|
variant="ghost"
|
||||||
<Tabs defaultValue="details" className="space-y-8">
|
size="sm"
|
||||||
<TabsList className="grid h-auto w-full grid-cols-2 gap-1 rounded-xl border border-border/60 bg-muted/40 p-1">
|
onClick={() => void copyText(rawJson)}
|
||||||
<TabsTrigger
|
className={cn('h-7 gap-1.5 px-2 text-xs', FOCUS_RING)}
|
||||||
value="details"
|
>
|
||||||
className="min-w-0 gap-2 rounded-lg px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.1em] transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-sm"
|
<Copy className="h-3 w-3" aria-hidden="true" />
|
||||||
>
|
Copy JSON
|
||||||
<Info className="h-3.5 w-3.5" />
|
</Button>
|
||||||
{t('logsDetailPanel.details')}
|
{entry.requestId ? (
|
||||||
</TabsTrigger>
|
<Button
|
||||||
<TabsTrigger
|
variant="ghost"
|
||||||
value="raw"
|
size="sm"
|
||||||
className="min-w-0 gap-2 rounded-lg px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.1em] transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-sm"
|
onClick={() => entry.requestId && void copyText(entry.requestId)}
|
||||||
>
|
className={cn('h-7 gap-1.5 px-2 text-xs', FOCUS_RING)}
|
||||||
<FileJson className="h-3.5 w-3.5" />
|
|
||||||
{/* TODO i18n: missing key for "Raw Context" */}
|
|
||||||
Raw Context
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent
|
|
||||||
value="details"
|
|
||||||
className="mt-0 space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-500"
|
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<Copy className="h-3 w-3" aria-hidden="true" />
|
||||||
<MetaRow
|
Copy requestId
|
||||||
label="Entry Signature"
|
</Button>
|
||||||
value={entry.id.slice(0, 16) + '...'}
|
) : null}
|
||||||
icon={Fingerprint}
|
{entry.requestId && onShowTrace ? (
|
||||||
/>
|
<Button
|
||||||
<MetaRow label="Telemetry Origin" value={entry.source} icon={Database} />
|
variant="ghost"
|
||||||
<MetaRow label="Process ID" value={entry.processId ?? 'NA'} icon={Cpu} />
|
size="sm"
|
||||||
<MetaRow
|
onClick={() => entry.requestId && onShowTrace(entry.requestId)}
|
||||||
label="Operational Run"
|
className={cn('h-7 gap-1.5 px-2 text-xs', FOCUS_RING)}
|
||||||
value={entry.runId?.slice(0, 8) ?? 'NA'}
|
>
|
||||||
icon={ShieldCheck}
|
<GitBranch className="h-3 w-3" aria-hidden="true" />
|
||||||
/>
|
Show trace
|
||||||
</div>
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div className="relative overflow-hidden rounded-[2rem] border border-border bg-muted/10 p-1 shadow-inner group">
|
<Tabs defaultValue="overview" className="flex min-h-0 flex-1 flex-col">
|
||||||
{/* Background Scanline */}
|
<TabsList className="m-2 h-8 w-fit bg-muted/40">
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-primary/[0.03] to-transparent h-[200%] -top-full animate-[scan_8s_linear_infinite] pointer-events-none" />
|
<TabsTrigger value="overview" className="text-xs">
|
||||||
|
Overview
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="context" className="text-xs">
|
||||||
|
Context
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="raw" className="text-xs">
|
||||||
|
Raw
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
<div className="rounded-[calc(2rem-4px)] border border-dashed border-border/40 bg-background/50 p-6 space-y-4">
|
<TabsContent value="overview" className="m-0 min-h-0 flex-1">
|
||||||
<div className="flex items-center gap-3">
|
<ScrollArea className="h-full">
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/5 border border-primary/20 text-primary shadow-inner">
|
<dl className="grid grid-cols-[120px_minmax(0,1fr)] gap-x-3 gap-y-2 px-4 pb-4">
|
||||||
<Terminal className="h-4 w-4" />
|
{overviewRows.map((row) => (
|
||||||
</div>
|
<DetailRow key={row.label} {...row} />
|
||||||
<div className="space-y-0.5">
|
))}
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-primary">
|
{entry.error ? (
|
||||||
Automated Summary
|
<div className="col-span-2 mt-2 rounded border border-red-500/30 bg-red-500/5 p-3">
|
||||||
</p>
|
<p className="text-xs font-semibold text-red-700 dark:text-red-400">
|
||||||
<p className="text-[10px] font-medium uppercase tracking-[0.1em] text-muted-foreground/55">
|
{entry.error.code ?? 'Error'}: {entry.error.message}
|
||||||
Quick interpretation
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-[13px] leading-relaxed text-muted-foreground/80 font-medium">
|
|
||||||
This telemetry node was captured from{' '}
|
|
||||||
<span className="rounded bg-muted/40 px-1.5 py-0.5 font-semibold text-foreground">
|
|
||||||
{sourceLabel ?? entry.source}
|
|
||||||
</span>
|
|
||||||
operating at the{' '}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'rounded px-1.5 py-0.5 text-[11px] font-semibold uppercase tracking-[0.08em]',
|
|
||||||
entry.level === 'error'
|
|
||||||
? 'bg-red-500/10 text-red-500'
|
|
||||||
: entry.level === 'warn'
|
|
||||||
? 'bg-amber-500/10 text-amber-500'
|
|
||||||
: entry.level === 'info'
|
|
||||||
? 'bg-sky-500/10 text-sky-500'
|
|
||||||
: 'bg-zinc-500/10 text-zinc-500'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{entry.level}
|
|
||||||
</span>{' '}
|
|
||||||
threshold. The operational payload indicates an event state of{' '}
|
|
||||||
<span className="font-semibold text-foreground">{entry.event}</span>.
|
|
||||||
</p>
|
</p>
|
||||||
|
{entry.error.stack ? (
|
||||||
|
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap font-mono text-[11px] text-red-700/80 dark:text-red-300/80">
|
||||||
|
{entry.error.stack}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
</TabsContent>
|
</dl>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent
|
<TabsContent value="context" className="m-0 min-h-0 flex-1">
|
||||||
value="raw"
|
<ScrollArea className="h-full">
|
||||||
className="mt-0 animate-in fade-in slide-in-from-bottom-2 duration-500"
|
<div className="px-4 pb-4 text-[12px] leading-relaxed">
|
||||||
|
{entry.metadata && Object.keys(entry.metadata).length > 0 ? (
|
||||||
|
<MetaTree value={entry.metadata} redact={redact} />
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">No structured metadata.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="raw" className="m-0 min-h-0 flex-1">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<pre
|
||||||
|
className={cn(
|
||||||
|
'm-0 px-4 pb-4 text-[12px] leading-relaxed text-foreground/90',
|
||||||
|
MONO_NUMERIC
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="group relative rounded-2xl border-2 border-border bg-zinc-950 p-1 shadow-2xl transition-all hover:border-primary/20">
|
{rawJson}
|
||||||
{/* Copy HUD */}
|
</pre>
|
||||||
<div className="absolute right-4 top-4 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
|
</ScrollArea>
|
||||||
<div className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[9px] font-medium uppercase tracking-[0.12em] text-white/45 backdrop-blur-md">
|
</TabsContent>
|
||||||
JSON.RAW.MODE
|
</Tabs>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="h-[30rem] w-full rounded-xl p-6">
|
|
||||||
<pre className="font-mono text-[12px] leading-relaxed tracking-tight text-zinc-400 selection:bg-primary/40 selection:text-primary-foreground">
|
|
||||||
{formatJson({
|
|
||||||
id: entry.id,
|
|
||||||
timestamp: entry.timestamp,
|
|
||||||
level: entry.level,
|
|
||||||
source: entry.source,
|
|
||||||
event: entry.event,
|
|
||||||
message: entry.message,
|
|
||||||
processId: entry.processId,
|
|
||||||
runId: entry.runId,
|
|
||||||
context: entry.context ?? {},
|
|
||||||
})}
|
|
||||||
</pre>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center justify-between border-t border-border bg-muted/5 px-6 py-3">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" />
|
|
||||||
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/35">
|
|
||||||
Node Verified
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-3 w-px bg-border/40" />
|
|
||||||
<span className="text-[10px] font-medium tabular-nums uppercase tracking-[0.12em] text-foreground/35">
|
|
||||||
{entry.id.slice(0, 8)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-full bg-primary/5 px-2 py-0.5 border border-primary/10">
|
|
||||||
<span className="text-[9px] font-medium uppercase tracking-[0.12em] text-primary/65">
|
|
||||||
CCS-TEC-v3
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value, mono }: OverviewRow) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<dt className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</dt>
|
||||||
|
<dd
|
||||||
|
className={cn('truncate text-[13px] text-foreground/90', mono && MONO_NUMERIC)}
|
||||||
|
title={value === null || value === undefined ? undefined : String(value)}
|
||||||
|
>
|
||||||
|
{value ?? '—'}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Inbox, FilterX, MousePointer, EyeOff } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export type LogsEmptyVariant = 'selection' | 'noData' | 'noResults' | 'selectionOutOfScope';
|
||||||
|
|
||||||
|
export interface LogsEmptyProps {
|
||||||
|
variant: LogsEmptyVariant;
|
||||||
|
onClearFilters?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COPY: Record<LogsEmptyVariant, { title: string; body: string; icon: typeof Inbox }> = {
|
||||||
|
selection: {
|
||||||
|
title: 'No entry selected',
|
||||||
|
body: 'Pick a row in the list to inspect its context. Use j/k to move and Enter to focus the detail.',
|
||||||
|
icon: MousePointer,
|
||||||
|
},
|
||||||
|
noData: {
|
||||||
|
title: 'No log activity yet',
|
||||||
|
body: 'Once requests flow through the system, structured entries will appear here.',
|
||||||
|
icon: Inbox,
|
||||||
|
},
|
||||||
|
noResults: {
|
||||||
|
title: 'No entries match your filters',
|
||||||
|
body: 'Adjust source, level, search, or time window to see more entries.',
|
||||||
|
icon: FilterX,
|
||||||
|
},
|
||||||
|
selectionOutOfScope: {
|
||||||
|
title: 'Selected entry not visible under current filter',
|
||||||
|
body: 'Clear your filter or select another row to inspect details.',
|
||||||
|
icon: EyeOff,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LogsEmpty({ variant, onClearFilters }: LogsEmptyProps) {
|
||||||
|
const { title, body, icon: Icon } = COPY[variant];
|
||||||
|
const showClear = variant === 'selectionOutOfScope' || variant === 'noResults';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="flex h-full flex-col items-center justify-center gap-3 px-6 py-10 text-center"
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-border bg-muted/30 text-muted-foreground">
|
||||||
|
<Icon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||||
|
<p className="max-w-xs text-[13px] leading-relaxed text-muted-foreground">{body}</p>
|
||||||
|
{showClear && onClearFilters ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={onClearFilters} className="mt-1">
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,31 @@
|
|||||||
import { Activity, ArrowRight, Inbox, Loader2 } from 'lucide-react';
|
import { useCallback, useMemo, useState, type ReactNode } from 'react';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { Virtuoso } from 'react-virtuoso';
|
||||||
import type { LogsEntry } from '@/lib/api-client';
|
import type { LogsEntry } from '@/lib/api-client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { LogLevelBadge } from './log-level-badge';
|
import { LogsRow } from './logs-row';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { LogsTraceRow } from './logs-trace-row';
|
||||||
|
import { type RowDensity } from './tokens';
|
||||||
|
import { LogsEmpty } from './logs-empty';
|
||||||
|
import { deriveTraceGroups, type DerivedItem } from './derive-trace-groups';
|
||||||
|
|
||||||
|
export interface LogsEntryListProps {
|
||||||
|
entries: LogsEntry[];
|
||||||
|
selectedEntryId: string | null;
|
||||||
|
onSelect: (entryId: string) => void;
|
||||||
|
sourceLabels: Record<string, string>;
|
||||||
|
isLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
/**
|
||||||
|
* Slot for live-tail controls (pause/resume + pending pill).
|
||||||
|
* Phase-04 owns the controls; phase-03 just provides the slot.
|
||||||
|
*/
|
||||||
|
liveTailSlot?: ReactNode;
|
||||||
|
/** Density toggle (phase-04 may surface a switch). Defaults to cozy. */
|
||||||
|
density?: RowDensity;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLS_TEMPLATE =
|
||||||
|
'grid grid-cols-[88px_64px_140px_minmax(0,1fr)_72px_88px] items-center gap-3 px-3';
|
||||||
|
|
||||||
export function LogsEntryList({
|
export function LogsEntryList({
|
||||||
entries,
|
entries,
|
||||||
@@ -11,218 +33,116 @@ export function LogsEntryList({
|
|||||||
onSelect,
|
onSelect,
|
||||||
sourceLabels,
|
sourceLabels,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching: _isFetching,
|
||||||
}: {
|
liveTailSlot,
|
||||||
entries: LogsEntry[];
|
density = 'cozy',
|
||||||
selectedEntryId: string | null;
|
}: LogsEntryListProps) {
|
||||||
onSelect: (entryId: string) => void;
|
const items = useMemo(() => deriveTraceGroups(entries), [entries]);
|
||||||
sourceLabels: Record<string, string>;
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
isLoading: boolean;
|
|
||||||
isFetching: boolean;
|
const toggle = useCallback((requestId: string) => {
|
||||||
}) {
|
setExpanded((prev) => {
|
||||||
const { t } = useTranslation();
|
const next = new Set(prev);
|
||||||
|
if (next.has(requestId)) next.delete(requestId);
|
||||||
|
else next.add(requestId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-expand the trace whose child is currently selected.
|
||||||
|
const effectiveExpanded = useMemo(() => {
|
||||||
|
if (!selectedEntryId) return expanded;
|
||||||
|
const owning = items.find(
|
||||||
|
(it) => it.kind === 'trace' && it.children.some((c) => c.id === selectedEntryId)
|
||||||
|
);
|
||||||
|
if (owning && owning.kind === 'trace' && !expanded.has(owning.requestId)) {
|
||||||
|
const next = new Set(expanded);
|
||||||
|
next.add(owning.requestId);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return expanded;
|
||||||
|
}, [expanded, items, selectedEntryId]);
|
||||||
|
|
||||||
|
const renderItem = useCallback(
|
||||||
|
(_index: number, item: DerivedItem) => {
|
||||||
|
if (item.kind === 'trace') {
|
||||||
|
return (
|
||||||
|
<LogsTraceRow
|
||||||
|
group={item}
|
||||||
|
isExpanded={effectiveExpanded.has(item.requestId)}
|
||||||
|
selectedEntryId={selectedEntryId}
|
||||||
|
density={density}
|
||||||
|
sourceLabel={sourceLabels[item.source] ?? item.source}
|
||||||
|
onToggle={toggle}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<LogsRow
|
||||||
|
entry={item.entry}
|
||||||
|
isSelected={item.entry.id === selectedEntryId}
|
||||||
|
density={density}
|
||||||
|
sourceLabel={sourceLabels[item.entry.source] ?? item.entry.source}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[density, effectiveExpanded, onSelect, selectedEntryId, sourceLabels, toggle]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-background/50 backdrop-blur-sm">
|
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card/40 px-6 py-3 shadow-sm">
|
{/* Sticky column header outside Virtuoso scroll body */}
|
||||||
<div className="flex items-center gap-4">
|
<div
|
||||||
<div className="flex items-center gap-2">
|
role="row"
|
||||||
<div className="h-2 w-2 animate-pulse rounded-full bg-primary shadow-[0_0_12px_rgba(var(--primary),0.6)]" />
|
className={cn(
|
||||||
<h2 className="text-[12px] font-semibold uppercase tracking-[0.14em] text-foreground">
|
COLS_TEMPLATE,
|
||||||
Live Entry Stream
|
'h-8 shrink-0 border-b border-border bg-muted/30 text-[10px] font-medium uppercase tracking-wide text-muted-foreground'
|
||||||
</h2>
|
)}
|
||||||
</div>
|
>
|
||||||
<div className="h-4 w-px bg-border/60" />
|
<span role="columnheader">Time</span>
|
||||||
<div className="flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5">
|
<span role="columnheader">Level</span>
|
||||||
<Activity className="h-3 w-3 text-emerald-500" />
|
<span role="columnheader">Module</span>
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-emerald-600">
|
<span role="columnheader">Message</span>
|
||||||
Live telemetry
|
<span role="columnheader" className="text-right">
|
||||||
</span>
|
Latency
|
||||||
</div>
|
</span>
|
||||||
</div>
|
<span role="columnheader" className="text-right">
|
||||||
<div className="flex items-center gap-3">
|
Request
|
||||||
{isFetching && (
|
</span>
|
||||||
<div className="flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-3 py-1">
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin text-primary" />
|
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-primary">
|
|
||||||
Syncing
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="font-mono text-[10px] font-medium uppercase tracking-[0.16em] text-foreground/35">
|
|
||||||
NODE.01
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-0 border-b border-border bg-muted/30 px-0 py-2 text-[10px] font-semibold uppercase tracking-[0.12em] text-foreground/45">
|
{liveTailSlot ? (
|
||||||
<div className="w-[6.5rem] shrink-0 px-6">{t('logsConfig.time')}</div>
|
<div className="flex h-9 shrink-0 items-center justify-between border-b border-border bg-background px-3">
|
||||||
<div className="w-14 shrink-0 border-l border-border/10 px-2 text-center">
|
{liveTailSlot}
|
||||||
{t('logsConfig.level')}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[15rem] shrink-0 border-l border-border/10 px-4">
|
) : null}
|
||||||
{t('logsConfig.source')}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 border-l border-border/10 px-4">{t('logsConfig.message')}</div>
|
|
||||||
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-2 text-center">
|
|
||||||
{t('logsConfig.proc')}
|
|
||||||
</div>
|
|
||||||
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-3 text-center">
|
|
||||||
{t('logsConfig.run')}
|
|
||||||
</div>
|
|
||||||
<div className="w-11 shrink-0 border-l border-border/10 px-2 text-center">
|
|
||||||
{t('logsConfig.open')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div role="grid" className="min-h-0 flex-1">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-1 p-2">
|
<div className="space-y-1 p-2">
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map((item) => (
|
{Array.from({ length: 14 }).map((_, idx) => (
|
||||||
<div
|
<div
|
||||||
key={item}
|
key={idx}
|
||||||
className="h-10 w-full animate-pulse rounded-lg border border-border/5 bg-muted/20"
|
className="h-9 w-full animate-pulse rounded border border-border/40 bg-muted/30"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : entries.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div className="flex h-full animate-in fade-in duration-1000 flex-col items-center justify-center gap-6 px-8 text-center">
|
<LogsEmpty variant="noResults" />
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 animate-ping rounded-full bg-muted/5 p-12" />
|
|
||||||
<div className="relative rounded-full border border-dashed border-border/40 bg-muted/5 p-10">
|
|
||||||
<Inbox className="h-10 w-10 text-muted-foreground/20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-[13px] font-semibold uppercase tracking-[0.14em] text-foreground/55">
|
|
||||||
No matching entries
|
|
||||||
</p>
|
|
||||||
<p className="max-w-[18rem] text-[12px] font-medium leading-relaxed text-muted-foreground/60">
|
|
||||||
Your current source, level, or search filters are hiding the stream. Adjust them to
|
|
||||||
bring entries back into view.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="h-full">
|
<Virtuoso
|
||||||
<div className="flex flex-col">
|
data={items}
|
||||||
{entries.map((entry) => {
|
itemContent={renderItem}
|
||||||
const isSelected = entry.id === selectedEntryId;
|
increaseViewportBy={{ top: 200, bottom: 400 }}
|
||||||
|
followOutput={(atBottom) => (atBottom ? 'auto' : false)}
|
||||||
return (
|
computeItemKey={(_idx, item) =>
|
||||||
<button
|
item.kind === 'trace' ? `t:${item.requestId}` : `l:${item.entry.id}`
|
||||||
key={entry.id}
|
}
|
||||||
type="button"
|
/>
|
||||||
onClick={() => onSelect(entry.id)}
|
|
||||||
className={cn(
|
|
||||||
'group relative flex w-full items-center border-b border-border/5 px-0 py-2.5 text-left transition-all duration-150',
|
|
||||||
isSelected
|
|
||||||
? 'z-10 bg-primary/[0.08] shadow-[inset_4px_0_0_rgba(var(--primary),1)]'
|
|
||||||
: 'bg-transparent hover:bg-muted/30'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-y-0 left-0 w-1 origin-center scale-y-0 bg-primary transition-transform duration-300 group-hover:scale-y-100" />
|
|
||||||
|
|
||||||
<div className="flex w-full items-center gap-0">
|
|
||||||
<div className="flex w-[6.5rem] shrink-0 items-center">
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'px-6 font-mono text-[11px] font-semibold tabular-nums transition-colors',
|
|
||||||
isSelected
|
|
||||||
? 'text-primary'
|
|
||||||
: 'text-foreground/60 group-hover:text-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{new Date(entry.timestamp).toLocaleTimeString(undefined, {
|
|
||||||
hour12: false,
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-0">
|
|
||||||
<div className="flex w-14 shrink-0 items-center justify-center opacity-80 transition-opacity group-hover:opacity-100">
|
|
||||||
<LogLevelBadge
|
|
||||||
level={entry.level}
|
|
||||||
className="origin-center scale-[0.85]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-[15rem] shrink-0 flex-col gap-0.5 overflow-hidden px-4">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'truncate text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors',
|
|
||||||
isSelected
|
|
||||||
? 'text-foreground'
|
|
||||||
: 'text-foreground/50 group-hover:text-foreground/80'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{sourceLabels[entry.source] ?? entry.source}
|
|
||||||
</span>
|
|
||||||
<span className="truncate text-[10px] font-medium uppercase tracking-[0.1em] text-muted-foreground/55">
|
|
||||||
{entry.event}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 px-4">
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'truncate text-[13px] font-medium leading-5 transition-colors',
|
|
||||||
isSelected
|
|
||||||
? 'text-foreground'
|
|
||||||
: 'text-foreground/70 group-hover:text-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{entry.message}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-[5.5rem] shrink-0 items-center justify-center px-2 font-mono text-[10px] font-semibold tabular-nums tracking-[0.12em] text-foreground/30 transition-colors group-hover:text-primary/45">
|
|
||||||
{entry.processId ?? '????'}
|
|
||||||
</div>
|
|
||||||
<div className="flex w-[5.5rem] shrink-0 items-center justify-center px-3 font-mono text-[10px] font-semibold tabular-nums tracking-[0.12em] text-foreground/30 transition-colors group-hover:text-primary/45">
|
|
||||||
{entry.runId?.slice(0, 4).toUpperCase() ?? 'NONE'}
|
|
||||||
</div>
|
|
||||||
<div className="flex w-11 shrink-0 items-center justify-center px-2">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-7 w-7 items-center justify-center rounded-full border transition-all',
|
|
||||||
isSelected
|
|
||||||
? 'animate-in zoom-in duration-300 border-primary/20 bg-primary/10 text-primary'
|
|
||||||
: 'border-transparent text-foreground/20 group-hover:border-border/40 group-hover:bg-background/80 group-hover:text-foreground/55'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ArrowRight className="h-3.5 w-3.5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center justify-between border-t border-border bg-muted/5 px-6 py-2">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/35">
|
|
||||||
Node: CCS-CORE
|
|
||||||
</span>
|
|
||||||
<div className="h-1 w-1 rounded-full bg-border/40" />
|
|
||||||
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/35">
|
|
||||||
Status: Operational
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-[10px] font-medium uppercase tracking-[0.12em] text-foreground/35">
|
|
||||||
Entries: {entries.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { AlertTriangle, RotateCcw } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export interface LogsErrorProps {
|
||||||
|
error: Error | { message?: string } | null;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogsError({ error, onRetry }: LogsErrorProps) {
|
||||||
|
const message = error?.message ?? 'Unknown error fetching logs.';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex h-full flex-col items-center justify-center gap-3 px-6 py-10 text-center"
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-red-500/30 bg-red-500/10 text-red-600 dark:text-red-400">
|
||||||
|
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">Could not load logs</h3>
|
||||||
|
<p className="max-w-sm text-[13px] leading-relaxed text-muted-foreground">{message}</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={onRetry} className="gap-2">
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,28 @@
|
|||||||
import { Search, RefreshCw, Filter, Shield, Zap } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { ChevronDown, Search, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import type { LogsSource } from '@/lib/api-client';
|
import type { LogsSource } from '@/lib/api-client';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { LogsLevelFilter, LogsSourceFilter } from '@/hooks/use-logs';
|
import {
|
||||||
import { getLogLevelOptions } from '@/hooks/use-logs';
|
getLogLevelOptions,
|
||||||
import { useTranslation } from 'react-i18next';
|
getLogsTimeWindowOptions,
|
||||||
|
type LogsLevelFilter,
|
||||||
|
type LogsSourceFilter,
|
||||||
|
type LogsTimeWindow,
|
||||||
|
} from '@/hooks/use-logs';
|
||||||
|
import { FOCUS_RING } from './tokens';
|
||||||
|
|
||||||
export function LogsFilters({
|
export interface LogsFiltersProps {
|
||||||
sources,
|
|
||||||
selectedSource,
|
|
||||||
onSourceChange,
|
|
||||||
selectedLevel,
|
|
||||||
onLevelChange,
|
|
||||||
search,
|
|
||||||
onSearchChange,
|
|
||||||
limit,
|
|
||||||
onLimitChange,
|
|
||||||
onRefresh,
|
|
||||||
isRefreshing,
|
|
||||||
}: {
|
|
||||||
sources: LogsSource[];
|
sources: LogsSource[];
|
||||||
selectedSource: LogsSourceFilter;
|
selectedSource: LogsSourceFilter;
|
||||||
onSourceChange: (value: LogsSourceFilter) => void;
|
onSourceChange: (value: LogsSourceFilter) => void;
|
||||||
@@ -32,169 +34,325 @@ export function LogsFilters({
|
|||||||
onLimitChange: (value: number) => void;
|
onLimitChange: (value: number) => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
/** Phase-04 advanced filters (optional for back-compat). */
|
||||||
|
moduleFilter?: string;
|
||||||
|
onModuleChange?: (v: string) => void;
|
||||||
|
stageFilter?: string;
|
||||||
|
onStageChange?: (v: string) => void;
|
||||||
|
requestIdFilter?: string;
|
||||||
|
onRequestIdChange?: (v: string) => void;
|
||||||
|
timeWindow?: LogsTimeWindow;
|
||||||
|
onTimeWindowChange?: (v: LogsTimeWindow) => void;
|
||||||
|
onClearAll?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChipProps {
|
||||||
|
label: string;
|
||||||
|
onRemove: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({ label, onRemove }: ChipProps) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-[11px] text-foreground/80">
|
||||||
|
{label}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
aria-label={`Remove ${label}`}
|
||||||
|
className={cn('rounded-full p-0.5 hover:bg-muted', FOCUS_RING)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogsFilters({
|
||||||
|
sources,
|
||||||
|
selectedSource,
|
||||||
|
onSourceChange,
|
||||||
|
selectedLevel,
|
||||||
|
onLevelChange,
|
||||||
|
search,
|
||||||
|
onSearchChange,
|
||||||
|
limit,
|
||||||
|
onLimitChange,
|
||||||
|
onRefresh: _onRefresh,
|
||||||
|
isRefreshing: _isRefreshing,
|
||||||
|
moduleFilter = '',
|
||||||
|
onModuleChange,
|
||||||
|
stageFilter = '',
|
||||||
|
onStageChange,
|
||||||
|
requestIdFilter = '',
|
||||||
|
onRequestIdChange,
|
||||||
|
timeWindow = 'all',
|
||||||
|
onTimeWindowChange,
|
||||||
|
onClearAll,
|
||||||
|
}: LogsFiltersProps) {
|
||||||
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
const levels = getLogLevelOptions();
|
const levels = getLogLevelOptions();
|
||||||
const limits = [50, 100, 150, 250];
|
const limits = [50, 100, 150, 250];
|
||||||
|
const timeWindows = getLogsTimeWindowOptions();
|
||||||
|
|
||||||
|
const activeChips: Array<{ key: string; label: string; clear: () => void }> = [];
|
||||||
|
if (selectedLevel !== 'all') {
|
||||||
|
activeChips.push({
|
||||||
|
key: 'level',
|
||||||
|
label: `level: ${selectedLevel}`,
|
||||||
|
clear: () => onLevelChange('all'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (selectedSource !== 'all') {
|
||||||
|
const label = sources.find((s) => s.source === selectedSource)?.label ?? selectedSource;
|
||||||
|
activeChips.push({
|
||||||
|
key: 'source',
|
||||||
|
label: `source: ${label}`,
|
||||||
|
clear: () => onSourceChange('all'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (search.trim()) {
|
||||||
|
activeChips.push({
|
||||||
|
key: 'search',
|
||||||
|
label: `search: ${search}`,
|
||||||
|
clear: () => onSearchChange(''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (moduleFilter) {
|
||||||
|
activeChips.push({
|
||||||
|
key: 'module',
|
||||||
|
label: `module: ${moduleFilter}`,
|
||||||
|
clear: () => onModuleChange?.(''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (stageFilter) {
|
||||||
|
activeChips.push({
|
||||||
|
key: 'stage',
|
||||||
|
label: `stage: ${stageFilter}`,
|
||||||
|
clear: () => onStageChange?.(''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (requestIdFilter) {
|
||||||
|
activeChips.push({
|
||||||
|
key: 'requestId',
|
||||||
|
label: `requestId: ${requestIdFilter}`,
|
||||||
|
clear: () => onRequestIdChange?.(''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (timeWindow !== 'all') {
|
||||||
|
activeChips.push({
|
||||||
|
key: 'timeWindow',
|
||||||
|
label: `time: ${timeWindow}`,
|
||||||
|
clear: () => onTimeWindowChange?.('all'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 animate-in fade-in slide-in-from-left-2 duration-700">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="space-y-3">
|
{/* Primary row */}
|
||||||
<div className="flex items-center justify-between px-1">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<Label
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-primary shadow-[0_0_8px_rgba(var(--primary),0.5)]" />
|
htmlFor="logs-search"
|
||||||
<Label
|
className="text-[11px] uppercase tracking-wide text-muted-foreground"
|
||||||
htmlFor="logs-search"
|
>
|
||||||
className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80"
|
Search
|
||||||
>
|
</Label>
|
||||||
Payload Search
|
<div className="relative">
|
||||||
</Label>
|
<Search
|
||||||
</div>
|
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
||||||
<Zap className="h-3 w-3 text-primary/20" />
|
aria-hidden="true"
|
||||||
</div>
|
/>
|
||||||
<div className="group relative">
|
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-3.5 flex items-center transition-all group-focus-within:translate-x-1">
|
|
||||||
<Search className="h-3.5 w-3.5 text-foreground/20 group-focus-within:text-primary transition-colors" />
|
|
||||||
</div>
|
|
||||||
<Input
|
<Input
|
||||||
id="logs-search"
|
id="logs-search"
|
||||||
aria-label="Search"
|
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(event) => onSearchChange(event.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
placeholder="Scan for patterns..."
|
placeholder="Search message, event, module"
|
||||||
className="h-11 rounded-xl border-2 border-border/40 bg-background/50 pl-10 text-[13px] font-medium text-foreground placeholder:text-foreground/35 focus-visible:border-primary/40 focus-visible:ring-0 transition-all shadow-inner"
|
className="h-9 pl-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="flex items-center gap-2 px-1">
|
<div className="space-y-2">
|
||||||
<Filter className="h-3 w-3 text-primary/40" />
|
<Label
|
||||||
<Label className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
htmlFor="logs-level"
|
||||||
Source Matrix
|
className="text-[11px] uppercase tracking-wide text-muted-foreground"
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1" aria-label="Source filter">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSourceChange('all')}
|
|
||||||
className={cn(
|
|
||||||
'group relative flex items-center justify-between rounded-lg border px-3 py-2 transition-all active:scale-[0.98]',
|
|
||||||
selectedSource === 'all'
|
|
||||||
? 'border-primary/50 bg-primary/10 text-primary shadow-[0_0_15px_rgba(var(--primary),0.1)]'
|
|
||||||
: 'border-border/40 bg-muted/20 text-foreground/40 hover:border-border hover:bg-muted/40 hover:text-foreground/80'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-[0.12em]">
|
Level
|
||||||
Global Stream
|
</Label>
|
||||||
</span>
|
<Select value={selectedLevel} onValueChange={(v) => onLevelChange(v as LogsLevelFilter)}>
|
||||||
{selectedSource === 'all' && (
|
<SelectTrigger id="logs-level" className="h-9">
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-primary shadow-[0_0_8px_rgba(var(--primary),0.8)]" />
|
<SelectValue />
|
||||||
)}
|
</SelectTrigger>
|
||||||
</button>
|
<SelectContent>
|
||||||
|
{levels.map((opt) => (
|
||||||
<div className="grid grid-cols-2 gap-1 mt-1">
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
{sources.map((source) => (
|
{opt.label}
|
||||||
<button
|
</SelectItem>
|
||||||
key={source.source}
|
))}
|
||||||
type="button"
|
</SelectContent>
|
||||||
onClick={() => onSourceChange(source.source)}
|
</Select>
|
||||||
className={cn(
|
</div>
|
||||||
'rounded-lg border px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-[0.1em] transition-all active:scale-[0.97]',
|
<div className="space-y-2">
|
||||||
selectedSource === source.source
|
<Label
|
||||||
? 'border-primary/50 bg-primary/10 text-primary shadow-sm'
|
htmlFor="logs-source"
|
||||||
: 'border-border/40 bg-muted/20 text-foreground/40 hover:border-border hover:bg-muted/40 hover:text-foreground/80'
|
className="text-[11px] uppercase tracking-wide text-muted-foreground"
|
||||||
)}
|
>
|
||||||
>
|
Source
|
||||||
{source.label}
|
</Label>
|
||||||
</button>
|
<Select
|
||||||
))}
|
value={selectedSource}
|
||||||
</div>
|
onValueChange={(v) => onSourceChange(v as LogsSourceFilter)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="logs-source" className="h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All sources</SelectItem>
|
||||||
|
{sources.map((s) => (
|
||||||
|
<SelectItem key={s.source} value={s.source}>
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Threshold Control */}
|
{/* Advanced */}
|
||||||
<div className="space-y-4">
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||||
<div className="flex items-center gap-2 px-1">
|
<CollapsibleTrigger asChild>
|
||||||
<Shield className="h-3 w-3 text-primary/40" />
|
<Button
|
||||||
<Label className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
variant="ghost"
|
||||||
Sensitivity
|
size="sm"
|
||||||
</Label>
|
className={cn('h-7 w-full justify-between px-2 text-xs font-medium', FOCUS_RING)}
|
||||||
</div>
|
aria-expanded={advancedOpen}
|
||||||
<div className="grid grid-cols-2 gap-1.5" aria-label="Level filter">
|
>
|
||||||
{levels.map((option) => (
|
Advanced filters
|
||||||
<button
|
<ChevronDown
|
||||||
key={option.value}
|
className={cn('h-3.5 w-3.5 transition-transform', advancedOpen && 'rotate-180')}
|
||||||
type="button"
|
aria-hidden="true"
|
||||||
onClick={() => onLevelChange(option.value)}
|
/>
|
||||||
className={cn(
|
</Button>
|
||||||
'flex flex-col items-center gap-1 rounded-lg border px-2 py-2 text-[10px] font-semibold uppercase tracking-[0.1em] transition-all active:scale-[0.97]',
|
</CollapsibleTrigger>
|
||||||
selectedLevel === option.value
|
<CollapsibleContent className="mt-2 space-y-2">
|
||||||
? 'border-primary/50 bg-primary/10 text-primary shadow-[0_0_15px_rgba(var(--primary),0.1)]'
|
{onModuleChange ? (
|
||||||
: 'border-border/40 bg-muted/20 text-foreground/40 hover:border-border hover:bg-muted/40 hover:text-foreground/80'
|
<div className="space-y-1">
|
||||||
)}
|
<Label
|
||||||
>
|
htmlFor="logs-module"
|
||||||
<span>{option.label}</span>
|
className="text-[11px] uppercase tracking-wide text-muted-foreground"
|
||||||
<div
|
>
|
||||||
className={cn(
|
Module
|
||||||
'h-0.5 w-4 rounded-full transition-colors',
|
</Label>
|
||||||
selectedLevel === option.value ? 'bg-primary' : 'bg-foreground/10'
|
<Input
|
||||||
)}
|
id="logs-module"
|
||||||
|
value={moduleFilter}
|
||||||
|
onChange={(e) => onModuleChange(e.target.value)}
|
||||||
|
placeholder="e.g. cliproxy.router"
|
||||||
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
</button>
|
</div>
|
||||||
|
) : null}
|
||||||
|
{onStageChange ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="logs-stage"
|
||||||
|
className="text-[11px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Stage
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="logs-stage"
|
||||||
|
value={stageFilter}
|
||||||
|
onChange={(e) => onStageChange(e.target.value)}
|
||||||
|
placeholder="e.g. handler"
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{onRequestIdChange ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="logs-request-id"
|
||||||
|
className="text-[11px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Request ID
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="logs-request-id"
|
||||||
|
value={requestIdFilter}
|
||||||
|
onChange={(e) => onRequestIdChange(e.target.value)}
|
||||||
|
placeholder="req_…"
|
||||||
|
className="h-9 font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{onTimeWindowChange ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="logs-time-window"
|
||||||
|
className="text-[11px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Time window
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={timeWindow}
|
||||||
|
onValueChange={(v) => onTimeWindowChange(v as LogsTimeWindow)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="logs-time-window" className="h-9">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{timeWindows.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
Visible entries
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-4 gap-1">
|
||||||
|
{limits.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
variant={limit === option ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
onClick={() => onLimitChange(option)}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Operational Deck */}
|
{activeChips.length > 0 ? (
|
||||||
<div className="mt-4 rounded-2xl border-2 border-border bg-card/40 p-1.5 shadow-xl shadow-black/5">
|
<div className="flex flex-wrap items-center gap-1.5 border-t border-border pt-3">
|
||||||
<div className="rounded-[calc(1rem-2px)] border border-dashed border-border bg-background/60 p-4 space-y-5">
|
<span className="text-[11px] uppercase tracking-wide text-muted-foreground">Active:</span>
|
||||||
<div className="flex items-center justify-between">
|
{activeChips.map((c) => (
|
||||||
<div className="space-y-0.5">
|
<Chip key={c.key} label={c.label} onRemove={c.clear} />
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-primary">
|
))}
|
||||||
Operational Window
|
{onClearAll ? (
|
||||||
</p>
|
<Button
|
||||||
<p className="text-[11px] font-medium uppercase tracking-[0.1em] text-foreground/45">
|
variant="ghost"
|
||||||
Tail Capacity
|
size="sm"
|
||||||
</p>
|
onClick={onClearAll}
|
||||||
</div>
|
className="h-7 px-2 text-[11px] text-muted-foreground"
|
||||||
<div className="h-8 w-8 rounded-lg bg-primary/5 border border-primary/10 flex items-center justify-center">
|
>
|
||||||
<Zap className="h-4 w-4 text-primary/40" />
|
Clear all
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-1.5" aria-label="Visible entries">
|
|
||||||
{limits.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onLimitChange(option)}
|
|
||||||
className={cn(
|
|
||||||
'rounded-md border py-1.5 text-[11px] font-semibold tabular-nums transition-all active:scale-[0.95]',
|
|
||||||
limit === option
|
|
||||||
? 'border-foreground bg-foreground text-background'
|
|
||||||
: 'border-border/60 bg-muted/40 text-foreground/40 hover:bg-muted hover:text-foreground'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{option}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="group relative h-10 w-full overflow-hidden rounded-xl border-none bg-primary text-[11px] font-semibold uppercase tracking-[0.14em] text-primary-foreground transition-all hover:scale-[1.02] hover:shadow-lg hover:shadow-primary/20 active:scale-[0.98]"
|
|
||||||
onClick={onRefresh}
|
|
||||||
>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-1000" />
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<RefreshCw className={cn('h-3.5 w-3.5', isRefreshing && 'animate-spin')} />
|
|
||||||
<span>{t('logsConfig.refreshEntries')}</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { RefreshCw, Settings, ScrollText, HelpCircle } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { FOCUS_RING } from './tokens';
|
||||||
|
|
||||||
|
export interface LogsHeaderProps {
|
||||||
|
isFetching: boolean;
|
||||||
|
hasError: boolean;
|
||||||
|
capturedCount: number;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
onOpenShortcuts: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calm 48px header for the logs surface.
|
||||||
|
* - Title + icon on the left
|
||||||
|
* - Live connection pill in the middle (idle/syncing/error)
|
||||||
|
* - Refresh + settings buttons on the right
|
||||||
|
*
|
||||||
|
* No glows, no decorative captions, no scale animations.
|
||||||
|
*/
|
||||||
|
export function LogsHeader({
|
||||||
|
isFetching,
|
||||||
|
hasError,
|
||||||
|
capturedCount,
|
||||||
|
onRefresh,
|
||||||
|
onOpenSettings,
|
||||||
|
onOpenShortcuts,
|
||||||
|
}: LogsHeaderProps) {
|
||||||
|
const status = hasError ? 'error' : isFetching ? 'syncing' : 'live';
|
||||||
|
const statusLabel =
|
||||||
|
status === 'error' ? 'Disconnected' : status === 'syncing' ? 'Syncing' : 'Live';
|
||||||
|
const dotClass =
|
||||||
|
status === 'error' ? 'bg-red-500' : status === 'syncing' ? 'bg-amber-500' : 'bg-emerald-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-12 shrink-0 items-center justify-between border-b border-border bg-background px-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ScrollText className="h-4 w-4 text-foreground/70" aria-hidden="true" />
|
||||||
|
<h1 className="text-sm font-semibold tracking-tight text-foreground">Logs</h1>
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
className="ml-3 inline-flex items-center gap-1.5 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-[11px] font-medium text-foreground/70"
|
||||||
|
>
|
||||||
|
<span className={cn('h-1.5 w-1.5 rounded-full', dotClass)} aria-hidden="true" />
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-medium tabular-nums text-muted-foreground">
|
||||||
|
{capturedCount} entries
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRefresh}
|
||||||
|
aria-label="Refresh logs"
|
||||||
|
className={cn('h-8 gap-2 px-2 text-xs font-medium', FOCUS_RING)}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={cn('h-3.5 w-3.5', isFetching && 'animate-spin')}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onOpenShortcuts}
|
||||||
|
aria-label="Show keyboard shortcuts"
|
||||||
|
title="Keyboard shortcuts (?)"
|
||||||
|
className={cn('h-8 w-8', FOCUS_RING)}
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
aria-label="Open logging settings"
|
||||||
|
className={cn('h-8 w-8', FOCUS_RING)}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { Activity, Archive, Database, RadioTower } from 'lucide-react';
|
|
||||||
import type { LogsConfig, LogsEntry, LogsSource } from '@/lib/api-client';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { formatCount, formatLogTimestamp, formatRelativeLogTime } from './utils';
|
|
||||||
// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready
|
|
||||||
|
|
||||||
function MetricCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
detail,
|
|
||||||
icon: Icon,
|
|
||||||
accent,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
detail: string;
|
|
||||||
icon: typeof Activity;
|
|
||||||
accent: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border bg-card/80 p-4 shadow-sm">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
|
|
||||||
<p className="text-2xl font-semibold tracking-tight">{value}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{detail}</p>
|
|
||||||
</div>
|
|
||||||
<div className={cn('rounded-xl p-2.5', accent)}>
|
|
||||||
<Icon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LogsOverviewCards({
|
|
||||||
config,
|
|
||||||
sources,
|
|
||||||
entries,
|
|
||||||
latestTimestamp,
|
|
||||||
}: {
|
|
||||||
config: LogsConfig;
|
|
||||||
sources: LogsSource[];
|
|
||||||
entries: LogsEntry[];
|
|
||||||
latestTimestamp: string | null;
|
|
||||||
}) {
|
|
||||||
// TODO i18n: uncomment when keys for Pipeline/Retention/Coverage/Visible Entries are added
|
|
||||||
// const { t } = useTranslation();
|
|
||||||
const nativeSources = sources.filter((source) => source.kind === 'native').length;
|
|
||||||
const legacySources = sources.length - nativeSources;
|
|
||||||
const errorCount = entries.filter((entry) => entry.level === 'error').length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{/* TODO i18n: missing keys for Pipeline/Retention/Coverage/Visible Entries labels and detail strings */}
|
|
||||||
<MetricCard
|
|
||||||
label="Pipeline"
|
|
||||||
value={config.enabled ? 'Enabled' : 'Disabled'}
|
|
||||||
detail={`Threshold: ${config.level.toUpperCase()} • Redaction ${config.redact ? 'on' : 'off'}`}
|
|
||||||
icon={RadioTower}
|
|
||||||
accent={
|
|
||||||
config.enabled
|
|
||||||
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
|
||||||
: 'bg-zinc-500/10 text-zinc-700 dark:text-zinc-300'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Retention"
|
|
||||||
value={`${config.retain_days}d`}
|
|
||||||
detail={`Rotate at ${config.rotate_mb} MB per file`}
|
|
||||||
icon={Archive}
|
|
||||||
accent="bg-amber-500/10 text-amber-700 dark:text-amber-300"
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Coverage"
|
|
||||||
value={formatCount(sources.length)}
|
|
||||||
detail={`${nativeSources} active sources${legacySources > 0 ? ` • ${legacySources} legacy` : ''}`}
|
|
||||||
icon={Database}
|
|
||||||
accent="bg-sky-500/10 text-sky-700 dark:text-sky-300"
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
label="Visible Entries"
|
|
||||||
value={formatCount(entries.length)}
|
|
||||||
detail={`${formatCount(errorCount)} errors • ${formatRelativeLogTime(latestTimestamp)}`}
|
|
||||||
icon={Activity}
|
|
||||||
accent="bg-violet-500/10 text-violet-700 dark:text-violet-300"
|
|
||||||
/>
|
|
||||||
<div className="md:col-span-2 xl:col-span-4 rounded-2xl border border-border/70 bg-card/70 px-4 py-3 text-sm text-muted-foreground shadow-sm">
|
|
||||||
Last ingested event:{' '}
|
|
||||||
<span className="font-medium text-foreground">{formatLogTimestamp(latestTimestamp)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,58 +1,45 @@
|
|||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs surface initial-load skeleton.
|
||||||
|
* Shape mirrors the new shell: 48px header, 40px tab bar, 3-pane body.
|
||||||
|
* `aria-busy` lives here; phase-06 layers full a11y polish.
|
||||||
|
*/
|
||||||
export function LogsPageSkeleton() {
|
export function LogsPageSkeleton() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" aria-label={t('logsPageSkeleton.loadingLogs')}>
|
<div
|
||||||
<Card className="gap-4">
|
className="flex h-full min-h-0 flex-col bg-background"
|
||||||
<CardHeader className="space-y-3">
|
aria-busy="true"
|
||||||
<Skeleton className="h-4 w-28" />
|
aria-live="polite"
|
||||||
<Skeleton className="h-8 w-72" />
|
aria-label={t('logsPageSkeleton.loadingLogs')}
|
||||||
<Skeleton className="h-4 w-[30rem]" />
|
>
|
||||||
</CardHeader>
|
<div className="flex h-12 items-center justify-between border-b border-border px-4">
|
||||||
</Card>
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-6 w-28" />
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{[1, 2, 3, 4].map((item) => (
|
|
||||||
<Card key={item} className="gap-3">
|
|
||||||
<CardHeader className="space-y-2">
|
|
||||||
<Skeleton className="h-4 w-20" />
|
|
||||||
<Skeleton className="h-7 w-24" />
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex h-10 items-center border-b border-border px-4">
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_22rem]">
|
<Skeleton className="h-6 w-40" />
|
||||||
<Card className="gap-4">
|
</div>
|
||||||
<CardHeader className="space-y-3">
|
<div className="grid min-h-0 flex-1 grid-cols-[22%_52%_26%] gap-px bg-border">
|
||||||
<Skeleton className="h-4 w-52" />
|
<div className="space-y-3 bg-background p-4">
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<Skeleton className="h-8 w-full" />
|
||||||
{[1, 2, 3, 4].map((item) => (
|
<Skeleton className="h-8 w-full" />
|
||||||
<Skeleton key={item} className="h-10 w-full" />
|
<Skeleton className="h-8 w-full" />
|
||||||
))}
|
<Skeleton className="h-32 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
<div className="space-y-2 bg-background p-3">
|
||||||
<CardContent className="grid gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
{Array.from({ length: 12 }).map((_, idx) => (
|
||||||
<Skeleton className="h-[26rem] w-full" />
|
<Skeleton key={idx} className="h-8 w-full" />
|
||||||
<Skeleton className="h-[26rem] w-full" />
|
))}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<div className="space-y-3 bg-background p-4">
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
<Card className="gap-4">
|
<Skeleton className="h-4 w-full" />
|
||||||
<CardHeader className="space-y-3">
|
<Skeleton className="h-4 w-3/4" />
|
||||||
<Skeleton className="h-5 w-44" />
|
<Skeleton className="h-40 w-full" />
|
||||||
<Skeleton className="h-4 w-full" />
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{[1, 2, 3, 4].map((item) => (
|
|
||||||
<Skeleton key={item} className="h-10 w-full" />
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import type { LogsEntry } from '@/lib/api-client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { LogLevelBadge } from './log-level-badge';
|
||||||
|
import { FOCUS_RING, MONO_NUMERIC, ROW_DENSITY, ROW_INTERACTIVE, type RowDensity } from './tokens';
|
||||||
|
|
||||||
|
export interface LogsRowProps {
|
||||||
|
entry: LogsEntry;
|
||||||
|
isSelected: boolean;
|
||||||
|
density: RowDensity;
|
||||||
|
sourceLabel: string;
|
||||||
|
onSelect: (entryId: string) => void;
|
||||||
|
/** Optional indent (px) when row sits inside an expanded trace. */
|
||||||
|
indent?: number;
|
||||||
|
/** Optional stage chip text (e.g. for child rows of a trace). */
|
||||||
|
stageHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHms(timestamp: string): string {
|
||||||
|
const d = new Date(timestamp);
|
||||||
|
if (Number.isNaN(d.getTime())) return timestamp;
|
||||||
|
return d.toLocaleTimeString(undefined, {
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogsRowImpl({
|
||||||
|
entry,
|
||||||
|
isSelected,
|
||||||
|
density,
|
||||||
|
sourceLabel,
|
||||||
|
onSelect,
|
||||||
|
indent = 0,
|
||||||
|
stageHint,
|
||||||
|
}: LogsRowProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="row"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
data-selected={isSelected}
|
||||||
|
onClick={() => onSelect(entry.id)}
|
||||||
|
style={{ paddingLeft: 12 + indent }}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 border-b border-border/50 pr-3 text-left',
|
||||||
|
ROW_DENSITY[density],
|
||||||
|
ROW_INTERACTIVE,
|
||||||
|
FOCUS_RING,
|
||||||
|
isSelected && 'bg-muted/60 shadow-[inset_2px_0_0_var(--ring)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
role="cell"
|
||||||
|
className={cn('w-[88px] shrink-0 truncate text-[11px] text-muted-foreground', MONO_NUMERIC)}
|
||||||
|
>
|
||||||
|
{formatHms(entry.timestamp)}
|
||||||
|
</span>
|
||||||
|
<span role="cell" className="flex w-[64px] shrink-0 items-center">
|
||||||
|
<LogLevelBadge level={entry.level} />
|
||||||
|
</span>
|
||||||
|
{stageHint ? (
|
||||||
|
<span
|
||||||
|
role="cell"
|
||||||
|
className="w-[72px] shrink-0 truncate rounded border border-border bg-muted/30 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
{stageHint}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span
|
||||||
|
role="cell"
|
||||||
|
className="w-[140px] shrink-0 truncate text-[11px] font-medium text-foreground/80"
|
||||||
|
>
|
||||||
|
{entry.module ?? sourceLabel}
|
||||||
|
</span>
|
||||||
|
<span role="cell" className="min-w-0 flex-1 truncate text-foreground/90">
|
||||||
|
{entry.message}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
role="cell"
|
||||||
|
className={cn(
|
||||||
|
'hidden w-[72px] shrink-0 truncate text-right text-[11px] text-muted-foreground sm:inline-block',
|
||||||
|
MONO_NUMERIC
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.latencyMs !== undefined && entry.latencyMs !== null ? `${entry.latencyMs}ms` : ''}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
role="cell"
|
||||||
|
className={cn(
|
||||||
|
'hidden w-[88px] shrink-0 truncate text-right text-[11px] text-muted-foreground/80 lg:inline-block',
|
||||||
|
MONO_NUMERIC
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.requestId ? entry.requestId.slice(-8) : ''}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogsRow = memo(LogsRowImpl, (prev, next) => {
|
||||||
|
return (
|
||||||
|
prev.entry.id === next.entry.id &&
|
||||||
|
prev.isSelected === next.isSelected &&
|
||||||
|
prev.density === next.density &&
|
||||||
|
prev.indent === next.indent &&
|
||||||
|
prev.stageHint === next.stageHint &&
|
||||||
|
prev.sourceLabel === next.sourceLabel
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { ErrorLogsMonitor } from '@/components/error-logs-monitor';
|
||||||
|
import { LogsConfigCard } from '@/components/logs/logs-config-card';
|
||||||
|
import { LogsDetailPanel } from '@/components/logs/logs-detail-panel';
|
||||||
|
import { LogsEntryList } from '@/components/logs/logs-entry-list';
|
||||||
|
import { LogsEmpty } from '@/components/logs/logs-empty';
|
||||||
|
import { LogsError } from '@/components/logs/logs-error';
|
||||||
|
import { LogsFilters } from '@/components/logs/logs-filters';
|
||||||
|
import { LogsHeader } from '@/components/logs/logs-header';
|
||||||
|
import { LiveTailControls } from '@/components/logs/live-tail-controls';
|
||||||
|
import { LogsShortcutsDialog } from '@/components/logs/logs-shortcuts-dialog';
|
||||||
|
import { useLogsKeyboardNav } from '@/components/logs/use-logs-keyboard-nav';
|
||||||
|
import {
|
||||||
|
getSourceLabelMap,
|
||||||
|
type useLogsWorkspace,
|
||||||
|
type useUpdateLogsConfig,
|
||||||
|
} from '@/hooks/use-logs';
|
||||||
|
|
||||||
|
const LAYOUT_KEY = 'ccs.logs.layout.v1';
|
||||||
|
const DESKTOP_BREAKPOINT = 1200;
|
||||||
|
|
||||||
|
type Workspace = ReturnType<typeof useLogsWorkspace>;
|
||||||
|
type UpdateConfig = ReturnType<typeof useUpdateLogsConfig>;
|
||||||
|
|
||||||
|
interface LayoutSizes {
|
||||||
|
filters: number;
|
||||||
|
list: number;
|
||||||
|
detail: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SIZES: LayoutSizes = { filters: 22, list: 52, detail: 26 };
|
||||||
|
|
||||||
|
function readSavedSizes(): LayoutSizes {
|
||||||
|
if (typeof window === 'undefined') return DEFAULT_SIZES;
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(LAYOUT_KEY);
|
||||||
|
if (!raw) return DEFAULT_SIZES;
|
||||||
|
const parsed = JSON.parse(raw) as Partial<LayoutSizes>;
|
||||||
|
if (
|
||||||
|
typeof parsed.filters === 'number' &&
|
||||||
|
typeof parsed.list === 'number' &&
|
||||||
|
typeof parsed.detail === 'number'
|
||||||
|
) {
|
||||||
|
return { filters: parsed.filters, list: parsed.list, detail: parsed.detail };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return DEFAULT_SIZES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDesktopLayout(): boolean {
|
||||||
|
const [isDesktop, setIsDesktop] = useState(() =>
|
||||||
|
typeof window === 'undefined' ? true : window.innerWidth >= DESKTOP_BREAKPOINT
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const mq = window.matchMedia(`(min-width: ${DESKTOP_BREAKPOINT}px)`);
|
||||||
|
const sync = () => setIsDesktop(window.innerWidth >= DESKTOP_BREAKPOINT);
|
||||||
|
sync();
|
||||||
|
mq.addEventListener('change', sync);
|
||||||
|
return () => mq.removeEventListener('change', sync);
|
||||||
|
}, []);
|
||||||
|
return isDesktop;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SheetKey = 'settings' | 'detail' | null;
|
||||||
|
|
||||||
|
export interface LogsShellProps {
|
||||||
|
workspace: Workspace;
|
||||||
|
updateConfig: UpdateConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogsShell({ workspace, updateConfig }: LogsShellProps) {
|
||||||
|
const isDesktop = useDesktopLayout();
|
||||||
|
const sourceLabels = useMemo(
|
||||||
|
() => getSourceLabelMap(workspace.sourcesQuery.data ?? []),
|
||||||
|
[workspace.sourcesQuery.data]
|
||||||
|
);
|
||||||
|
const [activeSheet, setActiveSheet] = useState<SheetKey>(null);
|
||||||
|
const [sizes, setSizes] = useState<LayoutSizes>(readSavedSizes);
|
||||||
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const config = workspace.configQuery.data;
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
void Promise.all([workspace.sourcesQuery.refetch(), workspace.entriesQuery.refetch()]);
|
||||||
|
}, [workspace.entriesQuery, workspace.sourcesQuery]);
|
||||||
|
|
||||||
|
const onLayout = useCallback((next: number[]) => {
|
||||||
|
if (next.length !== 3) return;
|
||||||
|
const [filters, list, detail] = next as [number, number, number];
|
||||||
|
const updated = { filters, list, detail };
|
||||||
|
setSizes(updated);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(LAYOUT_KEY, JSON.stringify(updated));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openSettings = useCallback(() => setActiveSheet('settings'), []);
|
||||||
|
const openDetailSheet = useCallback(() => setActiveSheet('detail'), []);
|
||||||
|
const closeSheet = useCallback(() => setActiveSheet(null), []);
|
||||||
|
|
||||||
|
// On mobile, selecting an entry opens the detail bottom-sheet.
|
||||||
|
// We wrap `setSelectedEntryId` so it stays an event-driven side effect.
|
||||||
|
const baseSetSelectedEntryId = workspace.setSelectedEntryId;
|
||||||
|
const handleSelectEntry = useCallback(
|
||||||
|
(id: string | null) => {
|
||||||
|
baseSetSelectedEntryId(id);
|
||||||
|
if (!isDesktop && id) setActiveSheet('detail');
|
||||||
|
},
|
||||||
|
[baseSetSelectedEntryId, isDesktop]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleShowTrace = useCallback(
|
||||||
|
(requestId: string) => {
|
||||||
|
workspace.setRequestIdFilter(requestId);
|
||||||
|
workspace.setSearch('');
|
||||||
|
},
|
||||||
|
[workspace]
|
||||||
|
);
|
||||||
|
|
||||||
|
const entryIds = useMemo(
|
||||||
|
() => (workspace.entriesQuery.data ?? []).map((e) => e.id),
|
||||||
|
[workspace.entriesQuery.data]
|
||||||
|
);
|
||||||
|
|
||||||
|
const focusSearch = useCallback(() => {
|
||||||
|
const input = document.getElementById('logs-search') as HTMLInputElement | null;
|
||||||
|
if (input) {
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
} else if (searchInputRef.current) {
|
||||||
|
searchInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLogsKeyboardNav({
|
||||||
|
entryIds,
|
||||||
|
selectedId: workspace.selectedEntryId,
|
||||||
|
onSelect: workspace.setSelectedEntryId,
|
||||||
|
onTogglePause: workspace.liveTail.togglePause,
|
||||||
|
onFocusSearch: focusSearch,
|
||||||
|
onOpenShortcuts: () => setShortcutsOpen(true),
|
||||||
|
onCloseDetail: !isDesktop && activeSheet === 'detail' ? closeSheet : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
const filtersNode = (
|
||||||
|
<LogsFilters
|
||||||
|
sources={workspace.sourcesQuery.data ?? []}
|
||||||
|
selectedSource={workspace.selectedSource}
|
||||||
|
onSourceChange={workspace.setSelectedSource}
|
||||||
|
selectedLevel={workspace.selectedLevel}
|
||||||
|
onLevelChange={workspace.setSelectedLevel}
|
||||||
|
search={workspace.search}
|
||||||
|
onSearchChange={workspace.setSearch}
|
||||||
|
limit={workspace.limit}
|
||||||
|
onLimitChange={workspace.setLimit}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
isRefreshing={workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching}
|
||||||
|
moduleFilter={workspace.moduleFilter}
|
||||||
|
onModuleChange={workspace.setModuleFilter}
|
||||||
|
stageFilter={workspace.stageFilter}
|
||||||
|
onStageChange={workspace.setStageFilter}
|
||||||
|
requestIdFilter={workspace.requestIdFilter}
|
||||||
|
onRequestIdChange={workspace.setRequestIdFilter}
|
||||||
|
timeWindow={workspace.timeWindow}
|
||||||
|
onTimeWindowChange={workspace.setTimeWindow}
|
||||||
|
onClearAll={workspace.clearAdvancedFilters}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const liveTailSlot = (
|
||||||
|
<LiveTailControls
|
||||||
|
isPaused={workspace.liveTail.isPaused}
|
||||||
|
pendingCount={workspace.liveTail.pendingCount}
|
||||||
|
onTogglePause={workspace.liveTail.togglePause}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const listNode = workspace.entriesQuery.error ? (
|
||||||
|
<LogsError
|
||||||
|
error={workspace.entriesQuery.error as Error}
|
||||||
|
onRetry={() => void workspace.entriesQuery.refetch()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LogsEntryList
|
||||||
|
entries={workspace.entriesQuery.data ?? []}
|
||||||
|
selectedEntryId={workspace.selectedEntryId}
|
||||||
|
onSelect={handleSelectEntry}
|
||||||
|
sourceLabels={sourceLabels}
|
||||||
|
isLoading={workspace.entriesQuery.isLoading}
|
||||||
|
isFetching={workspace.entriesQuery.isFetching}
|
||||||
|
liveTailSlot={liveTailSlot}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const detailNode = workspace.isSelectionOutOfScope ? (
|
||||||
|
<LogsEmpty variant="selectionOutOfScope" onClearFilters={workspace.clearAdvancedFilters} />
|
||||||
|
) : (
|
||||||
|
<LogsDetailPanel
|
||||||
|
entry={workspace.selectedEntry}
|
||||||
|
sourceLabel={
|
||||||
|
workspace.selectedEntry ? sourceLabels[workspace.selectedEntry.source] : undefined
|
||||||
|
}
|
||||||
|
onShowTrace={handleShowTrace}
|
||||||
|
redact={Boolean(config.redact)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
|
||||||
|
<LogsHeader
|
||||||
|
isFetching={workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching}
|
||||||
|
hasError={Boolean(workspace.entriesQuery.error)}
|
||||||
|
capturedCount={workspace.entriesQuery.data?.length ?? 0}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
onOpenSettings={openSettings}
|
||||||
|
onOpenShortcuts={() => setShortcutsOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs defaultValue="stream" className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
<div className="flex h-10 shrink-0 items-center border-b border-border bg-background px-4">
|
||||||
|
<TabsList className="h-8 bg-muted/40">
|
||||||
|
<TabsTrigger value="stream" className="text-xs">
|
||||||
|
Stream
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="errors" className="text-xs">
|
||||||
|
Upstream errors
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="stream"
|
||||||
|
className="m-0 flex min-h-0 flex-1 overflow-hidden focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
{isDesktop ? (
|
||||||
|
<PanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
onLayout={onLayout}
|
||||||
|
className="flex min-h-0 flex-1"
|
||||||
|
autoSaveId={LAYOUT_KEY}
|
||||||
|
>
|
||||||
|
<Panel defaultSize={sizes.filters} minSize={16} maxSize={36} order={1}>
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-y-auto border-r border-border bg-background p-4">
|
||||||
|
{filtersNode}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
<PanelResizeHandle className="w-px bg-border transition-colors hover:bg-foreground/20 data-[resize-handle-active]:bg-foreground/30" />
|
||||||
|
<Panel defaultSize={sizes.list} minSize={32} order={2}>
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||||
|
{listNode}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
<PanelResizeHandle className="w-px bg-border transition-colors hover:bg-foreground/20 data-[resize-handle-active]:bg-foreground/30" />
|
||||||
|
<Panel defaultSize={sizes.detail} minSize={20} maxSize={42} order={3}>
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden border-l border-border bg-background">
|
||||||
|
{detailNode}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</PanelGroup>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="border-b border-border p-3">{filtersNode}</div>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">{listNode}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent
|
||||||
|
value="errors"
|
||||||
|
className="m-0 flex-1 overflow-y-auto p-4 focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-5xl space-y-4">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<h2 className="text-base font-semibold text-foreground">CLIProxy upstream errors</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Historical failure stream from the upstream proxy. Distinct from the structured
|
||||||
|
stream.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<ErrorLogsMonitor />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Logging settings sheet (right). Phase-05 may replace body with extracted form. */}
|
||||||
|
<Sheet
|
||||||
|
open={activeSheet === 'settings'}
|
||||||
|
onOpenChange={(open) => (open ? openSettings() : closeSheet())}
|
||||||
|
>
|
||||||
|
<SheetContent side="right" className="w-full max-w-md">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Logging settings</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
Configure retention, redaction, and sampling for the logs surface.
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="mt-4 px-4 pb-6">
|
||||||
|
<LogsConfigCard
|
||||||
|
config={config}
|
||||||
|
onSave={(payload) => updateConfig.mutate(payload)}
|
||||||
|
isPending={updateConfig.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<LogsShortcutsDialog open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
|
||||||
|
|
||||||
|
{/* Mobile detail sheet (bottom). Desktop renders detail in panel directly. */}
|
||||||
|
{!isDesktop ? (
|
||||||
|
<Sheet
|
||||||
|
open={activeSheet === 'detail'}
|
||||||
|
onOpenChange={(open) => (open ? openDetailSheet() : closeSheet())}
|
||||||
|
>
|
||||||
|
<SheetContent side="bottom" className="h-[85vh]">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Entry detail</SheetTitle>
|
||||||
|
<SheetDescription>Selected log entry context and raw payload.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="mt-2 flex min-h-0 flex-1 overflow-hidden">{detailNode}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
export interface LogsShortcutsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHORTCUTS: Array<{ keys: string[]; label: string }> = [
|
||||||
|
{ keys: ['j', '↓'], label: 'Next entry' },
|
||||||
|
{ keys: ['k', '↑'], label: 'Previous entry' },
|
||||||
|
{ keys: ['Enter'], label: 'Focus detail' },
|
||||||
|
{ keys: ['Esc'], label: 'Close detail (mobile)' },
|
||||||
|
{ keys: ['/'], label: 'Focus search' },
|
||||||
|
{ keys: ['Space'], label: 'Pause / resume tail' },
|
||||||
|
{ keys: ['?'], label: 'Show this help' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Kbd({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<kbd className="inline-flex h-5 min-w-[20px] items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-[11px] font-medium text-foreground">
|
||||||
|
{children}
|
||||||
|
</kbd>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogsShortcutsDialog({ open, onOpenChange }: LogsShortcutsDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Keyboard shortcuts</DialogTitle>
|
||||||
|
<DialogDescription>Logs surface keybindings.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ul className="mt-2 space-y-2">
|
||||||
|
{SHORTCUTS.map((s) => (
|
||||||
|
<li key={s.label} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-foreground/90">{s.label}</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
{s.keys.map((k) => (
|
||||||
|
<Kbd key={k}>{k}</Kbd>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
import { ChevronDown, ChevronRight, GitBranch } from 'lucide-react';
|
||||||
|
import type { LogsEntry, LogsLevel } from '@/lib/api-client';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { LogLevelBadge } from './log-level-badge';
|
||||||
|
import { LogsRow } from './logs-row';
|
||||||
|
import { FOCUS_RING, MONO_NUMERIC, ROW_DENSITY, ROW_INTERACTIVE, type RowDensity } from './tokens';
|
||||||
|
|
||||||
|
export interface TraceGroup {
|
||||||
|
kind: 'trace';
|
||||||
|
requestId: string;
|
||||||
|
module: string;
|
||||||
|
source: string;
|
||||||
|
ts: string; // min child ts
|
||||||
|
maxLevel: LogsLevel;
|
||||||
|
totalLatencyMs: number;
|
||||||
|
children: LogsEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogsTraceRowProps {
|
||||||
|
group: TraceGroup;
|
||||||
|
isExpanded: boolean;
|
||||||
|
selectedEntryId: string | null;
|
||||||
|
density: RowDensity;
|
||||||
|
sourceLabel: string;
|
||||||
|
onToggle: (requestId: string) => void;
|
||||||
|
onSelect: (entryId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHms(timestamp: string): string {
|
||||||
|
const d = new Date(timestamp);
|
||||||
|
if (Number.isNaN(d.getTime())) return timestamp;
|
||||||
|
return d.toLocaleTimeString(undefined, {
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogsTraceRowImpl({
|
||||||
|
group,
|
||||||
|
isExpanded,
|
||||||
|
selectedEntryId,
|
||||||
|
density,
|
||||||
|
sourceLabel,
|
||||||
|
onToggle,
|
||||||
|
onSelect,
|
||||||
|
}: LogsTraceRowProps) {
|
||||||
|
const Chevron = isExpanded ? ChevronDown : ChevronRight;
|
||||||
|
return (
|
||||||
|
<div role="rowgroup" className="border-b border-border/50">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="row"
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
onClick={() => onToggle(group.requestId)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 pl-3 pr-3 text-left',
|
||||||
|
ROW_DENSITY[density],
|
||||||
|
ROW_INTERACTIVE,
|
||||||
|
FOCUS_RING
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Chevron className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<span
|
||||||
|
role="cell"
|
||||||
|
className={cn(
|
||||||
|
'w-[88px] shrink-0 truncate text-[11px] text-muted-foreground',
|
||||||
|
MONO_NUMERIC
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatHms(group.ts)}
|
||||||
|
</span>
|
||||||
|
<span role="cell" className="flex w-[64px] shrink-0 items-center">
|
||||||
|
<LogLevelBadge level={group.maxLevel} />
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
role="cell"
|
||||||
|
className="flex w-[140px] shrink-0 items-center gap-1.5 truncate text-[11px] font-medium text-foreground/80"
|
||||||
|
>
|
||||||
|
<GitBranch className="h-3 w-3 text-muted-foreground" aria-hidden="true" />
|
||||||
|
{group.module}
|
||||||
|
</span>
|
||||||
|
<span role="cell" className="min-w-0 flex-1 truncate text-foreground/90">
|
||||||
|
<span className="text-muted-foreground">trace · {group.children.length} stages</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
role="cell"
|
||||||
|
className={cn(
|
||||||
|
'hidden w-[72px] shrink-0 truncate text-right text-[11px] text-muted-foreground sm:inline-block',
|
||||||
|
MONO_NUMERIC
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{group.totalLatencyMs > 0 ? `${group.totalLatencyMs}ms` : ''}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
role="cell"
|
||||||
|
className={cn(
|
||||||
|
'hidden w-[88px] shrink-0 truncate text-right text-[11px] text-muted-foreground lg:inline-block',
|
||||||
|
MONO_NUMERIC
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{group.requestId.slice(-8)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded
|
||||||
|
? group.children.map((child) => (
|
||||||
|
<LogsRow
|
||||||
|
key={child.id}
|
||||||
|
entry={child}
|
||||||
|
isSelected={child.id === selectedEntryId}
|
||||||
|
density={density}
|
||||||
|
sourceLabel={sourceLabel}
|
||||||
|
onSelect={onSelect}
|
||||||
|
indent={20}
|
||||||
|
stageHint={child.stage}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogsTraceRow = memo(LogsTraceRowImpl);
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { LogsLevel } from '@/lib/api-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calm-density design tokens for the logs surface.
|
||||||
|
*
|
||||||
|
* Strings only -- no runtime cost. Components import these instead of
|
||||||
|
* duplicating Tailwind utilities. Color values use neutral red/amber/blue/zinc
|
||||||
|
* scales tuned for AA contrast on `bg-background` in both light and dark
|
||||||
|
* themes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface LevelToken {
|
||||||
|
bg: string;
|
||||||
|
fg: string;
|
||||||
|
border: string;
|
||||||
|
dot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LEVEL_TOKENS: Record<LogsLevel, LevelToken> = {
|
||||||
|
error: {
|
||||||
|
bg: 'bg-red-500/10',
|
||||||
|
fg: 'text-red-700 dark:text-red-300',
|
||||||
|
border: 'border-red-500/30',
|
||||||
|
dot: 'bg-red-500',
|
||||||
|
},
|
||||||
|
warn: {
|
||||||
|
bg: 'bg-amber-500/10',
|
||||||
|
fg: 'text-amber-800 dark:text-amber-200',
|
||||||
|
border: 'border-amber-500/30',
|
||||||
|
dot: 'bg-amber-500',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
bg: 'bg-sky-500/10',
|
||||||
|
fg: 'text-sky-800 dark:text-sky-200',
|
||||||
|
border: 'border-sky-500/30',
|
||||||
|
dot: 'bg-sky-500',
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
bg: 'bg-zinc-500/10',
|
||||||
|
fg: 'text-zinc-700 dark:text-zinc-300',
|
||||||
|
border: 'border-zinc-500/30',
|
||||||
|
dot: 'bg-zinc-500',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROW_DENSITY = {
|
||||||
|
compact: 'h-8 text-[12px]',
|
||||||
|
cozy: 'h-10 text-[13px]',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type RowDensity = keyof typeof ROW_DENSITY;
|
||||||
|
|
||||||
|
export const PANEL_CHROME = 'border-border bg-background';
|
||||||
|
|
||||||
|
export const PANEL_CHROME_RIGHT = 'border-r border-border bg-background';
|
||||||
|
export const PANEL_CHROME_LEFT = 'border-l border-border bg-background';
|
||||||
|
|
||||||
|
/** Mono numerics for timestamp / latency columns. */
|
||||||
|
export const MONO_NUMERIC = 'font-mono tabular-nums';
|
||||||
|
|
||||||
|
/** Subtle row hover/selected feedback. */
|
||||||
|
export const ROW_INTERACTIVE =
|
||||||
|
'transition-colors hover:bg-muted/40 data-[selected=true]:bg-muted/60';
|
||||||
|
|
||||||
|
/** Calm focus ring used across logs interactive elements. */
|
||||||
|
export const FOCUS_RING =
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background';
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export interface UseLogsKeyboardNavOptions {
|
||||||
|
/** Currently visible entry ids in display order (post-filter, post-grouping flatten). */
|
||||||
|
entryIds: string[];
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onTogglePause: () => void;
|
||||||
|
onFocusSearch: () => void;
|
||||||
|
onOpenShortcuts: () => void;
|
||||||
|
/** Detail-sheet close (mobile only). Optional. */
|
||||||
|
onCloseDetail?: () => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTypingTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
const tag = target.tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
|
||||||
|
return target.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralised keyboard navigation for the logs surface.
|
||||||
|
* Bound on `window`. Bail out when typing in form fields or the user has
|
||||||
|
* any modifier (cmd/ctrl/alt) other than shift on `?`.
|
||||||
|
*/
|
||||||
|
export function useLogsKeyboardNav({
|
||||||
|
entryIds,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onTogglePause,
|
||||||
|
onFocusSearch,
|
||||||
|
onOpenShortcuts,
|
||||||
|
onCloseDetail,
|
||||||
|
enabled = true,
|
||||||
|
}: UseLogsKeyboardNavOptions): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const handler = (event: KeyboardEvent) => {
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
|
|
||||||
|
// `/` always focuses search, even from inputs (consistent with most apps)
|
||||||
|
if (event.key === '/') {
|
||||||
|
event.preventDefault();
|
||||||
|
onFocusSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === '?') {
|
||||||
|
event.preventDefault();
|
||||||
|
onOpenShortcuts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isTypingTarget(event.target)) return;
|
||||||
|
|
||||||
|
if (event.key === 'Escape' && onCloseDetail) {
|
||||||
|
onCloseDetail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
onTogglePause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'j' || event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (entryIds.length === 0) return;
|
||||||
|
const idx = selectedId ? entryIds.indexOf(selectedId) : -1;
|
||||||
|
const next = entryIds[Math.min(idx + 1, entryIds.length - 1)] ?? entryIds[0];
|
||||||
|
if (next) onSelect(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'k' || event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (entryIds.length === 0) return;
|
||||||
|
const idx = selectedId ? entryIds.indexOf(selectedId) : 0;
|
||||||
|
const next = entryIds[Math.max(idx - 1, 0)] ?? entryIds[0];
|
||||||
|
if (next) onSelect(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [
|
||||||
|
enabled,
|
||||||
|
entryIds,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
onTogglePause,
|
||||||
|
onFocusSearch,
|
||||||
|
onOpenShortcuts,
|
||||||
|
onCloseDetail,
|
||||||
|
]);
|
||||||
|
}
|
||||||
+236
-18
@@ -1,5 +1,5 @@
|
|||||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useDeferredValue, useMemo, useState } from 'react';
|
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
@@ -9,20 +9,120 @@ import {
|
|||||||
type UpdateLogsConfigPayload,
|
type UpdateLogsConfigPayload,
|
||||||
} from '@/lib/api-client';
|
} from '@/lib/api-client';
|
||||||
|
|
||||||
|
// ---- dev-only fixture injection (`?mock=logs`) -----------------------------
|
||||||
|
// Tree-shaken from production: the fixture import lives behind
|
||||||
|
// `import.meta.env.DEV` and the URL flag check.
|
||||||
|
const KNOWN_LOGS_ENTRY_KEYS = new Set([
|
||||||
|
'id',
|
||||||
|
'timestamp',
|
||||||
|
'level',
|
||||||
|
'source',
|
||||||
|
'event',
|
||||||
|
'message',
|
||||||
|
'processId',
|
||||||
|
'runId',
|
||||||
|
'context',
|
||||||
|
'requestId',
|
||||||
|
'module',
|
||||||
|
'stage',
|
||||||
|
'latencyMs',
|
||||||
|
'metadata',
|
||||||
|
'error',
|
||||||
|
]);
|
||||||
|
|
||||||
|
let _shapeWarned = false;
|
||||||
|
function assertLogsEntryShape(entry: LogsEntry): void {
|
||||||
|
if (!import.meta.env.DEV || _shapeWarned) return;
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!entry.id) missing.push('id');
|
||||||
|
if (!entry.timestamp) missing.push('timestamp');
|
||||||
|
if (!entry.level) missing.push('level');
|
||||||
|
const unknown = Object.keys(entry).filter((k) => !KNOWN_LOGS_ENTRY_KEYS.has(k));
|
||||||
|
if (missing.length || unknown.length) {
|
||||||
|
_shapeWarned = true;
|
||||||
|
|
||||||
|
console.warn('[logs] LogsEntry shape drift detected -- backend contract changed?', {
|
||||||
|
missing,
|
||||||
|
unknown,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMockLogsEnabled(): boolean {
|
||||||
|
if (!import.meta.env.DEV) return false;
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
try {
|
||||||
|
return new URLSearchParams(window.location.search).get('mock') === 'logs';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type LogsLevelFilter = 'all' | LogsLevel;
|
export type LogsLevelFilter = 'all' | LogsLevel;
|
||||||
export type LogsSourceFilter = 'all' | string;
|
export type LogsSourceFilter = 'all' | string;
|
||||||
|
export type LogsTimeWindow = 'all' | '5m' | '15m' | '1h' | '24h';
|
||||||
|
|
||||||
const CONFIG_QUERY_KEY = ['logs', 'config'] as const;
|
const CONFIG_QUERY_KEY = ['logs', 'config'] as const;
|
||||||
const SOURCES_QUERY_KEY = ['logs', 'sources'] as const;
|
const SOURCES_QUERY_KEY = ['logs', 'sources'] as const;
|
||||||
const DEFAULT_LIMIT = 150;
|
const DEFAULT_LIMIT = 150;
|
||||||
|
const POLL_INTERVAL_MS = 10_000;
|
||||||
|
const TEXT_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||||
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = window.setTimeout(() => setDebounced(value), delayMs);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [value, delayMs]);
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDocumentVisible(): boolean {
|
||||||
|
const [visible, setVisible] = useState(() =>
|
||||||
|
typeof document === 'undefined' ? true : document.visibilityState !== 'hidden'
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const sync = () => setVisible(document.visibilityState !== 'hidden');
|
||||||
|
document.addEventListener('visibilitychange', sync);
|
||||||
|
return () => document.removeEventListener('visibilitychange', sync);
|
||||||
|
}, []);
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowToCutoffMs(window: LogsTimeWindow): number | null {
|
||||||
|
switch (window) {
|
||||||
|
case '5m':
|
||||||
|
return 5 * 60_000;
|
||||||
|
case '15m':
|
||||||
|
return 15 * 60_000;
|
||||||
|
case '1h':
|
||||||
|
return 60 * 60_000;
|
||||||
|
case '24h':
|
||||||
|
return 24 * 60 * 60_000;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useLogsWorkspace() {
|
export function useLogsWorkspace() {
|
||||||
const [selectedSource, setSelectedSource] = useState<LogsSourceFilter>('all');
|
const [selectedSource, setSelectedSource] = useState<LogsSourceFilter>('all');
|
||||||
const [selectedLevel, setSelectedLevel] = useState<LogsLevelFilter>('all');
|
const [selectedLevel, setSelectedLevel] = useState<LogsLevelFilter>('all');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [moduleFilter, setModuleFilter] = useState('');
|
||||||
|
const [stageFilter, setStageFilter] = useState('');
|
||||||
|
const [requestIdFilter, setRequestIdFilter] = useState('');
|
||||||
|
const [timeWindow, setTimeWindow] = useState<LogsTimeWindow>('all');
|
||||||
const [limit, setLimit] = useState(DEFAULT_LIMIT);
|
const [limit, setLimit] = useState(DEFAULT_LIMIT);
|
||||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const frozenIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const deferredSearch = useDeferredValue(search.trim());
|
const deferredSearch = useDeferredValue(search.trim());
|
||||||
|
const debouncedModule = useDebouncedValue(moduleFilter.trim(), TEXT_DEBOUNCE_MS);
|
||||||
|
const debouncedStage = useDebouncedValue(stageFilter.trim(), TEXT_DEBOUNCE_MS);
|
||||||
|
const debouncedRequestId = useDebouncedValue(requestIdFilter.trim(), TEXT_DEBOUNCE_MS);
|
||||||
|
const documentVisible = useDocumentVisible();
|
||||||
|
|
||||||
const configQuery = useQuery({
|
const configQuery = useQuery({
|
||||||
queryKey: CONFIG_QUERY_KEY,
|
queryKey: CONFIG_QUERY_KEY,
|
||||||
@@ -36,37 +136,118 @@ export function useLogsWorkspace() {
|
|||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockEnabled = isMockLogsEnabled();
|
||||||
|
const refetchInterval = isPaused || !documentVisible ? false : POLL_INTERVAL_MS;
|
||||||
|
|
||||||
const entriesQuery = useQuery({
|
const entriesQuery = useQuery({
|
||||||
queryKey: ['logs', 'entries', selectedSource, selectedLevel, deferredSearch, limit],
|
queryKey: [
|
||||||
queryFn: async () =>
|
'logs',
|
||||||
(
|
'entries',
|
||||||
await api.logs.getEntries({
|
selectedSource,
|
||||||
|
selectedLevel,
|
||||||
|
deferredSearch,
|
||||||
|
debouncedModule,
|
||||||
|
debouncedStage,
|
||||||
|
debouncedRequestId,
|
||||||
|
timeWindow,
|
||||||
|
limit,
|
||||||
|
mockEnabled ? 'mock' : 'live',
|
||||||
|
],
|
||||||
|
queryFn: async () => {
|
||||||
|
let entries: LogsEntry[];
|
||||||
|
if (mockEnabled && import.meta.env.DEV) {
|
||||||
|
const { STRUCTURED_LOG_ENTRIES } =
|
||||||
|
await import('@/components/logs/__fixtures__/structured-log-entries');
|
||||||
|
entries = STRUCTURED_LOG_ENTRIES.filter((entry) => {
|
||||||
|
if (selectedSource !== 'all' && entry.source !== selectedSource) return false;
|
||||||
|
if (selectedLevel !== 'all' && entry.level !== selectedLevel) return false;
|
||||||
|
if (deferredSearch) {
|
||||||
|
const needle = deferredSearch.toLowerCase();
|
||||||
|
const hay =
|
||||||
|
`${entry.message} ${entry.event} ${entry.module ?? ''} ${entry.requestId ?? ''}`.toLowerCase();
|
||||||
|
if (!hay.includes(needle)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).slice(0, limit);
|
||||||
|
} else {
|
||||||
|
const result = await api.logs.getEntries({
|
||||||
source: selectedSource === 'all' ? undefined : selectedSource,
|
source: selectedSource === 'all' ? undefined : selectedSource,
|
||||||
level: selectedLevel === 'all' ? undefined : selectedLevel,
|
level: selectedLevel === 'all' ? undefined : selectedLevel,
|
||||||
search: deferredSearch || undefined,
|
search: deferredSearch || undefined,
|
||||||
limit,
|
limit,
|
||||||
})
|
});
|
||||||
).entries,
|
entries = result.entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client-side fallback for advanced filters (backend may not yet implement).
|
||||||
|
const cutoffMs = windowToCutoffMs(timeWindow);
|
||||||
|
const now = Date.now();
|
||||||
|
const filtered = entries.filter((entry) => {
|
||||||
|
if (debouncedModule && !(entry.module ?? '').includes(debouncedModule)) return false;
|
||||||
|
if (debouncedStage && (entry.stage ?? '') !== debouncedStage) return false;
|
||||||
|
if (debouncedRequestId && !(entry.requestId ?? '').includes(debouncedRequestId))
|
||||||
|
return false;
|
||||||
|
if (cutoffMs !== null) {
|
||||||
|
const ts = Date.parse(entry.timestamp);
|
||||||
|
if (Number.isFinite(ts) && now - ts > cutoffMs) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const head = filtered[0];
|
||||||
|
if (head) assertLogsEntryShape(head);
|
||||||
|
return filtered;
|
||||||
|
},
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
refetchInterval: 10_000,
|
refetchInterval,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeSelectedEntryId = useMemo(() => {
|
// Live-tail pending count: rotation-safe id-set diff.
|
||||||
const nextEntries = entriesQuery.data ?? [];
|
const entriesData = entriesQuery.data;
|
||||||
if (nextEntries.length === 0) {
|
const backendEntries = useMemo<LogsEntry[]>(() => entriesData ?? [], [entriesData]);
|
||||||
return null;
|
const pendingCount = useMemo(() => {
|
||||||
|
if (!isPaused) return 0;
|
||||||
|
const frozen = frozenIdsRef.current;
|
||||||
|
let count = 0;
|
||||||
|
for (const e of backendEntries) {
|
||||||
|
if (!frozen.has(e.id)) count += 1;
|
||||||
}
|
}
|
||||||
|
return count;
|
||||||
|
}, [backendEntries, isPaused]);
|
||||||
|
|
||||||
if (selectedEntryId && nextEntries.some((entry) => entry.id === selectedEntryId)) {
|
const pause = useCallback(() => {
|
||||||
|
frozenIdsRef.current = new Set(backendEntries.map((e) => e.id));
|
||||||
|
setIsPaused(true);
|
||||||
|
}, [backendEntries]);
|
||||||
|
|
||||||
|
const resume = useCallback(() => {
|
||||||
|
frozenIdsRef.current = new Set();
|
||||||
|
setIsPaused(false);
|
||||||
|
void entriesQuery.refetch();
|
||||||
|
}, [entriesQuery]);
|
||||||
|
|
||||||
|
const togglePause = useCallback(() => {
|
||||||
|
if (isPaused) resume();
|
||||||
|
else pause();
|
||||||
|
}, [isPaused, pause, resume]);
|
||||||
|
|
||||||
|
// Selection: keep selectedId across refetch, fall back to first entry.
|
||||||
|
const activeSelectedEntryId = useMemo(() => {
|
||||||
|
if (backendEntries.length === 0) return null;
|
||||||
|
if (selectedEntryId && backendEntries.some((entry) => entry.id === selectedEntryId)) {
|
||||||
return selectedEntryId;
|
return selectedEntryId;
|
||||||
}
|
}
|
||||||
|
return backendEntries[0]?.id ?? null;
|
||||||
return nextEntries[0]?.id ?? null;
|
}, [backendEntries, selectedEntryId]);
|
||||||
}, [entriesQuery.data, selectedEntryId]);
|
|
||||||
|
|
||||||
const selectedEntry = useMemo(
|
const selectedEntry = useMemo(
|
||||||
() => (entriesQuery.data ?? []).find((entry) => entry.id === activeSelectedEntryId) ?? null,
|
() => backendEntries.find((entry) => entry.id === activeSelectedEntryId) ?? null,
|
||||||
[activeSelectedEntryId, entriesQuery.data]
|
[activeSelectedEntryId, backendEntries]
|
||||||
|
);
|
||||||
|
|
||||||
|
// selectionOutOfScope: a selection survived but is no longer in filtered entries.
|
||||||
|
const isSelectionOutOfScope = Boolean(
|
||||||
|
selectedEntryId && !backendEntries.some((entry) => entry.id === selectedEntryId)
|
||||||
);
|
);
|
||||||
|
|
||||||
const latestTimestamp = useMemo(() => {
|
const latestTimestamp = useMemo(() => {
|
||||||
@@ -76,6 +257,16 @@ export function useLogsWorkspace() {
|
|||||||
return timestamps.sort((left, right) => right.localeCompare(left))[0] ?? null;
|
return timestamps.sort((left, right) => right.localeCompare(left))[0] ?? null;
|
||||||
}, [sourcesQuery.data]);
|
}, [sourcesQuery.data]);
|
||||||
|
|
||||||
|
const clearAdvancedFilters = useCallback(() => {
|
||||||
|
setSearch('');
|
||||||
|
setSelectedLevel('all');
|
||||||
|
setSelectedSource('all');
|
||||||
|
setModuleFilter('');
|
||||||
|
setStageFilter('');
|
||||||
|
setRequestIdFilter('');
|
||||||
|
setTimeWindow('all');
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
configQuery,
|
configQuery,
|
||||||
sourcesQuery,
|
sourcesQuery,
|
||||||
@@ -86,16 +277,33 @@ export function useLogsWorkspace() {
|
|||||||
setSelectedLevel,
|
setSelectedLevel,
|
||||||
search,
|
search,
|
||||||
setSearch,
|
setSearch,
|
||||||
|
moduleFilter,
|
||||||
|
setModuleFilter,
|
||||||
|
stageFilter,
|
||||||
|
setStageFilter,
|
||||||
|
requestIdFilter,
|
||||||
|
setRequestIdFilter,
|
||||||
|
timeWindow,
|
||||||
|
setTimeWindow,
|
||||||
limit,
|
limit,
|
||||||
setLimit,
|
setLimit,
|
||||||
selectedEntryId: activeSelectedEntryId,
|
selectedEntryId: activeSelectedEntryId,
|
||||||
setSelectedEntryId,
|
setSelectedEntryId,
|
||||||
selectedEntry,
|
selectedEntry,
|
||||||
|
isSelectionOutOfScope,
|
||||||
latestTimestamp,
|
latestTimestamp,
|
||||||
isInitialLoading:
|
isInitialLoading:
|
||||||
(!configQuery.data && configQuery.isLoading) ||
|
(!configQuery.data && configQuery.isLoading) ||
|
||||||
(!sourcesQuery.data && sourcesQuery.isLoading) ||
|
(!sourcesQuery.data && sourcesQuery.isLoading) ||
|
||||||
(!entriesQuery.data && entriesQuery.isLoading),
|
(!entriesQuery.data && entriesQuery.isLoading),
|
||||||
|
liveTail: {
|
||||||
|
isPaused,
|
||||||
|
pendingCount,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
togglePause,
|
||||||
|
},
|
||||||
|
clearAdvancedFilters,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +337,16 @@ export function getLogLevelOptions(): Array<{ value: LogsLevelFilter; label: str
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLogsTimeWindowOptions(): Array<{ value: LogsTimeWindow; label: string }> {
|
||||||
|
return [
|
||||||
|
{ value: 'all', label: 'All time' },
|
||||||
|
{ value: '5m', label: 'Last 5m' },
|
||||||
|
{ value: '15m', label: 'Last 15m' },
|
||||||
|
{ value: '1h', label: 'Last 1h' },
|
||||||
|
{ value: '24h', label: 'Last 24h' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function getSelectedSourceLabel(
|
export function getSelectedSourceLabel(
|
||||||
source: LogsSourceFilter,
|
source: LogsSourceFilter,
|
||||||
sources: Array<{ source: string; label: string }>
|
sources: Array<{ source: string; label: string }>
|
||||||
|
|||||||
@@ -981,6 +981,14 @@ export interface LogsEntry {
|
|||||||
processId: number | null;
|
processId: number | null;
|
||||||
runId: string | null;
|
runId: string | null;
|
||||||
context?: unknown;
|
context?: unknown;
|
||||||
|
// Structured-log fields from #1141 contract. All optional; UI ships first,
|
||||||
|
// backend fills these incrementally. Order is stable and additive.
|
||||||
|
requestId?: string;
|
||||||
|
module?: string;
|
||||||
|
stage?: string;
|
||||||
|
latencyMs?: number;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
error?: { message: string; stack?: string; code?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogsEntriesParams {
|
export interface LogsEntriesParams {
|
||||||
|
|||||||
+11
-473
@@ -1,487 +1,25 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { LogsShell } from '@/components/logs/logs-shell';
|
||||||
import {
|
|
||||||
ArrowRight,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
RefreshCw,
|
|
||||||
ScrollText,
|
|
||||||
TimerReset,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { ErrorLogsMonitor } from '@/components/error-logs-monitor';
|
|
||||||
import { LogsConfigCard } from '@/components/logs/logs-config-card';
|
|
||||||
import { LogsDetailPanel } from '@/components/logs/logs-detail-panel';
|
|
||||||
import { LogsEntryList } from '@/components/logs/logs-entry-list';
|
|
||||||
import { LogsFilters } from '@/components/logs/logs-filters';
|
|
||||||
import { LogsPageSkeleton } from '@/components/logs/logs-page-skeleton';
|
import { LogsPageSkeleton } from '@/components/logs/logs-page-skeleton';
|
||||||
import { getSourceLabelMap, useLogsWorkspace, useUpdateLogsConfig } from '@/hooks/use-logs';
|
import { useLogsWorkspace, useUpdateLogsConfig } from '@/hooks/use-logs';
|
||||||
// TODO i18n: import { useTranslation } from 'react-i18next'; when keys are ready
|
|
||||||
|
|
||||||
const DESKTOP_LOGS_BREAKPOINT = 1200;
|
|
||||||
const LEFT_PANEL_WIDTH = 336;
|
|
||||||
const RIGHT_PANEL_WIDTH = 368;
|
|
||||||
const COLLAPSED_PANEL_WIDTH = 52;
|
|
||||||
|
|
||||||
function CollapsedPaneToggle({
|
|
||||||
side,
|
|
||||||
label,
|
|
||||||
onExpand,
|
|
||||||
}: {
|
|
||||||
side: 'left' | 'right';
|
|
||||||
label: string;
|
|
||||||
onExpand: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-full w-full flex-col items-center justify-center gap-4 bg-muted/5',
|
|
||||||
side === 'left' ? 'border-r border-border' : 'border-l border-border'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={onExpand}
|
|
||||||
aria-label={`Show ${label.toLowerCase()}`}
|
|
||||||
className="h-9 w-9 rounded-xl border border-border/70 bg-background/85 shadow-sm"
|
|
||||||
>
|
|
||||||
{side === 'left' ? (
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<span
|
|
||||||
className="text-[10px] font-semibold uppercase tracking-[0.14em] text-foreground/45"
|
|
||||||
style={{ writingMode: 'vertical-rl' }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs route — calm, virtualized 3-pane shell.
|
||||||
|
*
|
||||||
|
* Implementation lives in `LogsShell` (composition + layout) and the
|
||||||
|
* `logs/*` components (filters/list/detail/settings). This file is just the
|
||||||
|
* route + initial-load gate.
|
||||||
|
*/
|
||||||
export function LogsPage() {
|
export function LogsPage() {
|
||||||
// TODO i18n: uncomment when keys for Syncing/Refresh and other strings are added
|
|
||||||
// const { t } = useTranslation();
|
|
||||||
const workspace = useLogsWorkspace();
|
const workspace = useLogsWorkspace();
|
||||||
const updateConfig = useUpdateLogsConfig();
|
const updateConfig = useUpdateLogsConfig();
|
||||||
const sourceLabels = getSourceLabelMap(workspace.sourcesQuery.data ?? []);
|
|
||||||
const [isDesktopLayout, setIsDesktopLayout] = useState(() =>
|
|
||||||
typeof window !== 'undefined' ? window.innerWidth >= DESKTOP_LOGS_BREAKPOINT : false
|
|
||||||
);
|
|
||||||
const [isFiltersCollapsed, setIsFiltersCollapsed] = useState(false);
|
|
||||||
const [isDetailsCollapsed, setIsDetailsCollapsed] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const mediaQuery = window.matchMedia(`(min-width: ${DESKTOP_LOGS_BREAKPOINT}px)`);
|
|
||||||
const syncLayout = () => {
|
|
||||||
setIsDesktopLayout(window.innerWidth >= DESKTOP_LOGS_BREAKPOINT);
|
|
||||||
};
|
|
||||||
|
|
||||||
syncLayout();
|
|
||||||
mediaQuery.addEventListener('change', syncLayout);
|
|
||||||
return () => mediaQuery.removeEventListener('change', syncLayout);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (workspace.isInitialLoading) {
|
if (workspace.isInitialLoading) {
|
||||||
return <LogsPageSkeleton />;
|
return <LogsPageSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = workspace.configQuery.data;
|
if (!workspace.configQuery.data) {
|
||||||
if (!config) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <LogsShell workspace={workspace} updateConfig={updateConfig} />;
|
||||||
<div className="relative flex h-full min-h-full flex-col overflow-hidden border-t border-border/40 bg-background font-sans text-foreground antialiased selection:bg-primary/30 selection:text-primary">
|
|
||||||
<div className="pointer-events-none absolute inset-0 z-0 opacity-40 [background-image:radial-gradient(circle_at_1px_1px,rgba(38,38,36,0.08)_1px,transparent_0)] [background-size:14px_14px]" />
|
|
||||||
|
|
||||||
<div className="relative z-10 flex shrink-0 items-center justify-between border-b border-border/80 bg-card/95 px-6 py-2 backdrop-blur-xl shadow-md transition-all xl:px-8">
|
|
||||||
<div className="flex items-center gap-10">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="group flex h-9 w-9 items-center justify-center rounded-xl border-2 border-primary/20 bg-primary/5 text-primary shadow-inner transition-all hover:scale-110 active:scale-90">
|
|
||||||
<ScrollText className="h-4.5 w-4.5 transition-transform group-hover:rotate-6" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<span className="rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-primary-foreground shadow-lg shadow-primary/20">
|
|
||||||
Operational
|
|
||||||
</span>
|
|
||||||
<h1 className="text-[17px] font-semibold tracking-tight text-foreground">
|
|
||||||
Log Operations Center
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p className="font-mono text-[10px] font-medium uppercase tracking-[0.16em] text-foreground/45">
|
|
||||||
CCS.TOC.LOGS.STREAM.v3
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden h-8 w-px bg-border/80 md:block" />
|
|
||||||
|
|
||||||
<div className="hidden items-center gap-10 md:flex">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'h-2 w-2 rounded-full ring-4 transition-all duration-700',
|
|
||||||
config.redact
|
|
||||||
? 'bg-emerald-500 shadow-[0_0_20px_rgba(16,185,129,0.6)] ring-emerald-500/30'
|
|
||||||
: 'bg-zinc-600 ring-transparent'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/90">
|
|
||||||
Redaction
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] font-medium text-foreground/50">
|
|
||||||
{config.redact ? 'Enforced' : 'Standby'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg border-2 border-border bg-muted shadow-inner">
|
|
||||||
<TimerReset className="h-3.5 w-3.5 text-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/90">
|
|
||||||
Retention
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] font-medium text-foreground/50">
|
|
||||||
{config.retain_days}D / {config.rotate_mb}MB
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="group h-9 gap-3 rounded-xl border-2 border-border bg-muted px-5 text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground transition-all hover:bg-foreground hover:text-background active:scale-95 shadow-lg shadow-black/5"
|
|
||||||
onClick={() =>
|
|
||||||
void Promise.all([workspace.sourcesQuery.refetch(), workspace.entriesQuery.refetch()])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<RefreshCw
|
|
||||||
className={cn(
|
|
||||||
'h-3.5 w-3.5 transition-transform duration-500',
|
|
||||||
(workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching) &&
|
|
||||||
'animate-spin'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{!workspace.entriesQuery.isFetching && !workspace.sourcesQuery.isFetching && (
|
|
||||||
<div className="absolute -right-1 -top-1 h-1.5 w-1.5 rounded-full border border-muted bg-primary animate-pulse" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching
|
|
||||||
? /* TODO i18n: missing key for "Syncing" */ 'Syncing'
|
|
||||||
: /* TODO i18n: missing key for "Refresh" */ 'Refresh'}
|
|
||||||
</Button>
|
|
||||||
<div className="h-7 w-px bg-border/80" />
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-9 w-9 rounded-xl border-2 border-border bg-muted p-0 text-foreground transition-all hover:bg-foreground hover:text-background active:scale-95 shadow-lg shadow-black/5"
|
|
||||||
>
|
|
||||||
<Link to="/health">
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex min-h-0 flex-1 overflow-hidden">
|
|
||||||
<Tabs defaultValue="stream" className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-border/80 bg-card/80 px-6 py-2 backdrop-blur-xl shadow-inner xl:px-8">
|
|
||||||
<TabsList className="h-10 w-auto gap-1.5 rounded-xl border border-border/60 bg-muted/40 p-1">
|
|
||||||
<TabsTrigger
|
|
||||||
value="stream"
|
|
||||||
className="rounded-lg px-5 text-[11px] font-semibold uppercase tracking-[0.12em] text-foreground/60 transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-md"
|
|
||||||
>
|
|
||||||
Telemetry Stream
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="errors"
|
|
||||||
className="rounded-lg px-5 text-[11px] font-semibold uppercase tracking-[0.12em] text-foreground/60 transition-all data-[state=active]:bg-background data-[state=active]:text-primary data-[state=active]:shadow-md"
|
|
||||||
>
|
|
||||||
Legacy Errors
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<div className="hidden items-center gap-3 lg:flex">
|
|
||||||
<div className="flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1 shadow-inner">
|
|
||||||
<span className="relative flex h-1.5 w-1.5">
|
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
|
|
||||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.6)]"></span>
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-tight text-foreground/80">
|
|
||||||
Connected
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="pr-4 text-[11px] font-medium tabular-nums text-foreground/45">
|
|
||||||
{workspace.entriesQuery.data?.length ?? 0} captured
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TabsContent
|
|
||||||
value="stream"
|
|
||||||
className="m-0 flex min-h-0 flex-1 overflow-y-auto lg:overflow-hidden focus-visible:outline-none"
|
|
||||||
>
|
|
||||||
{isDesktopLayout ? (
|
|
||||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
|
||||||
<div
|
|
||||||
data-logs-pane="filters"
|
|
||||||
style={{ width: isFiltersCollapsed ? COLLAPSED_PANEL_WIDTH : LEFT_PANEL_WIDTH }}
|
|
||||||
className="flex min-h-0 shrink-0 bg-muted/5"
|
|
||||||
>
|
|
||||||
{isFiltersCollapsed ? (
|
|
||||||
<CollapsedPaneToggle
|
|
||||||
side="left"
|
|
||||||
label="Filters"
|
|
||||||
onExpand={() => setIsFiltersCollapsed(false)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full min-h-0 w-full flex-col border-r border-border p-5 2xl:p-6">
|
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
|
||||||
Filters
|
|
||||||
</p>
|
|
||||||
<p className="text-[11px] text-muted-foreground/70">
|
|
||||||
Search, source, and retention controls
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setIsFiltersCollapsed(true)}
|
|
||||||
aria-label="Hide filters"
|
|
||||||
className="h-9 w-9 rounded-xl border border-border/70 bg-background/85 shadow-sm"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollArea className="min-h-0 flex-1" data-logs-scroll-region="filters">
|
|
||||||
<div className="space-y-5 pr-4">
|
|
||||||
<LogsFilters
|
|
||||||
sources={workspace.sourcesQuery.data ?? []}
|
|
||||||
selectedSource={workspace.selectedSource}
|
|
||||||
onSourceChange={workspace.setSelectedSource}
|
|
||||||
selectedLevel={workspace.selectedLevel}
|
|
||||||
onLevelChange={workspace.setSelectedLevel}
|
|
||||||
search={workspace.search}
|
|
||||||
onSearchChange={workspace.setSearch}
|
|
||||||
limit={workspace.limit}
|
|
||||||
onLimitChange={workspace.setLimit}
|
|
||||||
onRefresh={() =>
|
|
||||||
void Promise.all([
|
|
||||||
workspace.sourcesQuery.refetch(),
|
|
||||||
workspace.entriesQuery.refetch(),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
isRefreshing={
|
|
||||||
workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="border-t border-border/20 pt-5">
|
|
||||||
<LogsConfigCard
|
|
||||||
config={config}
|
|
||||||
onSave={(payload) => updateConfig.mutate(payload)}
|
|
||||||
isPending={updateConfig.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
data-logs-pane="entries"
|
|
||||||
className="flex min-h-0 min-w-0 flex-1 overflow-hidden border-l border-r border-border bg-background/95"
|
|
||||||
>
|
|
||||||
<LogsEntryList
|
|
||||||
entries={workspace.entriesQuery.data ?? []}
|
|
||||||
selectedEntryId={workspace.selectedEntryId}
|
|
||||||
onSelect={workspace.setSelectedEntryId}
|
|
||||||
sourceLabels={sourceLabels}
|
|
||||||
isLoading={workspace.entriesQuery.isLoading}
|
|
||||||
isFetching={workspace.entriesQuery.isFetching}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
data-logs-pane="details"
|
|
||||||
style={{ width: isDetailsCollapsed ? COLLAPSED_PANEL_WIDTH : RIGHT_PANEL_WIDTH }}
|
|
||||||
className="flex min-h-0 shrink-0 bg-muted/5 shadow-inner"
|
|
||||||
>
|
|
||||||
{isDetailsCollapsed ? (
|
|
||||||
<CollapsedPaneToggle
|
|
||||||
side="right"
|
|
||||||
label="Details"
|
|
||||||
onExpand={() => setIsDetailsCollapsed(false)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full min-h-0 w-full flex-col">
|
|
||||||
<div className="flex items-center justify-between border-b border-border/50 bg-background/60 px-3 py-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
|
||||||
Details
|
|
||||||
</p>
|
|
||||||
<p className="text-[11px] text-muted-foreground/70">
|
|
||||||
Selected entry context and raw payload
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setIsDetailsCollapsed(true)}
|
|
||||||
aria-label="Hide details"
|
|
||||||
className="h-9 w-9 rounded-xl border border-border/70 bg-background/85 shadow-sm"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="min-h-0 flex-1 overflow-hidden">
|
|
||||||
<LogsDetailPanel
|
|
||||||
entry={workspace.selectedEntry}
|
|
||||||
sourceLabel={
|
|
||||||
workspace.selectedEntry
|
|
||||||
? sourceLabels[workspace.selectedEntry.source]
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
|
||||||
<div className="border-b border-border bg-muted/5 p-5">
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<LogsFilters
|
|
||||||
sources={workspace.sourcesQuery.data ?? []}
|
|
||||||
selectedSource={workspace.selectedSource}
|
|
||||||
onSourceChange={workspace.setSelectedSource}
|
|
||||||
selectedLevel={workspace.selectedLevel}
|
|
||||||
onLevelChange={workspace.setSelectedLevel}
|
|
||||||
search={workspace.search}
|
|
||||||
onSearchChange={workspace.setSearch}
|
|
||||||
limit={workspace.limit}
|
|
||||||
onLimitChange={workspace.setLimit}
|
|
||||||
onRefresh={() =>
|
|
||||||
void Promise.all([
|
|
||||||
workspace.sourcesQuery.refetch(),
|
|
||||||
workspace.entriesQuery.refetch(),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
isRefreshing={
|
|
||||||
workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<LogsConfigCard
|
|
||||||
config={config}
|
|
||||||
onSave={(payload) => updateConfig.mutate(payload)}
|
|
||||||
isPending={updateConfig.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-h-[32rem] flex-col overflow-hidden border-b border-border bg-background/95">
|
|
||||||
<LogsEntryList
|
|
||||||
entries={workspace.entriesQuery.data ?? []}
|
|
||||||
selectedEntryId={workspace.selectedEntryId}
|
|
||||||
onSelect={workspace.setSelectedEntryId}
|
|
||||||
sourceLabels={sourceLabels}
|
|
||||||
isLoading={workspace.entriesQuery.isLoading}
|
|
||||||
isFetching={workspace.entriesQuery.isFetching}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex min-h-[30rem] flex-col overflow-hidden bg-muted/5 shadow-inner">
|
|
||||||
<LogsDetailPanel
|
|
||||||
entry={workspace.selectedEntry}
|
|
||||||
sourceLabel={
|
|
||||||
workspace.selectedEntry
|
|
||||||
? sourceLabels[workspace.selectedEntry.source]
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent
|
|
||||||
value="errors"
|
|
||||||
className="m-0 flex-1 overflow-y-auto bg-background/20 p-6 focus-visible:outline-none xl:p-8"
|
|
||||||
>
|
|
||||||
<div className="mx-auto max-w-5xl space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
|
||||||
<div className="relative overflow-hidden rounded-[2.5rem] border-2 border-border bg-card/40 p-1.5 shadow-2xl shadow-black/10">
|
|
||||||
<div className="absolute inset-0 opacity-[0.02] pointer-events-none [background-image:radial-gradient(circle_at_center,var(--primary)_1px,transparent_0)] [background-size:24px_24px]" />
|
|
||||||
|
|
||||||
<Card className="rounded-[calc(2.5rem-0.375rem)] border-none bg-background/60 shadow-none overflow-hidden backdrop-blur-md">
|
|
||||||
<CardContent className="flex flex-col gap-6 p-10">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl border-2 border-primary/20 bg-primary/10 text-primary shadow-inner">
|
|
||||||
<ScrollText className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<p className="text-[10px] font-black uppercase tracking-[0.4em] text-primary">
|
|
||||||
Legacy Diagnostic Node
|
|
||||||
</p>
|
|
||||||
<p className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground/40">
|
|
||||||
CCS-MATRIX-FAILURE-MONITOR
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-full border border-border bg-background/50 px-3 py-1 text-[9px] font-black uppercase tracking-widest text-foreground/40 shadow-inner">
|
|
||||||
Mode: Historical
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h2 className="text-3xl font-black uppercase tracking-tighter text-foreground">
|
|
||||||
CLIProxy Failure Analysis
|
|
||||||
</h2>
|
|
||||||
<p className="max-w-3xl text-[15px] font-medium leading-relaxed text-muted-foreground/60">
|
|
||||||
Maintain oversight of legacy request failures while the unified stream
|
|
||||||
consolidates system-wide telemetry. This view provides direct access to the
|
|
||||||
historical failure matrix for deep-field debugging.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="h-px w-full bg-gradient-to-r from-transparent via-border to-transparent opacity-40" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-[2.5rem] border-2 border-border bg-muted/5 p-8 shadow-inner backdrop-blur-sm">
|
|
||||||
<div className="mb-6 flex items-center gap-3">
|
|
||||||
<div className="h-1.5 w-1.5 animate-pulse rounded-full bg-primary shadow-[0_0_8px_rgba(var(--primary),0.5)]" />
|
|
||||||
<span className="text-[10px] font-black uppercase tracking-[0.3em] text-foreground/40">
|
|
||||||
Realtime Monitoring Deck
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ErrorLogsMonitor />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user