diff --git a/ui/.gitignore b/ui/.gitignore index a547bf36..2005c0ca 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -1,5 +1,7 @@ # Logs logs +!src/components/logs/ +!src/components/logs/** *.log npm-debug.log* yarn-debug.log* diff --git a/ui/src/components/logs/log-level-badge.tsx b/ui/src/components/logs/log-level-badge.tsx new file mode 100644 index 00000000..adf85604 --- /dev/null +++ b/ui/src/components/logs/log-level-badge.tsx @@ -0,0 +1,24 @@ +import type { LogsLevel } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; +import { getLevelLabel } from './utils'; + +const LEVEL_STYLES: Record = { + 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 ( + + {getLevelLabel(level)} + + ); +} diff --git a/ui/src/components/logs/logs-config-card.tsx b/ui/src/components/logs/logs-config-card.tsx new file mode 100644 index 00000000..b9551b72 --- /dev/null +++ b/ui/src/components/logs/logs-config-card.tsx @@ -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 ( + + +
+ + Logging policy +
+ + Keep retention, file rotation, redaction, and live tail depth aligned with the host. + +
+ +
+
+
+

Current posture

+

+ {config.enabled ? 'Logging is enabled' : 'Logging is disabled'} at{' '} + {config.level.toUpperCase()} and above. +

+
+ + {config.redact ? 'Redacted' : 'Plain'} + +
+
+ +
+
+
+ +

+ Turn the unified logging pipeline on or off. +

+
+ + setDraft((current) => ({ ...current, enabled: checked })) + } + /> +
+ +
+
+ +

+ Mask sensitive content before it lands in the log archive. +

+
+ + setDraft((current) => ({ ...current, redact: checked })) + } + /> +
+
+ +
+ + +
+ +
+
+ + + setDraft((current) => ({ + ...current, + rotate_mb: parseInteger(event.target.value, current.rotate_mb), + })) + } + /> +
+
+ + + setDraft((current) => ({ + ...current, + retain_days: parseInteger(event.target.value, current.retain_days), + })) + } + /> +
+
+ +
+ + + setDraft((current) => ({ + ...current, + live_buffer_size: parseInteger(event.target.value, current.live_buffer_size), + })) + } + /> +
+ +
+ + + {isDirty ? Unsaved changes : null} +
+
+
+ ); +} diff --git a/ui/src/components/logs/logs-detail-panel.tsx b/ui/src/components/logs/logs-detail-panel.tsx new file mode 100644 index 00000000..4945a388 --- /dev/null +++ b/ui/src/components/logs/logs-detail-panel.tsx @@ -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 ( +
+

{label}

+

{value}

+
+ ); +} + +export function LogsDetailPanel({ + entry, + sourceLabel, +}: { + entry: LogsEntry | null; + sourceLabel?: string; +}) { + if (!entry) { + return ( + + + Entry details + Select a log entry to inspect metadata and raw context. + + + Nothing selected yet. + + + ); + } + + return ( + + +
+ + + {sourceLabel ?? entry.source} + + + {formatLogTimestamp(entry.timestamp)} + +
+
+ {entry.event} + + {entry.message} + +
+
+ + + + + + Details + + + + Raw context + + + + +
+ + + + +
+
+ + + +
+                {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 ?? {},
+                })}
+              
+
+
+
+
+
+ ); +} diff --git a/ui/src/components/logs/logs-entry-list.tsx b/ui/src/components/logs/logs-entry-list.tsx new file mode 100644 index 00000000..e3cc5b70 --- /dev/null +++ b/ui/src/components/logs/logs-entry-list.tsx @@ -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; + isLoading: boolean; + isFetching: boolean; +}) { + return ( + + +
+
+ Recent entries + Latest matches for the active filter set. +
+ {isFetching ? : null} +
+
+ + {isLoading ? ( +
+ {[1, 2, 3, 4, 5].map((item) => ( +
+
+
+
+
+ ))} +
+ ) : entries.length === 0 ? ( +
+ +
+

No entries matched these filters.

+

Try broadening the source, severity, or search terms.

+
+
+ ) : ( + +
+ {entries.map((entry) => { + const isSelected = entry.id === selectedEntryId; + + return ( + + ); + })} +
+
+ )} + + + ); +} diff --git a/ui/src/components/logs/logs-filters.tsx b/ui/src/components/logs/logs-filters.tsx new file mode 100644 index 00000000..f23cb01e --- /dev/null +++ b/ui/src/components/logs/logs-filters.tsx @@ -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 ( + + +
+ Log explorer + + Slice the unified CCS log stream by source, severity, or message content. + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + onSearchChange(event.target.value)} + placeholder="Search message, event, process, or run ID" + className="pl-9" + /> +
+
+
+
+ ); +} diff --git a/ui/src/components/logs/logs-overview-cards.tsx b/ui/src/components/logs/logs-overview-cards.tsx new file mode 100644 index 00000000..527a2315 --- /dev/null +++ b/ui/src/components/logs/logs-overview-cards.tsx @@ -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 ( +
+
+
+

{label}

+

{value}

+

{detail}

+
+
+ +
+
+
+ ); +} + +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 ( +
+ + + + +
+ Last ingested event:{' '} + {formatLogTimestamp(latestTimestamp)} +
+
+ ); +} diff --git a/ui/src/components/logs/logs-page-skeleton.tsx b/ui/src/components/logs/logs-page-skeleton.tsx new file mode 100644 index 00000000..bd31baf6 --- /dev/null +++ b/ui/src/components/logs/logs-page-skeleton.tsx @@ -0,0 +1,56 @@ +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +export function LogsPageSkeleton() { + return ( +
+ + + + + + + + +
+ {[1, 2, 3, 4].map((item) => ( + + + + + + + ))} +
+ +
+ + + +
+ {[1, 2, 3, 4].map((item) => ( + + ))} +
+
+ + + + +
+ + + + + + + + {[1, 2, 3, 4].map((item) => ( + + ))} + + +
+
+ ); +} diff --git a/ui/src/components/logs/utils.ts b/ui/src/components/logs/utils.ts new file mode 100644 index 00000000..16d0cff8 --- /dev/null +++ b/ui/src/components/logs/utils.ts @@ -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'; + } +}