mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
* feat(ui): full frontend redesign to the Sentry-style design system Replace the legacy UI with the dark, mono-accent design language from design/23-sentry.html across every non-canvas surface. - PlatformShell: fixed 252px sidebar with grouped nav (Platform / Operate / Workspace), brand mark, user card, and a topbar with search (Cmd+K palette), env badge, upgrade, notifications, and theme toggle; mobile collapses to a top bar with horizontal nav - New shared component kit in shared/components/sentry.tsx: SPageHead, SStat, SCard, STable, SSection, SPill, Ticks, Seg, GhostBtn/QuietBtn/IconBtn, status tone helpers - New DashboardPage at / — stats row, per-node CPU chart with 1h/24h/7d ranges, resource usage tick strips, anomaly box, node summary table, recent deployments and project cards; real API data with demo-mode fixtures - Landing page moved to /landing and rebuilt: live host telemetry window, install strip, feature grid, template catalog, docs - Every page (Projects, Templates, Builds, Databases, HA, Operations, Activity, Security, Usage, Docs, People, Settings, Audit Logs, Webhooks, Admin, NodeDetail, ServiceDetail, auth, setup, not-found) migrated to the new kit and tokens - Nav active state resolves by longest matching href - Canvas internals intentionally untouched — separate pass Verified: tsc --noEmit, eslint, vite build, Playwright walkthrough of every route at desktop and mobile widths (demo + live session). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): deep audit pass — palette navigation, crash fix, honest badges - CommandPalette: full route navigation, project deep-links fetched on open, Create group only where onAddService is wired, dead Connect items removed, decorative kbd hints dropped; workspace passes its own view targets - NodeDetailPage crash: container status arrives as a Docker state object, not a string — flattened in listAgentContainers and guarded at render for persisted-cache payloads - env badge reflects import.meta.env.MODE instead of hardcoded 'production' - Relative times capped: >1y or future timestamps render 'n/a'/'—' (zero-time heartbeats no longer show 739898d ago) - Post-auth and setup redirects land on / (dashboard is the index) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): use SPageHead actions prop, satisfy tsc -b literal types Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
176 lines
7.0 KiB
TypeScript
176 lines
7.0 KiB
TypeScript
import { useDeferredValue, useState } from 'react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { listAuditLogs } from '@/lib/api-client';
|
|
import { useDemoMode } from '@/lib/demo-mode';
|
|
import { type FilterConfig } from '@/lib/dynamic-filter';
|
|
import { useDynamicFilter } from '@/lib/use-dynamic-filter';
|
|
import { formatDate, formatRelative } from '@/lib/time';
|
|
import { DemoRestricted, FilterBar } from '@/shared/components';
|
|
import { QuietBtn, SCard, SPageHead, SPill, STable, type SCol } from '@/shared/components/sentry';
|
|
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
|
import type { AuditLogEntity } from '@/lib/api-client';
|
|
|
|
const PAGE_SIZE = 50;
|
|
|
|
const rangeOptions = [
|
|
{ value: '', label: 'All time' },
|
|
{ value: '24h', label: 'Last 24 hours', ms: 24 * 60 * 60 * 1000 },
|
|
{ value: '7d', label: 'Last 7 days', ms: 7 * 24 * 60 * 60 * 1000 },
|
|
{ value: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 },
|
|
] as const;
|
|
|
|
// Server-backed filters — the engine runs over empty data; values map to
|
|
// API params. URL state makes filtered views shareable.
|
|
const AUDIT_SCHEMA: FilterConfig<unknown>[] = [
|
|
{ key: 'resource', label: 'Resource', type: 'search' },
|
|
{ key: 'action', label: 'Action', type: 'search' },
|
|
{ key: 'actor', label: 'Actor', type: 'search' },
|
|
{
|
|
key: 'range', label: 'Time', type: 'select',
|
|
options: rangeOptions.filter((o) => o.value !== '').map((o) => ({ value: o.value, label: o.label })),
|
|
},
|
|
];
|
|
|
|
export function AuditLogsPage() {
|
|
const isDemoMode = useDemoMode();
|
|
const filter = useDynamicFilter<unknown>({ data: [], schema: AUDIT_SCHEMA });
|
|
const [page, setPage] = useState(1);
|
|
// Back to page 1 whenever the filter set changes.
|
|
const filtersJson = JSON.stringify(filter.filters);
|
|
const [prevFiltersJson, setPrevFiltersJson] = useState(filtersJson);
|
|
if (prevFiltersJson !== filtersJson) {
|
|
setPrevFiltersJson(filtersJson);
|
|
setPage(1);
|
|
}
|
|
|
|
const deferredResource = useDeferredValue(String(filter.filters.resource ?? ''));
|
|
const deferredAction = useDeferredValue(String(filter.filters.action ?? ''));
|
|
const deferredActor = useDeferredValue(String(filter.filters.actor ?? ''));
|
|
const range = filter.filters.range === 'all' ? '' : String(filter.filters.range ?? '');
|
|
|
|
const filters = {
|
|
resource: deferredResource.trim() || undefined,
|
|
action: deferredAction.trim() || undefined,
|
|
actor: deferredActor.trim() || undefined,
|
|
page,
|
|
limit: PAGE_SIZE,
|
|
};
|
|
|
|
const logsQuery = useQuery({
|
|
queryKey: ['audit-logs', filters, range],
|
|
queryFn: () => {
|
|
const option = rangeOptions.find((o) => o.value === range);
|
|
const since = option && 'ms' in option ? new Date(Date.now() - option.ms).toISOString() : undefined;
|
|
return listAuditLogs({ ...filters, since });
|
|
},
|
|
enabled: !isDemoMode,
|
|
});
|
|
|
|
if (isDemoMode) {
|
|
return <DemoRestricted feature="Audit logs" />;
|
|
}
|
|
|
|
const logs = logsQuery.data ?? [];
|
|
const hasNext = logs.length === PAGE_SIZE;
|
|
const hasFilters = filter.activeCount > 0;
|
|
|
|
const cols: SCol<AuditLogEntity>[] = [
|
|
{
|
|
key: 'time', label: 'Time', width: '100px', sortable: false,
|
|
render: (log) => (
|
|
<span className="v-mono text-[11px] text-[var(--text-tertiary)]" title={log.createdAt ? formatDate(log.createdAt) : undefined}>
|
|
{log.createdAt ? formatRelative(log.createdAt) : '—'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'actor', label: 'Actor', width: 'minmax(140px,1.1fr)',
|
|
sortValue: (log) => log.userEmail ?? log.userId ?? 'system',
|
|
render: (log) => (
|
|
<span className="text-[12px] text-[var(--text-primary)] truncate">
|
|
{log.userEmail || (log.userId ? log.userId.slice(0, 8) : 'system')}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'resource', label: 'Resource', width: 'minmax(120px,1fr)',
|
|
sortValue: (log) => log.resource ?? '',
|
|
render: (log) => <span className="v-mono text-[11.5px] text-[var(--text-secondary)]">{log.resource}</span>,
|
|
},
|
|
{
|
|
key: 'action', label: 'Action', width: 'minmax(130px,1fr)',
|
|
sortValue: (log) => log.action ?? '',
|
|
render: (log) => <SPill tone="info">{log.action}</SPill>,
|
|
},
|
|
{
|
|
key: 'rid', label: 'Resource ID', width: 'minmax(110px,0.8fr)', sortable: false,
|
|
render: (log) => (
|
|
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)] truncate block" title={log.resourceId}>
|
|
{log.resourceId || '—'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'ip', label: 'IP', width: '110px', sortable: false,
|
|
render: (log) => <span className="v-mono text-[10.5px] text-[var(--text-tertiary)]">{log.ipAddress?.replace(/\/\d+$/, '') || '—'}</span>,
|
|
},
|
|
{
|
|
key: 'details', label: 'Details', width: 'minmax(160px,1.2fr)', sortable: false,
|
|
render: (log) => (
|
|
<span className="v-mono text-[10.5px] text-[var(--text-tertiary)] truncate block" title={log.details}>
|
|
{log.details && log.details !== '{}' ? log.details : '—'}
|
|
</span>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="min-h-screen">
|
|
<div className="w-full px-4 pt-6 sm:px-8">
|
|
<SPageHead
|
|
title="Audit Logs"
|
|
titleAccent="_"
|
|
sub="Every authenticated action recorded by the platform"
|
|
/>
|
|
</div>
|
|
|
|
<div className="w-full px-4 sm:px-8">
|
|
<SCard icon={<ScrollText />} title="Event Log" accent={`page ${page}`} pad={false}>
|
|
<div className="px-4 pt-1 pb-3">
|
|
<FilterBar {...filter} />
|
|
</div>
|
|
<div className="border-t border-[var(--border-subtle)] px-4 overflow-x-auto">
|
|
{logsQuery.isLoading ? (
|
|
<div className="py-10 text-center text-[var(--text-muted)]">
|
|
<Loader2 size={18} className="animate-spin inline-block" />
|
|
</div>
|
|
) : logsQuery.isError ? (
|
|
<div className="py-10 text-center text-[var(--error)]">Failed to load audit logs.</div>
|
|
) : logs.length === 0 ? (
|
|
<div className="py-10 text-center">
|
|
<div className="s-ibox mx-auto mb-2"><ScrollText /></div>
|
|
<p className="text-[var(--text-secondary)] text-sm">No audit events{hasFilters ? ' match these filters' : ' recorded yet'}.</p>
|
|
</div>
|
|
) : (
|
|
<STable cols={cols} rows={logs} rowKey={(l) => l.id} />
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-subtle)]">
|
|
<p className="v-mono text-[10.5px] text-[var(--text-muted)]">
|
|
Page {page}{logs.length > 0 ? ` — ${logs.length} entries` : ''}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<QuietBtn onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>
|
|
<ChevronLeft size={12} /> Prev
|
|
</QuietBtn>
|
|
<QuietBtn onClick={() => setPage((p) => p + 1)} disabled={!hasNext}>
|
|
Next <ChevronRight size={12} />
|
|
</QuietBtn>
|
|
</div>
|
|
</div>
|
|
</SCard>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|