fix(ci): track dashboard logs components

- unignore the new ui logs component directory

- add the missing logs viewer components required by ui build

Refs #926
This commit is contained in:
Tam Nhu Tran
2026-04-08 15:57:15 -04:00
parent 6c799b2e58
commit 4c15a072dc
9 changed files with 769 additions and 0 deletions
+2
View File
@@ -1,5 +1,7 @@
# Logs
logs
!src/components/logs/
!src/components/logs/**
*.log
npm-debug.log*
yarn-debug.log*
@@ -0,0 +1,24 @@
import type { LogsLevel } from '@/lib/api-client';
import { cn } from '@/lib/utils';
import { getLevelLabel } from './utils';
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 }) {
return (
<span
className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-[0.18em]',
LEVEL_STYLES[level],
className
)}
>
{getLevelLabel(level)}
</span>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { useEffect, useMemo, useState } from 'react';
import { Save, Settings2 } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import type { LogsConfig, UpdateLogsConfigPayload } from '@/lib/api-client';
function parseInteger(value: string, fallback: number) {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed)) {
return fallback;
}
return Math.max(0, parsed);
}
export function LogsConfigCard({
config,
onSave,
isPending,
}: {
config: LogsConfig;
onSave: (payload: UpdateLogsConfigPayload) => void;
isPending: boolean;
}) {
const [draft, setDraft] = useState(config);
useEffect(() => {
setDraft(config);
}, [config]);
const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(config), [config, draft]);
return (
<Card className="gap-4">
<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>
<CardDescription>
Keep retention, file rotation, redaction, and live tail depth aligned with the host.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="rounded-xl border bg-muted/30 p-4 text-sm">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-medium">Current posture</p>
<p className="text-muted-foreground">
{config.enabled ? 'Logging is enabled' : 'Logging is disabled'} at{' '}
{config.level.toUpperCase()} and above.
</p>
</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'}
</span>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 rounded-xl border 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.
</p>
</div>
<Switch
id="logs-enabled"
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft((current) => ({ ...current, enabled: checked }))
}
/>
</div>
<div className="flex items-center justify-between gap-3 rounded-xl border 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.
</p>
</div>
<Switch
id="logs-redact"
checked={draft.redact}
onCheckedChange={(checked) =>
setDraft((current) => ({ ...current, redact: checked }))
}
/>
</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">
<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}
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">Retention (days)</Label>
<Input
id="logs-retain-days"
type="number"
min={1}
value={draft.retain_days}
onChange={(event) =>
setDraft((current) => ({
...current,
retain_days: parseInteger(event.target.value, current.retain_days),
}))
}
/>
</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}
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">
<Button onClick={() => onSave(draft)} disabled={!isDirty || isPending} className="gap-2">
<Save className="h-4 w-4" />
Save policy
</Button>
<Button
variant="outline"
onClick={() => setDraft(config)}
disabled={!isDirty || isPending}
>
Reset
</Button>
{isDirty ? <span className="text-sm text-muted-foreground">Unsaved changes</span> : null}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,101 @@
import { FileJson, Info } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { LogsEntry } from '@/lib/api-client';
import { LogLevelBadge } from './log-level-badge';
import { formatJson, formatLogTimestamp } from './utils';
function MetaRow({ label, value }: { label: string; value: string | number }) {
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>
);
}
export function LogsDetailPanel({
entry,
sourceLabel,
}: {
entry: LogsEntry | null;
sourceLabel?: string;
}) {
if (!entry) {
return (
<Card className="gap-4">
<CardHeader>
<CardTitle>Entry details</CardTitle>
<CardDescription>Select a log entry to inspect metadata and raw context.</CardDescription>
</CardHeader>
<CardContent className="flex min-h-[34rem] items-center justify-center text-sm text-muted-foreground">
Nothing selected yet.
</CardContent>
</Card>
);
}
return (
<Card className="gap-4">
<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>{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>
<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>
</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 ?? {},
})}
</pre>
</ScrollArea>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { AlertCircle, Inbox, Loader2 } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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,
selectedEntryId,
onSelect,
sourceLabels,
isLoading,
isFetching,
}: {
entries: LogsEntry[];
selectedEntryId: string | null;
onSelect: (entryId: string) => void;
sourceLabels: Record<string, string>;
isLoading: boolean;
isFetching: boolean;
}) {
return (
<Card className="gap-4">
<CardHeader className="space-y-1 border-b pb-4">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle>Recent entries</CardTitle>
<CardDescription>Latest matches for the active filter set.</CardDescription>
</div>
{isFetching ? <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> : null}
</div>
</CardHeader>
<CardContent className="px-0">
{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>
) : 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>
</div>
) : (
<ScrollArea className="h-[34rem]">
<div className="space-y-3 px-4 pb-4">
{entries.map((entry) => {
const isSelected = entry.id === selectedEntryId;
return (
<button
key={entry.id}
type="button"
onClick={() => onSelect(entry.id)}
className={cn(
'w-full rounded-xl border px-4 py-3 text-left transition-colors',
isSelected
? 'border-primary bg-primary/5 shadow-sm'
: 'border-border bg-background hover:bg-muted/40'
)}
>
<div className="flex items-start justify-between gap-3">
<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">
{sourceLabels[entry.source] ?? entry.source}
</span>
</div>
<p className="line-clamp-2 text-sm font-medium leading-6">
{entry.message}
</p>
</div>
{entry.level === 'error' ? (
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" />
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>{entry.event}</span>
<span>{formatRelativeLogTime(entry.timestamp)}</span>
<span>{formatLogTimestamp(entry.timestamp)}</span>
</div>
</button>
);
})}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
);
}
+127
View File
@@ -0,0 +1,127 @@
import { Search, RefreshCw } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { LogsSource } from '@/lib/api-client';
import type { LogsLevelFilter, LogsSourceFilter } from '@/hooks/use-logs';
import { getLogLevelOptions, getSelectedSourceLabel } from '@/hooks/use-logs';
export function LogsFilters({
sources,
selectedSource,
onSourceChange,
selectedLevel,
onLevelChange,
search,
onSearchChange,
limit,
onLimitChange,
onRefresh,
isRefreshing,
}: {
sources: LogsSource[];
selectedSource: LogsSourceFilter;
onSourceChange: (value: LogsSourceFilter) => void;
selectedLevel: LogsLevelFilter;
onLevelChange: (value: LogsLevelFilter) => void;
search: string;
onSearchChange: (value: string) => void;
limit: number;
onLimitChange: (value: number) => void;
onRefresh: () => void;
isRefreshing: boolean;
}) {
const sourceLabel = getSelectedSourceLabel(selectedSource, sources);
return (
<Card className="gap-4">
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="space-y-1">
<CardTitle>Log explorer</CardTitle>
<CardDescription>
Slice the unified CCS log stream by source, severity, or message content.
</CardDescription>
</div>
<Button variant="outline" size="sm" className="gap-2" onClick={onRefresh}>
<RefreshCw className={isRefreshing ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} />
Refresh
</Button>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="logs-source-filter">Source</Label>
<Select value={selectedSource} onValueChange={(value) => onSourceChange(value)}>
<SelectTrigger id="logs-source-filter" aria-label="Source filter">
<SelectValue placeholder="All sources">{sourceLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All sources</SelectItem>
{sources.map((source) => (
<SelectItem key={source.source} value={source.source}>
{source.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="logs-level-filter">Level</Label>
<Select
value={selectedLevel}
onValueChange={(value) => onLevelChange(value as LogsLevelFilter)}
>
<SelectTrigger id="logs-level-filter" aria-label="Level filter">
<SelectValue />
</SelectTrigger>
<SelectContent>
{getLogLevelOptions().map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="logs-limit-filter">Visible entries</Label>
<Select value={String(limit)} onValueChange={(value) => onLimitChange(Number(value))}>
<SelectTrigger id="logs-limit-filter" aria-label="Visible entries">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[50, 100, 150, 250].map((option) => (
<SelectItem key={option} value={String(option)}>
{option} entries
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<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="pl-9"
/>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,90 @@
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';
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;
}) {
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">
<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} native • ${legacySources} legacy sources`}
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-xl border bg-card/70 px-4 py-3 text-sm text-muted-foreground">
Last ingested event:{' '}
<span className="font-medium text-foreground">{formatLogTimestamp(latestTimestamp)}</span>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function LogsPageSkeleton() {
return (
<div className="space-y-6" aria-label="Loading logs workspace">
<Card className="gap-4">
<CardHeader className="space-y-3">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-8 w-72" />
<Skeleton className="h-4 w-[30rem]" />
</CardHeader>
</Card>
<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 className="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_22rem]">
<Card className="gap-4">
<CardHeader className="space-y-3">
<Skeleton className="h-4 w-52" />
<div className="grid gap-3 md:grid-cols-4">
{[1, 2, 3, 4].map((item) => (
<Skeleton key={item} className="h-10 w-full" />
))}
</div>
</CardHeader>
<CardContent className="grid gap-4 xl:grid-cols-[22rem_minmax(0,1fr)]">
<Skeleton className="h-[26rem] w-full" />
<Skeleton className="h-[26rem] w-full" />
</CardContent>
</Card>
<Card className="gap-4">
<CardHeader className="space-y-3">
<Skeleton className="h-5 w-44" />
<Skeleton className="h-4 w-full" />
</CardHeader>
<CardContent className="space-y-4">
{[1, 2, 3, 4].map((item) => (
<Skeleton key={item} className="h-10 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import type { LogsLevel } from '@/lib/api-client';
export function formatLogTimestamp(timestamp: string | null | undefined) {
if (!timestamp) {
return 'No activity yet';
}
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
return timestamp;
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date);
}
export function formatRelativeLogTime(timestamp: string | null | undefined) {
if (!timestamp) {
return 'No activity yet';
}
const value = new Date(timestamp).getTime();
if (Number.isNaN(value)) {
return timestamp;
}
const diffSeconds = Math.round((value - Date.now()) / 1000);
const absSeconds = Math.abs(diffSeconds);
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
if (absSeconds < 60) {
return formatter.format(diffSeconds, 'second');
}
const diffMinutes = Math.round(diffSeconds / 60);
if (Math.abs(diffMinutes) < 60) {
return formatter.format(diffMinutes, 'minute');
}
const diffHours = Math.round(diffMinutes / 60);
if (Math.abs(diffHours) < 24) {
return formatter.format(diffHours, 'hour');
}
return formatter.format(Math.round(diffHours / 24), 'day');
}
export function formatCount(value: number) {
return new Intl.NumberFormat().format(value);
}
export function formatJson(value: unknown) {
if (value === null || value === undefined) {
return '{}';
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
export function getLevelLabel(level: LogsLevel) {
switch (level) {
case 'error':
return 'Error';
case 'warn':
return 'Warn';
case 'info':
return 'Info';
case 'debug':
return 'Debug';
}
}