mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
fix(logs-ui): stabilize the dashboard log layout
This commit is contained in:
@@ -13,7 +13,7 @@ export function LogLevelBadge({ level, className }: { level: LogsLevel; classNam
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-[0.18em]',
|
||||
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium uppercase tracking-[0.12em]',
|
||||
LEVEL_STYLES[level],
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Save, Settings2 } from 'lucide-react';
|
||||
import { Save, Settings2, ShieldAlert, RotateCcw, Activity } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { LogsConfig, UpdateLogsConfigPayload } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function parseInteger(value: string, fallback: number) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
@@ -41,40 +41,72 @@ export function LogsConfigCard({
|
||||
const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(config), [config, draft]);
|
||||
|
||||
return (
|
||||
<Card className="gap-4 border-border/70 bg-card/85 shadow-sm">
|
||||
<CardHeader className="space-y-2 border-b pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle>Logging policy</CardTitle>
|
||||
<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">
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted/30 px-5 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/5 border border-primary/20">
|
||||
<Settings2 className="h-3.5 w-3.5 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground">
|
||||
Logging Policy
|
||||
</h3>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.1em] text-foreground/45">
|
||||
Retention and privacy
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Keep retention, file rotation, redaction, and live tail depth aligned with the host.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="rounded-2xl border border-border/70 bg-background/70 p-4 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Current posture
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{config.enabled ? 'Logging is enabled' : 'Logging is disabled'} at{' '}
|
||||
{config.level.toUpperCase()} and above.
|
||||
</p>
|
||||
<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 className="space-y-6 p-5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="rounded-xl border border-border/40 bg-background/50 p-3 flex flex-col gap-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">
|
||||
Active Status
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[11px] font-semibold uppercase tracking-[0.1em]',
|
||||
config.enabled ? 'text-emerald-500' : 'text-zinc-500'
|
||||
)}
|
||||
>
|
||||
{config.enabled ? 'Live' : 'Off'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="rounded-full border px-2 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||
{config.redact ? 'Redacted' : 'Plain'}
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/40 bg-background/50 p-3 flex flex-col gap-1">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70">
|
||||
Redaction
|
||||
</span>
|
||||
<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="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/60 p-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="logs-enabled">Enabled</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Turn the unified logging pipeline on or off.
|
||||
<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">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="logs-enabled"
|
||||
className="text-[12px] font-semibold uppercase tracking-[0.12em]"
|
||||
>
|
||||
Pipeline
|
||||
</Label>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.08em] text-muted-foreground/55">
|
||||
Enable structured logging
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -83,14 +115,20 @@ export function LogsConfigCard({
|
||||
onCheckedChange={(checked) =>
|
||||
setDraft((current) => ({ ...current, enabled: checked }))
|
||||
}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/60 p-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="logs-redact">Redact payloads</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mask sensitive content before it lands in the log archive.
|
||||
<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
|
||||
@@ -99,101 +137,146 @@ export function LogsConfigCard({
|
||||
onCheckedChange={(checked) =>
|
||||
setDraft((current) => ({ ...current, redact: checked }))
|
||||
}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logs-config-level">Minimum level</Label>
|
||||
<Select
|
||||
value={draft.level}
|
||||
onValueChange={(value) =>
|
||||
setDraft((current) => ({ ...current, level: value as LogsConfig['level'] }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="logs-config-level"
|
||||
aria-label="Minimum log level"
|
||||
className="border-border/70 bg-background/70"
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<ShieldAlert className="h-3 w-3 text-primary/40" />
|
||||
<Label
|
||||
htmlFor="logs-config-level"
|
||||
className="text-[10px] font-semibold uppercase tracking-[0.12em] text-foreground/70"
|
||||
>
|
||||
Minimum Operational Threshold
|
||||
</Label>
|
||||
</div>
|
||||
<Select
|
||||
value={draft.level}
|
||||
onValueChange={(value) =>
|
||||
setDraft((current) => ({ ...current, level: value as LogsConfig['level'] }))
|
||||
}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="error">Error only</SelectItem>
|
||||
<SelectItem value="warn">Warn and above</SelectItem>
|
||||
<SelectItem value="info">Info and above</SelectItem>
|
||||
<SelectItem value="debug">Debug and above</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logs-rotate-mb">Rotate size (MB)</Label>
|
||||
<Input
|
||||
id="logs-rotate-mb"
|
||||
type="number"
|
||||
min={1}
|
||||
className="border-border/70 bg-background/70"
|
||||
value={draft.rotate_mb}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
rotate_mb: parseInteger(event.target.value, current.rotate_mb),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<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="space-y-2">
|
||||
<Label htmlFor="logs-retain-days">Retention (days)</Label>
|
||||
<Input
|
||||
id="logs-retain-days"
|
||||
type="number"
|
||||
min={1}
|
||||
className="border-border/70 bg-background/70"
|
||||
value={draft.retain_days}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
retain_days: parseInteger(event.target.value, current.retain_days),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
<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="space-y-2">
|
||||
<Label htmlFor="logs-buffer-size">Live buffer size</Label>
|
||||
<Input
|
||||
id="logs-buffer-size"
|
||||
type="number"
|
||||
min={1}
|
||||
className="border-border/70 bg-background/70"
|
||||
value={draft.live_buffer_size}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
live_buffer_size: parseInteger(event.target.value, current.live_buffer_size),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 border-t border-border/70 pt-4">
|
||||
<Button onClick={() => onSave(draft)} disabled={!isDirty || isPending} className="gap-2">
|
||||
<Save className="h-4 w-4" />
|
||||
Save policy
|
||||
<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" />
|
||||
Commit Changes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
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"
|
||||
>
|
||||
Reset
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Rollback Draft
|
||||
</Button>
|
||||
{isDirty ? <span className="text-sm text-muted-foreground">Unsaved changes</span> : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between bg-muted/20 px-5 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Activity className="h-2.5 w-2.5 text-primary/40" />
|
||||
<span className="text-[9px] font-medium uppercase tracking-[0.12em] text-foreground/30">
|
||||
Operational Logic v3.4
|
||||
</span>
|
||||
</div>
|
||||
{isDirty && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-1 w-1 rounded-full bg-amber-500 animate-pulse" />
|
||||
<span className="text-[9px] font-medium uppercase tracking-[0.12em] text-amber-500/70">
|
||||
Pending
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,46 @@
|
||||
import { FileJson, Info } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
FileJson,
|
||||
Info,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
Fingerprint,
|
||||
Database,
|
||||
Cpu,
|
||||
Activity,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import type { LogsEntry } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LogLevelBadge } from './log-level-badge';
|
||||
import { formatJson, formatLogTimestamp } from './utils';
|
||||
import { formatJson } from './utils';
|
||||
|
||||
function MetaRow({ label, value }: { label: string; value: string | number }) {
|
||||
function MetaRow({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon?: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background/70 p-3">
|
||||
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 break-all text-sm font-medium">{value}</p>
|
||||
<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">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && (
|
||||
<Icon className="h-3 w-3 text-primary/40 group-hover:text-primary transition-colors" />
|
||||
)}
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/70 transition-colors group-hover:text-primary/60">
|
||||
{label}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -24,92 +54,213 @@ export function LogsDetailPanel({
|
||||
}) {
|
||||
if (!entry) {
|
||||
return (
|
||||
<Card className="gap-4 border-border/70 bg-card/85 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Inspector</CardTitle>
|
||||
<CardDescription>
|
||||
Select an activity row to inspect its operational metadata.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-[42rem] items-center justify-center text-sm text-muted-foreground">
|
||||
Nothing selected yet.
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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">
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="gap-4 border-border/70 bg-card/85 shadow-sm">
|
||||
<CardHeader className="space-y-3 border-b pb-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<LogLevelBadge level={entry.level} />
|
||||
<span className="rounded-full border px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||
{sourceLabel ?? entry.source}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatLogTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl tracking-tight">{entry.event}</CardTitle>
|
||||
<CardDescription className="text-sm leading-6 text-foreground/85">
|
||||
{entry.message}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="details" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="details" className="gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
Details
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="raw" className="gap-2">
|
||||
<FileJson className="h-4 w-4" />
|
||||
Raw context
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex h-full flex-col bg-card/30 backdrop-blur-sm animate-in fade-in slide-in-from-right-4 duration-500">
|
||||
{/* Tactical Inspector Header */}
|
||||
<div className="relative shrink-0 border-b border-border bg-card/60 p-6 shadow-sm overflow-hidden">
|
||||
{/* Pattern Overlay */}
|
||||
<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]" />
|
||||
|
||||
<TabsContent value="details" className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<MetaRow label="Entry ID" value={entry.id} />
|
||||
<MetaRow label="Source" value={entry.source} />
|
||||
<MetaRow label="Process ID" value={entry.processId ?? 'Unavailable'} />
|
||||
<MetaRow label="Run ID" value={entry.runId ?? 'Unavailable'} />
|
||||
<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="rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Context summary
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
This entry belongs to{' '}
|
||||
<span className="font-medium text-foreground">{sourceLabel ?? entry.source}</span>{' '}
|
||||
and was emitted at the{' '}
|
||||
<span className="font-medium text-foreground">{entry.level}</span> threshold. Use
|
||||
the raw tab for the exact structured payload.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="raw">
|
||||
<ScrollArea className="h-[24rem] rounded-xl border bg-zinc-950 p-4 text-xs text-zinc-100">
|
||||
<pre className="whitespace-pre-wrap break-words font-mono">
|
||||
{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 ?? {},
|
||||
<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',
|
||||
})}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-6">
|
||||
<Tabs defaultValue="details" className="space-y-8">
|
||||
<TabsList className="grid h-auto w-full grid-cols-2 gap-1 rounded-xl border border-border/60 bg-muted/40 p-1">
|
||||
<TabsTrigger
|
||||
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"
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
Details
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="raw"
|
||||
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"
|
||||
>
|
||||
<FileJson className="h-3.5 w-3.5" />
|
||||
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">
|
||||
<MetaRow
|
||||
label="Entry Signature"
|
||||
value={entry.id.slice(0, 16) + '...'}
|
||||
icon={Fingerprint}
|
||||
/>
|
||||
<MetaRow label="Telemetry Origin" value={entry.source} icon={Database} />
|
||||
<MetaRow label="Process ID" value={entry.processId ?? 'NA'} icon={Cpu} />
|
||||
<MetaRow
|
||||
label="Operational Run"
|
||||
value={entry.runId?.slice(0, 8) ?? 'NA'}
|
||||
icon={ShieldCheck}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-[2rem] border border-border bg-muted/10 p-1 shadow-inner group">
|
||||
{/* Background Scanline */}
|
||||
<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" />
|
||||
|
||||
<div className="rounded-[calc(2rem-4px)] border border-dashed border-border/40 bg-background/50 p-6 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/5 border border-primary/20 text-primary shadow-inner">
|
||||
<Terminal className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-primary">
|
||||
Automated Summary
|
||||
</p>
|
||||
<p className="text-[10px] font-medium uppercase tracking-[0.1em] text-muted-foreground/55">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="raw"
|
||||
className="mt-0 animate-in fade-in slide-in-from-bottom-2 duration-500"
|
||||
>
|
||||
<div className="group relative rounded-2xl border-2 border-border bg-zinc-950 p-1 shadow-2xl transition-all hover:border-primary/20">
|
||||
{/* Copy HUD */}
|
||||
<div className="absolute right-4 top-4 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<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">
|
||||
JSON.RAW.MODE
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { AlertCircle, Inbox, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Activity, ArrowRight, Inbox, Loader2 } from 'lucide-react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { LogsEntry } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LogLevelBadge } from './log-level-badge';
|
||||
import { formatLogTimestamp, formatRelativeLogTime } from './utils';
|
||||
|
||||
export function LogsEntryList({
|
||||
entries,
|
||||
@@ -22,40 +20,79 @@ export function LogsEntryList({
|
||||
isFetching: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className="gap-4 border-border/70 bg-card/85 shadow-sm">
|
||||
<CardHeader className="space-y-1 border-b pb-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Activity queue</CardTitle>
|
||||
<CardDescription>
|
||||
Dense operational feed for the active source and severity mix.
|
||||
</CardDescription>
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background/50 backdrop-blur-sm">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card/40 px-6 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 animate-pulse rounded-full bg-primary shadow-[0_0_12px_rgba(var(--primary),0.6)]" />
|
||||
<h2 className="text-[12px] font-semibold uppercase tracking-[0.14em] text-foreground">
|
||||
Live Entry Stream
|
||||
</h2>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-border/60" />
|
||||
<div className="flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5">
|
||||
<Activity className="h-3 w-3 text-emerald-500" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.12em] text-emerald-600">
|
||||
Live telemetry
|
||||
</span>
|
||||
</div>
|
||||
{isFetching ? <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{isFetching && (
|
||||
<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 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">
|
||||
<div className="w-[6.5rem] shrink-0 px-6">Time</div>
|
||||
<div className="w-14 shrink-0 border-l border-border/10 px-2 text-center">Lvl</div>
|
||||
<div className="w-[15rem] shrink-0 border-l border-border/10 px-4">Source</div>
|
||||
<div className="flex-1 border-l border-border/10 px-4">Message</div>
|
||||
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-2 text-center">Proc</div>
|
||||
<div className="w-[5.5rem] shrink-0 border-l border-border/10 px-3 text-center">Run</div>
|
||||
<div className="w-11 shrink-0 border-l border-border/10 px-2 text-center">Open</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="space-y-3 px-6 pb-6">
|
||||
{[1, 2, 3, 4, 5].map((item) => (
|
||||
<div key={item} className="rounded-xl border p-4">
|
||||
<div className="h-3 w-20 rounded bg-muted" />
|
||||
<div className="mt-3 h-4 w-4/5 rounded bg-muted" />
|
||||
<div className="mt-2 h-3 w-2/5 rounded bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-1 p-2">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="h-10 w-full animate-pulse rounded-lg border border-border/5 bg-muted/20"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="flex min-h-[24rem] flex-col items-center justify-center gap-3 px-6 py-12 text-center text-muted-foreground">
|
||||
<Inbox className="h-8 w-8" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">No entries matched these filters.</p>
|
||||
<p className="text-sm">Try broadening the source, severity, or search terms.</p>
|
||||
<div className="flex h-full animate-in fade-in duration-1000 flex-col items-center justify-center gap-6 px-8 text-center">
|
||||
<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-[42rem]">
|
||||
<div className="space-y-2 px-3 pb-3">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col">
|
||||
{entries.map((entry) => {
|
||||
const isSelected = entry.id === selectedEntryId;
|
||||
|
||||
@@ -65,51 +102,114 @@ export function LogsEntryList({
|
||||
type="button"
|
||||
onClick={() => onSelect(entry.id)}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border px-4 py-3 text-left transition-colors',
|
||||
'group relative flex w-full items-center border-b border-border/5 px-0 py-2.5 text-left transition-all duration-150',
|
||||
isSelected
|
||||
? 'border-primary/40 bg-primary/5 shadow-sm'
|
||||
: 'border-border/70 bg-background/70 hover:border-primary/20 hover:bg-muted/30'
|
||||
? 'z-10 bg-primary/[0.08] shadow-[inset_4px_0_0_rgba(var(--primary),1)]'
|
||||
: 'bg-transparent hover:bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-3 xl:grid-cols-[7.5rem_minmax(0,1fr)] xl:items-start">
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] uppercase tracking-[0.22em] text-muted-foreground">
|
||||
{formatRelativeLogTime(entry.timestamp)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatLogTimestamp(entry.timestamp)}
|
||||
<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="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<LogLevelBadge level={entry.level} />
|
||||
<span className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
|
||||
|
||||
<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="text-xs text-muted-foreground">{entry.event}</span>
|
||||
<span className="truncate text-[10px] font-medium uppercase tracking-[0.1em] text-muted-foreground/55">
|
||||
{entry.event}
|
||||
</span>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm font-medium leading-6 text-foreground">
|
||||
{entry.message}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>PID {entry.processId ?? 'n/a'}</span>
|
||||
<span>Run {entry.runId ?? 'n/a'}</span>
|
||||
|
||||
<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>
|
||||
{entry.level === 'error' ? (
|
||||
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-red-500/20 bg-red-500/5 px-2.5 py-1 text-[11px] font-medium text-red-700 dark:text-red-300">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
Elevated severity
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Search, RefreshCw, SlidersHorizontal } from 'lucide-react';
|
||||
import { Search, RefreshCw, Filter, Shield, Zap } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { LogsSource } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { LogsLevelFilter, LogsSourceFilter } from '@/hooks/use-logs';
|
||||
import { getLogLevelOptions, getSelectedSourceLabel } from '@/hooks/use-logs';
|
||||
import { getLogLevelOptions } from '@/hooks/use-logs';
|
||||
|
||||
export function LogsFilters({
|
||||
sources,
|
||||
@@ -33,67 +32,76 @@ export function LogsFilters({
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
}) {
|
||||
const sourceLabel = getSelectedSourceLabel(selectedSource, sources);
|
||||
const levels = getLogLevelOptions();
|
||||
const limits = [50, 100, 150, 250];
|
||||
|
||||
return (
|
||||
<Card className="gap-4 border-border/70 bg-card/85 shadow-sm">
|
||||
<CardHeader className="space-y-3 border-b pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||
<CardTitle>Explorer filters</CardTitle>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardDescription>
|
||||
Narrow the stream the same way you would in a management console: start with source,
|
||||
then severity, then message text.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logs-search">Search</Label>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="logs-search"
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Search message, event, process, or run ID"
|
||||
className="h-11 border-border/70 bg-background/70 pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label>Source</Label>
|
||||
<span className="text-xs text-muted-foreground">{sourceLabel}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2" aria-label="Source filter">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSourceChange('all')}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
selectedSource === 'all'
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border/70 bg-background/70 text-muted-foreground hover:border-primary/30 hover:text-foreground'
|
||||
)}
|
||||
<div className="flex flex-col gap-6 animate-in fade-in slide-in-from-left-2 duration-700">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary shadow-[0_0_8px_rgba(var(--primary),0.5)]" />
|
||||
<Label
|
||||
htmlFor="logs-search"
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80"
|
||||
>
|
||||
All sources
|
||||
</button>
|
||||
Payload Search
|
||||
</Label>
|
||||
</div>
|
||||
<Zap className="h-3 w-3 text-primary/20" />
|
||||
</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
|
||||
id="logs-search"
|
||||
aria-label="Search"
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Scan for patterns..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Filter className="h-3 w-3 text-primary/40" />
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
||||
Source Matrix
|
||||
</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]">
|
||||
Global Stream
|
||||
</span>
|
||||
{selectedSource === 'all' && (
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary shadow-[0_0_8px_rgba(var(--primary),0.8)]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-1 mt-1">
|
||||
{sources.map((source) => (
|
||||
<button
|
||||
key={source.source}
|
||||
type="button"
|
||||
onClick={() => onSourceChange(source.source)}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
'rounded-lg border px-2.5 py-1.5 text-[10px] font-semibold uppercase tracking-[0.1em] transition-all active:scale-[0.97]',
|
||||
selectedSource === source.source
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border/70 bg-background/70 text-muted-foreground hover:border-primary/30 hover:text-foreground'
|
||||
? 'border-primary/50 bg-primary/10 text-primary shadow-sm'
|
||||
: 'border-border/40 bg-muted/20 text-foreground/40 hover:border-border hover:bg-muted/40 hover:text-foreground/80'
|
||||
)}
|
||||
>
|
||||
{source.label}
|
||||
@@ -101,60 +109,90 @@ export function LogsFilters({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Level</Label>
|
||||
<div className="flex flex-wrap gap-2" aria-label="Level filter">
|
||||
{levels.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onLevelChange(option.value)}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
selectedLevel === option.value
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border/70 bg-background/70 text-muted-foreground hover:border-primary/30 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Threshold Control */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Shield className="h-3 w-3 text-primary/40" />
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-[0.14em] text-foreground/80">
|
||||
Sensitivity
|
||||
</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5" aria-label="Level filter">
|
||||
{levels.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onLevelChange(option.value)}
|
||||
className={cn(
|
||||
'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]',
|
||||
selectedLevel === option.value
|
||||
? '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>{option.label}</span>
|
||||
<div
|
||||
className={cn(
|
||||
'h-0.5 w-4 rounded-full transition-colors',
|
||||
selectedLevel === option.value ? 'bg-primary' : 'bg-foreground/10'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-2xl border border-border/70 bg-background/70 p-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-[0.24em] text-muted-foreground">
|
||||
View window
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Keep the rendered slice tight when you are chasing a live issue.
|
||||
</p>
|
||||
{/* Operational Deck */}
|
||||
<div className="mt-4 rounded-2xl border-2 border-border bg-card/40 p-1.5 shadow-xl shadow-black/5">
|
||||
<div className="rounded-[calc(1rem-2px)] border border-dashed border-border bg-background/60 p-4 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.14em] text-primary">
|
||||
Operational Window
|
||||
</p>
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.1em] text-foreground/45">
|
||||
Tail Capacity
|
||||
</p>
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2" aria-label="Visible entries">
|
||||
|
||||
<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-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
'rounded-md border py-1.5 text-[11px] font-semibold tabular-nums transition-all active:scale-[0.95]',
|
||||
limit === option
|
||||
? 'border-primary/40 bg-primary/10 text-primary'
|
||||
: 'border-border/70 bg-background text-muted-foreground hover:border-primary/30 hover:text-foreground'
|
||||
? 'border-foreground bg-foreground text-background'
|
||||
: 'border-border/60 bg-muted/40 text-foreground/40 hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{option} entries
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="w-full gap-2" onClick={onRefresh}>
|
||||
<RefreshCw className={isRefreshing ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} />
|
||||
Refresh stream
|
||||
|
||||
<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>Refresh Entries</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -374,3 +374,50 @@
|
||||
.scrollbar-editor::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-scan {
|
||||
animation: scan 8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-right {
|
||||
from {
|
||||
transform: translateX(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-left {
|
||||
from {
|
||||
transform: translateX(-10px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.slide-in-from-right-4 {
|
||||
animation: slide-in-from-right 0.5s ease-out;
|
||||
}
|
||||
|
||||
.slide-in-from-left-2 {
|
||||
animation: slide-in-from-left 0.4s ease-out;
|
||||
}
|
||||
|
||||
.slide-in-from-bottom-2 {
|
||||
animation: enter 0.3s ease-out;
|
||||
}
|
||||
|
||||
+433
-165
@@ -1,41 +1,93 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
ShieldAlert,
|
||||
TimerReset,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
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 { LogsOverviewCards } from '@/components/logs/logs-overview-cards';
|
||||
import { LogsPageSkeleton } from '@/components/logs/logs-page-skeleton';
|
||||
import { getSourceLabelMap, useLogsWorkspace, useUpdateLogsConfig } from '@/hooks/use-logs';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogsPage() {
|
||||
const workspace = useLogsWorkspace();
|
||||
const updateConfig = useUpdateLogsConfig();
|
||||
const sourceLabels = getSourceLabelMap(workspace.sourcesQuery.data ?? []);
|
||||
const errors = [
|
||||
workspace.configQuery.error,
|
||||
workspace.sourcesQuery.error,
|
||||
workspace.entriesQuery.error,
|
||||
].filter(Boolean) as Error[];
|
||||
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) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<LogsPageSkeleton />
|
||||
</div>
|
||||
);
|
||||
return <LogsPageSkeleton />;
|
||||
}
|
||||
|
||||
const config = workspace.configQuery.data;
|
||||
@@ -44,173 +96,389 @@ export function LogsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="relative overflow-hidden rounded-3xl border bg-[linear-gradient(135deg,var(--background)_0%,color-mix(in_oklab,var(--card)_84%,var(--muted))_100%)] p-6 shadow-sm">
|
||||
<div className="pointer-events-none absolute -right-10 top-1/2 hidden -translate-y-1/2 select-none text-[120px] font-semibold tracking-[-0.08em] text-foreground/5 xl:block">
|
||||
LOGS
|
||||
</div>
|
||||
<div className="relative flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div className="space-y-3">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.24em] text-muted-foreground">
|
||||
<ScrollText className="h-3.5 w-3.5" />
|
||||
System logs
|
||||
<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="space-y-2">
|
||||
<h1 className="text-3xl font-semibold tracking-[-0.03em] text-foreground md:text-4xl">
|
||||
Operational log workspace
|
||||
</h1>
|
||||
<p className="max-w-3xl text-sm leading-6 text-muted-foreground md:text-[15px]">
|
||||
Work through the CCS event stream like a control center: source-first filtering,
|
||||
dense entry inspection, and retention controls beside the live activity feed.
|
||||
<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="grid gap-3 sm:grid-cols-3 xl:min-w-[26rem]">
|
||||
<Card className="border-border/70 bg-background/70 shadow-none">
|
||||
<CardContent className="space-y-1 p-4">
|
||||
<div className="inline-flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.22em] text-muted-foreground">
|
||||
<ShieldAlert className="h-3.5 w-3.5" />
|
||||
<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
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{config.redact ? 'Active' : 'Disabled'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sensitive fields are masked before persistence.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/70 bg-background/70 shadow-none">
|
||||
<CardContent className="space-y-1 p-4">
|
||||
<div className="inline-flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.22em] text-muted-foreground">
|
||||
<TimerReset className="h-3.5 w-3.5" />
|
||||
</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
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-foreground">{config.retain_days} days</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Rotation threshold {config.rotate_mb} MB.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex flex-wrap items-center gap-2 xl:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 bg-background/70"
|
||||
onClick={() =>
|
||||
void Promise.all([
|
||||
workspace.sourcesQuery.refetch(),
|
||||
workspace.entriesQuery.refetch(),
|
||||
])
|
||||
}
|
||||
>
|
||||
<RefreshCw
|
||||
className={
|
||||
workspace.entriesQuery.isFetching || workspace.sourcesQuery.isFetching
|
||||
? 'h-4 w-4 animate-spin'
|
||||
: 'h-4 w-4'
|
||||
}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button asChild variant="ghost" className="gap-2">
|
||||
<Link to="/health">
|
||||
Health
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</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
|
||||
? 'Syncing'
|
||||
: '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>
|
||||
|
||||
{errors.length > 0 ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Unable to fully load the logs workspace</AlertTitle>
|
||||
<AlertDescription>{errors[0]?.message}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<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>
|
||||
|
||||
<LogsOverviewCards
|
||||
config={config}
|
||||
sources={workspace.sourcesQuery.data ?? []}
|
||||
entries={workspace.entriesQuery.data ?? []}
|
||||
latestTimestamp={workspace.latestTimestamp}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="stream" className="space-y-5">
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="stream">Activity stream</TabsTrigger>
|
||||
<TabsTrigger value="errors">CLIProxy errors</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="stream" className="space-y-5">
|
||||
<div className="grid gap-5 xl:grid-cols-[18rem_minmax(0,1.2fr)_minmax(18rem,0.8fr)]">
|
||||
<div className="space-y-5 xl:sticky xl:top-5 xl:self-start">
|
||||
<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 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>
|
||||
|
||||
<LogsEntryList
|
||||
entries={workspace.entriesQuery.data ?? []}
|
||||
selectedEntryId={workspace.selectedEntryId}
|
||||
onSelect={workspace.setSelectedEntryId}
|
||||
sourceLabels={sourceLabels}
|
||||
isLoading={workspace.entriesQuery.isLoading}
|
||||
isFetching={workspace.entriesQuery.isFetching}
|
||||
/>
|
||||
<LogsDetailPanel
|
||||
entry={workspace.selectedEntry}
|
||||
sourceLabel={
|
||||
workspace.selectedEntry ? sourceLabels[workspace.selectedEntry.source] : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="errors" className="space-y-4">
|
||||
<Card className="border-border/70 bg-card/80 shadow-sm">
|
||||
<CardContent className="flex flex-col gap-2 p-5">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.22em] text-muted-foreground">
|
||||
Legacy diagnostics
|
||||
</p>
|
||||
<h2 className="text-xl font-semibold tracking-tight text-foreground">
|
||||
CLIProxy request-failure viewer
|
||||
</h2>
|
||||
<p className="max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
Keep the existing request-failure drilldown nearby while the structured CCS stream
|
||||
matures. This tab preserves the old operational workflow instead of burying it
|
||||
behind Home-only UI.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ErrorLogsMonitor />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,6 +131,11 @@ describe('LogsPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = fetchMock;
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the loading skeleton while the initial queries are pending', () => {
|
||||
@@ -184,11 +189,11 @@ describe('LogsPage', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /Worker retry scheduled/i }));
|
||||
|
||||
expect((await screen.findAllByText('task.retry')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('4121')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('4121').length).toBeGreaterThan(0);
|
||||
|
||||
await userEvent.click(screen.getByRole('tab', { name: /Raw context/i }));
|
||||
|
||||
expect(await screen.findByText(/"reason": "network jitter"/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/"runId": "run-2"/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/network jitter/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/run-2/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user