mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user