Add system alerts log with nav badge and Admin Alerts tab.
Persist job and ingestion warnings/errors for 7 days, surface a dismissible top-nav badge, treat stale OHLCV as a warning (e.g. ticker renames), and show market bar age on the ticker freshness chip.
This commit is contained in:
@@ -225,6 +225,50 @@ export function triggerJob(jobName: string, options?: { target_model?: BacktestT
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
// System events (operational warnings / errors)
|
||||
export interface SystemEvent {
|
||||
id: number;
|
||||
severity: 'warning' | 'error' | string;
|
||||
source: string;
|
||||
code: string;
|
||||
message: string;
|
||||
symbol: string | null;
|
||||
created_at: string | null;
|
||||
acknowledged_at: string | null;
|
||||
}
|
||||
|
||||
export interface SystemEventSummary {
|
||||
days: number;
|
||||
total: number;
|
||||
unacknowledged: number;
|
||||
unacknowledged_errors: number;
|
||||
unacknowledged_warnings: number;
|
||||
}
|
||||
|
||||
export function listSystemEvents(params?: {
|
||||
days?: number;
|
||||
severity?: string;
|
||||
unacknowledged_only?: boolean;
|
||||
}) {
|
||||
return apiClient
|
||||
.get<SystemEvent[]>('admin/system-events', { params })
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function getSystemEventSummary(days = 7) {
|
||||
return apiClient
|
||||
.get<SystemEventSummary>('admin/system-events/summary', { params: { days } })
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
export function acknowledgeSystemEvents(days = 7) {
|
||||
return apiClient
|
||||
.post<{ acknowledged: number }>('admin/system-events/acknowledge', null, {
|
||||
params: { days },
|
||||
})
|
||||
.then((r) => r.data);
|
||||
}
|
||||
|
||||
// Data cleanup
|
||||
export function cleanupData(olderThanDays: number) {
|
||||
return apiClient
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useSystemEvents, useAcknowledgeSystemEvents } from '../../hooks/useAdmin';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
import { Button } from '../ui/Button';
|
||||
|
||||
function formatWhen(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
const mins = Math.floor((Date.now() - d.getTime()) / 60_000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function severityClass(severity: string): string {
|
||||
if (severity === 'error') return 'text-red-300 border-red-400/30 bg-red-500/10';
|
||||
return 'text-amber-300 border-amber-400/30 bg-amber-500/10';
|
||||
}
|
||||
|
||||
export function SystemEventsPanel() {
|
||||
const { data: events, isLoading, isError } = useSystemEvents(7);
|
||||
const ack = useAcknowledgeSystemEvents();
|
||||
const unacked = (events ?? []).filter((e) => !e.acknowledged_at).length;
|
||||
|
||||
if (isLoading) return <SkeletonTable rows={4} cols={3} />;
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="glass p-4 text-sm text-red-300">
|
||||
Failed to load system events.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = events ?? [];
|
||||
|
||||
return (
|
||||
<div className="glass p-5">
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||
System alerts
|
||||
</h3>
|
||||
<p className="mt-1 text-[11.5px] text-gray-500">
|
||||
Operational warnings and errors — jobs, ingestion, renames/stale data, and other sources · last 7 days
|
||||
{unacked > 0 ? ` · ${unacked} open` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => ack.mutate(7)}
|
||||
disabled={ack.isPending || unacked === 0}
|
||||
>
|
||||
{ack.isPending ? 'Dismissing…' : 'Dismiss open alerts'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No warnings or errors in the last 7 days.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-white/[0.05]">
|
||||
{rows.map((e) => (
|
||||
<li
|
||||
key={e.id}
|
||||
className={`flex flex-col gap-1 py-2.5 sm:flex-row sm:items-start sm:justify-between sm:gap-4 ${
|
||||
e.acknowledged_at ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`num rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${severityClass(e.severity)}`}
|
||||
>
|
||||
{e.severity}
|
||||
</span>
|
||||
<span className="num text-[11px] text-gray-500">{e.source}</span>
|
||||
{e.symbol && (
|
||||
<span className="num text-[11px] font-medium text-blue-300">{e.symbol}</span>
|
||||
)}
|
||||
{e.acknowledged_at && (
|
||||
<span className="text-[10px] text-gray-600">dismissed</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[13px] leading-snug text-gray-200">{e.message}</p>
|
||||
<p className="mt-0.5 num text-[10px] text-gray-600">{e.code}</p>
|
||||
</div>
|
||||
<span className="num shrink-0 text-[11px] text-gray-500">{formatWhen(e.created_at)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { check as healthCheck } from '../../api/health';
|
||||
import { getRunningJobs } from '../../api/jobs';
|
||||
import { useMarketRegime } from '../../hooks/useMarketRegime';
|
||||
import { useSystemEventSummary, useAcknowledgeSystemEvents } from '../../hooks/useAdmin';
|
||||
import { regimeDot, regimeHeadline } from '../../lib/regime';
|
||||
import TickerSearch from './TickerSearch';
|
||||
|
||||
@@ -24,6 +26,7 @@ const linkClasses = (isActive: boolean) =>
|
||||
/** Desktop command bar — the mockup's top navigation. MobileNav covers <lg. */
|
||||
export default function TopBar() {
|
||||
const { role, username, logout } = useAuthStore();
|
||||
const [alertsOpen, setAlertsOpen] = useState(false);
|
||||
|
||||
const health = useQuery({
|
||||
queryKey: ['health'],
|
||||
@@ -42,6 +45,11 @@ export default function TopBar() {
|
||||
enabled: isBackendUp,
|
||||
});
|
||||
|
||||
const eventSummary = useSystemEventSummary(7);
|
||||
const ackEvents = useAcknowledgeSystemEvents();
|
||||
const unacked = eventSummary.data?.unacknowledged ?? 0;
|
||||
const unackedErrors = eventSummary.data?.unacknowledged_errors ?? 0;
|
||||
|
||||
const running = jobs.data?.running ?? [];
|
||||
const regime = useMarketRegime();
|
||||
|
||||
@@ -108,6 +116,85 @@ export default function TopBar() {
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* System alerts badge — open issues from jobs / ingestion */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAlertsOpen((v) => !v)}
|
||||
className={`relative flex items-center gap-1.5 rounded-md px-1.5 py-1 text-[11px] transition-colors ${
|
||||
unacked > 0
|
||||
? unackedErrors > 0
|
||||
? 'text-red-300 hover:bg-red-500/10'
|
||||
: 'text-amber-300 hover:bg-amber-500/10'
|
||||
: 'text-gray-500 hover:bg-white/[0.04] hover:text-gray-300'
|
||||
}`}
|
||||
title={unacked > 0 ? `${unacked} open system alert${unacked === 1 ? '' : 's'}` : 'No open system alerts'}
|
||||
aria-label={unacked > 0 ? `${unacked} open system alerts` : 'System alerts'}
|
||||
aria-expanded={alertsOpen}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 17h5l-1.4-1.4A2 2 0 0118 14.2V11a6 6 0 10-12 0v3.2c0 .5-.2 1-.6 1.4L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
{unacked > 0 && (
|
||||
<span className={`num min-w-[1.1rem] rounded-full px-1 text-center text-[10px] font-semibold ${
|
||||
unackedErrors > 0 ? 'bg-red-500/25 text-red-200' : 'bg-amber-500/25 text-amber-100'
|
||||
}`}>
|
||||
{unacked > 99 ? '99+' : unacked}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{alertsOpen && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="fixed inset-0 z-40 cursor-default"
|
||||
aria-label="Close alerts"
|
||||
onClick={() => setAlertsOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-full z-50 mt-2 w-72 rounded-xl border border-white/[0.08] bg-[#12141c] p-3 shadow-xl">
|
||||
<p className="text-[11px] font-medium text-gray-300">
|
||||
{unacked > 0
|
||||
? `${unacked} open alert${unacked === 1 ? '' : 's'} (last 7 days)`
|
||||
: 'No open alerts'}
|
||||
</p>
|
||||
{eventSummary.data && eventSummary.data.total > 0 && (
|
||||
<p className="mt-0.5 text-[10px] text-gray-500">
|
||||
{eventSummary.data.unacknowledged_errors} error
|
||||
{eventSummary.data.unacknowledged_errors === 1 ? '' : 's'}
|
||||
{' · '}
|
||||
{eventSummary.data.unacknowledged_warnings} warning
|
||||
{eventSummary.data.unacknowledged_warnings === 1 ? '' : 's'} open
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-3 flex flex-col gap-1.5">
|
||||
{role === 'admin' && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
onClick={() => setAlertsOpen(false)}
|
||||
className="rounded-md px-2 py-1.5 text-[12px] text-blue-300 transition-colors hover:bg-white/[0.04]"
|
||||
>
|
||||
View in Admin → Alerts
|
||||
</NavLink>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={unacked === 0 || ackEvents.isPending}
|
||||
onClick={() => {
|
||||
ackEvents.mutate(7, {
|
||||
onSuccess: () => setAlertsOpen(false),
|
||||
});
|
||||
}}
|
||||
className="rounded-md px-2 py-1.5 text-left text-[12px] text-gray-300 transition-colors hover:bg-white/[0.04] disabled:opacity-40"
|
||||
>
|
||||
{ackEvents.isPending ? 'Dismissing…' : 'Dismiss open alerts'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
isBackendUp ? 'bg-emerald-400 shadow-lg shadow-emerald-400/50' : 'bg-red-400 shadow-lg shadow-red-400/50'
|
||||
|
||||
@@ -337,6 +337,41 @@ export function useToggleJob() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── System events ──
|
||||
|
||||
export function useSystemEvents(days = 7) {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'system-events', days],
|
||||
queryFn: () => adminApi.listSystemEvents({ days }),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSystemEventSummary(days = 7) {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'system-events-summary', days],
|
||||
queryFn: () => adminApi.getSystemEventSummary(days),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAcknowledgeSystemEvents() {
|
||||
const qc = useQueryClient();
|
||||
const { addToast } = useToast();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (days = 7) => adminApi.acknowledgeSystemEvents(days),
|
||||
onSuccess: (data) => {
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'system-events'] });
|
||||
qc.invalidateQueries({ queryKey: ['admin', 'system-events-summary'] });
|
||||
addToast('success', `Dismissed ${data.acknowledged} alert${data.acknowledged === 1 ? '' : 's'}`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
addToast('error', error.message || 'Failed to dismiss alerts');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTriggerJob() {
|
||||
const qc = useQueryClient();
|
||||
const { addToast } = useToast();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SentimentProviderSettings } from '../components/admin/SentimentProvider
|
||||
import { DataCleanup } from '../components/admin/DataCleanup';
|
||||
import { JobControls } from '../components/admin/JobControls';
|
||||
import { PipelineReadinessPanel } from '../components/admin/PipelineReadinessPanel';
|
||||
import { SystemEventsPanel } from '../components/admin/SystemEventsPanel';
|
||||
import { RecommendationSettings } from '../components/admin/RecommendationSettings';
|
||||
import { ScheduleSettings } from '../components/admin/ScheduleSettings';
|
||||
import { SettingsForm } from '../components/admin/SettingsForm';
|
||||
@@ -15,7 +16,7 @@ import { UserTable } from '../components/admin/UserTable';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
import { Tabs } from '../components/ui/Tabs';
|
||||
|
||||
const tabs = ['Users', 'Tickers', 'Settings', 'Jobs', 'Cleanup'] as const;
|
||||
const tabs = ['Users', 'Tickers', 'Settings', 'Jobs', 'Alerts', 'Cleanup'] as const;
|
||||
type Tab = (typeof tabs)[number];
|
||||
|
||||
export default function AdminPage() {
|
||||
@@ -49,6 +50,11 @@ export default function AdminPage() {
|
||||
<PipelineReadinessPanel />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'Alerts' && (
|
||||
<div className="space-y-4">
|
||||
<SystemEventsPanel />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'Cleanup' && <DataCleanup />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,8 +171,10 @@ export default function TickerDetailPage() {
|
||||
const dataStatus: DataStatusItem[] = useMemo(() => [
|
||||
{
|
||||
label: 'OHLCV',
|
||||
// Market age of the latest bar (session date), not DB insert time —
|
||||
// created_at stays frozen when the provider returns no new sessions.
|
||||
available: !!ohlcv.data && ohlcv.data.length > 0,
|
||||
timestamp: ohlcv.data?.[ohlcv.data.length - 1]?.created_at,
|
||||
timestamp: ohlcv.data?.[ohlcv.data.length - 1]?.date,
|
||||
selector: ['ohlcv'] as FetchSelector,
|
||||
paid: true,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user