mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
feat(logs-ui): redesign the log viewer workspace
- reshape the page into a management-center style control surface - add activity and CLIProxy error tabs with denser list/detail ergonomics - switch filters to practical chip groups and update the page tests Refs #926
This commit is contained in:
@@ -41,7 +41,7 @@ export function LogsConfigCard({
|
||||
const isDirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(config), [config, draft]);
|
||||
|
||||
return (
|
||||
<Card className="gap-4">
|
||||
<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" />
|
||||
@@ -52,10 +52,12 @@ export function LogsConfigCard({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="rounded-xl border bg-muted/30 p-4 text-sm">
|
||||
<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="font-medium">Current posture</p>
|
||||
<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.
|
||||
@@ -68,7 +70,7 @@ export function LogsConfigCard({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border p-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">
|
||||
@@ -84,7 +86,7 @@ export function LogsConfigCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border p-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-redact">Redact payloads</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -109,7 +111,11 @@ export function LogsConfigCard({
|
||||
setDraft((current) => ({ ...current, level: value as LogsConfig['level'] }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="logs-config-level" aria-label="Minimum log level">
|
||||
<SelectTrigger
|
||||
id="logs-config-level"
|
||||
aria-label="Minimum log level"
|
||||
className="border-border/70 bg-background/70"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -128,6 +134,7 @@ export function LogsConfigCard({
|
||||
id="logs-rotate-mb"
|
||||
type="number"
|
||||
min={1}
|
||||
className="border-border/70 bg-background/70"
|
||||
value={draft.rotate_mb}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
@@ -143,6 +150,7 @@ export function LogsConfigCard({
|
||||
id="logs-retain-days"
|
||||
type="number"
|
||||
min={1}
|
||||
className="border-border/70 bg-background/70"
|
||||
value={draft.retain_days}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
@@ -160,6 +168,7 @@ export function LogsConfigCard({
|
||||
id="logs-buffer-size"
|
||||
type="number"
|
||||
min={1}
|
||||
className="border-border/70 bg-background/70"
|
||||
value={draft.live_buffer_size}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({
|
||||
@@ -170,7 +179,7 @@ export function LogsConfigCard({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<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
|
||||
|
||||
@@ -24,12 +24,14 @@ export function LogsDetailPanel({
|
||||
}) {
|
||||
if (!entry) {
|
||||
return (
|
||||
<Card className="gap-4">
|
||||
<Card className="gap-4 border-border/70 bg-card/85 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Entry details</CardTitle>
|
||||
<CardDescription>Select a log entry to inspect metadata and raw context.</CardDescription>
|
||||
<CardTitle>Inspector</CardTitle>
|
||||
<CardDescription>
|
||||
Select an activity row to inspect its operational metadata.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex min-h-[34rem] items-center justify-center text-sm text-muted-foreground">
|
||||
<CardContent className="flex min-h-[42rem] items-center justify-center text-sm text-muted-foreground">
|
||||
Nothing selected yet.
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -37,7 +39,7 @@ export function LogsDetailPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="gap-4">
|
||||
<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} />
|
||||
@@ -49,7 +51,7 @@ export function LogsDetailPanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle>{entry.event}</CardTitle>
|
||||
<CardTitle className="text-2xl tracking-tight">{entry.event}</CardTitle>
|
||||
<CardDescription className="text-sm leading-6 text-foreground/85">
|
||||
{entry.message}
|
||||
</CardDescription>
|
||||
@@ -75,6 +77,18 @@ export function LogsDetailPanel({
|
||||
<MetaRow label="Process ID" value={entry.processId ?? 'Unavailable'} />
|
||||
<MetaRow label="Run ID" value={entry.runId ?? 'Unavailable'} />
|
||||
</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">
|
||||
|
||||
@@ -22,12 +22,14 @@ export function LogsEntryList({
|
||||
isFetching: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Card className="gap-4">
|
||||
<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>Recent entries</CardTitle>
|
||||
<CardDescription>Latest matches for the active filter set.</CardDescription>
|
||||
<CardTitle>Activity queue</CardTitle>
|
||||
<CardDescription>
|
||||
Dense operational feed for the active source and severity mix.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{isFetching ? <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> : null}
|
||||
</div>
|
||||
@@ -52,8 +54,8 @@ export function LogsEntryList({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[34rem]">
|
||||
<div className="space-y-3 px-4 pb-4">
|
||||
<ScrollArea className="h-[42rem]">
|
||||
<div className="space-y-2 px-3 pb-3">
|
||||
{entries.map((entry) => {
|
||||
const isSelected = entry.id === selectedEntryId;
|
||||
|
||||
@@ -63,33 +65,44 @@ export function LogsEntryList({
|
||||
type="button"
|
||||
onClick={() => onSelect(entry.id)}
|
||||
className={cn(
|
||||
'w-full rounded-xl border px-4 py-3 text-left transition-colors',
|
||||
'w-full rounded-2xl 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'
|
||||
? 'border-primary/40 bg-primary/5 shadow-sm'
|
||||
: 'border-border/70 bg-background/70 hover:border-primary/20 hover:bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<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)}
|
||||
</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">
|
||||
{sourceLabels[entry.source] ?? entry.source}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{entry.event}</span>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm font-medium leading-6">
|
||||
<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>
|
||||
</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>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { Search, RefreshCw } from 'lucide-react';
|
||||
import { Search, RefreshCw, SlidersHorizontal } 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 { cn } from '@/lib/utils';
|
||||
import type { LogsLevelFilter, LogsSourceFilter } from '@/hooks/use-logs';
|
||||
import { getLogLevelOptions, getSelectedSourceLabel } from '@/hooks/use-logs';
|
||||
|
||||
@@ -40,74 +34,24 @@ export function LogsFilters({
|
||||
isRefreshing: boolean;
|
||||
}) {
|
||||
const sourceLabel = getSelectedSourceLabel(selectedSource, sources);
|
||||
const levels = getLogLevelOptions();
|
||||
const limits = [50, 100, 150, 250];
|
||||
|
||||
return (
|
||||
<Card className="gap-4">
|
||||
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<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">
|
||||
<CardTitle>Log explorer</CardTitle>
|
||||
<CardDescription>
|
||||
Slice the unified CCS log stream by source, severity, or message content.
|
||||
Narrow the stream the same way you would in a management console: start with source,
|
||||
then severity, then message text.
|
||||
</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>
|
||||
|
||||
<CardContent className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logs-search">Search</Label>
|
||||
<div className="relative">
|
||||
@@ -117,10 +61,99 @@ export function LogsFilters({
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Search message, event, process, or run ID"
|
||||
className="pl-9"
|
||||
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'
|
||||
)}
|
||||
>
|
||||
All sources
|
||||
</button>
|
||||
{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',
|
||||
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'
|
||||
)}
|
||||
>
|
||||
{source.label}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2" 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',
|
||||
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'
|
||||
)}
|
||||
>
|
||||
{option} entries
|
||||
</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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -70,7 +70,7 @@ export function LogsOverviewCards({
|
||||
<MetricCard
|
||||
label="Coverage"
|
||||
value={formatCount(sources.length)}
|
||||
detail={`${nativeSources} native • ${legacySources} legacy sources`}
|
||||
detail={`${nativeSources} active sources${legacySources > 0 ? ` • ${legacySources} legacy` : ''}`}
|
||||
icon={Database}
|
||||
accent="bg-sky-500/10 text-sky-700 dark:text-sky-300"
|
||||
/>
|
||||
@@ -81,7 +81,7 @@ export function LogsOverviewCards({
|
||||
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">
|
||||
<div className="md:col-span-2 xl:col-span-4 rounded-2xl border border-border/70 bg-card/70 px-4 py-3 text-sm text-muted-foreground shadow-sm">
|
||||
Last ingested event:{' '}
|
||||
<span className="font-medium text-foreground">{formatLogTimestamp(latestTimestamp)}</span>
|
||||
</div>
|
||||
|
||||
+138
-52
@@ -1,7 +1,17 @@
|
||||
import { AlertCircle, ArrowRight, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ErrorLogsMonitor } from '@/components/error-logs-monitor';
|
||||
import { LogsConfigCard } from '@/components/logs/logs-config-card';
|
||||
import { LogsDetailPanel } from '@/components/logs/logs-detail-panel';
|
||||
import { LogsEntryList } from '@/components/logs/logs-entry-list';
|
||||
@@ -34,36 +44,82 @@ export function LogsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto space-y-6">
|
||||
<div className="rounded-2xl border bg-gradient-to-br from-background via-background to-muted/40 p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<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">
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-muted-foreground">System logs</p>
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Unified CCS logging</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
|
||||
Watch current CCS activity across native and legacy emitters, inspect structured
|
||||
context, and keep retention policy aligned with what the host actually stores.
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => workspace.entriesQuery.refetch()}
|
||||
>
|
||||
<RefreshCw
|
||||
className={workspace.entriesQuery.isFetching ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
|
||||
/>
|
||||
Refresh entries
|
||||
</Button>
|
||||
<Button asChild variant="ghost" className="gap-2">
|
||||
<Link to="/health">
|
||||
Open health checks
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<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" />
|
||||
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" />
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,25 +139,42 @@ export function LogsPage() {
|
||||
latestTimestamp={workspace.latestTimestamp}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_22rem]">
|
||||
<div className="space-y-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}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
|
||||
<LogsEntryList
|
||||
entries={workspace.entriesQuery.data ?? []}
|
||||
selectedEntryId={workspace.selectedEntryId}
|
||||
@@ -117,14 +190,27 @@ export function LogsPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<LogsConfigCard
|
||||
config={config}
|
||||
onSave={(payload) => updateConfig.mutate(payload)}
|
||||
isPending={updateConfig.isPending}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ function buildEntries(source: string) {
|
||||
source: 'agent-runner',
|
||||
event: 'task.retry',
|
||||
message: 'Worker retry scheduled',
|
||||
processId: 'worker-1',
|
||||
processId: 4121,
|
||||
runId: 'run-2',
|
||||
context: { attempt: 2, reason: 'network jitter' },
|
||||
},
|
||||
@@ -56,7 +56,7 @@ function buildEntries(source: string) {
|
||||
source: 'dashboard',
|
||||
event: 'logs.bootstrap',
|
||||
message: 'Boot sequence failed for dashboard logging',
|
||||
processId: 'api-1',
|
||||
processId: 25582,
|
||||
runId: 'run-1',
|
||||
context: { component: 'dashboard', stage: 'bootstrap' },
|
||||
},
|
||||
@@ -67,7 +67,7 @@ function buildEntries(source: string) {
|
||||
source: 'agent-runner',
|
||||
event: 'task.retry',
|
||||
message: 'Worker retry scheduled',
|
||||
processId: 'worker-1',
|
||||
processId: 4121,
|
||||
runId: 'run-2',
|
||||
context: { attempt: 2, reason: 'network jitter' },
|
||||
},
|
||||
@@ -150,8 +150,7 @@ describe('LogsPage', () => {
|
||||
(await screen.findAllByText('Boot sequence failed for dashboard logging')).length
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await userEvent.click(screen.getByRole('combobox', { name: 'Source filter' }));
|
||||
await userEvent.click(await screen.findByRole('option', { name: 'Agent Runner' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Agent Runner' }));
|
||||
|
||||
expect((await screen.findAllByText('Worker retry scheduled')).length).toBeGreaterThan(0);
|
||||
await waitFor(() => {
|
||||
@@ -185,7 +184,7 @@ 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('worker-1')).toBeInTheDocument();
|
||||
expect(screen.getByText('4121')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('tab', { name: /Raw context/i }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user