feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

View File

@@ -0,0 +1,126 @@
import { IconAlarm, IconBell, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, memo, useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { api } from '../../api/client';
import type { AlertEvent, AlertQuery, AlertRule, AlertRuleInput, AlertStatus, MetricDefinition } from '../../api/types';
import { actionLabels, alertValue, canAct, formatAlertTime, operatorLabels, ruleCondition, severityLabels, statusLabels, thresholdText } from '../domain/alert';
import { InlineError } from '../shared/AsyncState';
import { usePlatformSession } from '../auth/AuthGate';
import { canAdminister, canOperate } from '../auth/session';
type Tab = 'events' | 'rules' | 'notifications';
type Filters = { keyword: string; severity: string; status: string; ruleId: string; protocol: string; dateFrom: string; dateTo: string };
const EMPTY_FILTERS: Filters = { keyword: '', severity: '', status: '', ruleId: '', protocol: '', dateFrom: '', dateTo: '' };
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'];
const NUMERIC_OPERATORS = ['gt', 'gte', 'lt', 'lte', 'eq', 'neq', 'between', 'outside'];
const BOOLEAN_OPERATORS = ['eq', 'neq', 'changed'];
function SeverityTag({ severity }: Pick<AlertEvent, 'severity'>) { return <span className={`v2-alert-severity is-${severity}`}><i />{severityLabels[severity]}</span>; }
function StatusTag({ status }: Pick<AlertEvent, 'status'>) { return <span className={`v2-alert-status is-${status}`}>{statusLabels[status]}</span>; }
const AlertRows = memo(function AlertRows({ rows, selectedID, onSelect }: { rows: AlertEvent[]; selectedID: string; onSelect: (id: string) => void }) {
return <>{rows.map((event) => <tr key={event.id} className={selectedID === event.id ? 'is-selected' : ''} onClick={() => onSelect(event.id)}>
<td><input type="radio" name="alert-event" checked={selectedID === event.id} onChange={() => onSelect(event.id)} aria-label={`选择 ${event.ruleName}`} /></td>
<td><SeverityTag severity={event.severity} /></td><td><strong>{event.plate || '—'}</strong><small>{event.vin}</small></td><td>{event.ruleName}</td><td>{event.protocol || '—'}</td>
<td>{formatAlertTime(event.triggeredAt)}</td><td>{formatAlertTime(event.recoveredAt)}</td><td><StatusTag status={event.status} /></td><td>{alertValue(event)}</td><td>{thresholdText(event)}</td><td title={event.location}>{event.location || '—'}</td><td>{event.handler || '—'}</td>
</tr>)}</>;
});
function EventInspector({ event, note, acting, actionError, editable, onNote, onAction }: { event?: AlertEvent; note: string; acting: boolean; actionError?: string; editable: boolean; onNote: (value: string) => void; onAction: (action: 'acknowledge' | 'close' | 'ignore') => void }) {
if (!event) return <aside className="v2-alert-inspector"><div className="v2-alert-side-empty"><IconAlarm /><strong></strong><span>线</span></div></aside>;
return <aside className="v2-alert-inspector"><header><div><strong>{event.ruleName}</strong><span><SeverityTag severity={event.severity} /><StatusTag status={event.status} /></span></div></header>
<section><h3></h3><dl><div><dt> ID</dt><dd>{event.id}</dd></div><div><dt> / </dt><dd>{event.ruleId} / v{event.ruleVersion}</dd></div><div><dt> / VIN</dt><dd>{event.plate || '—'} / {event.vin}</dd></div><div><dt></dt><dd>{formatAlertTime(event.triggeredAt)}</dd></div><div><dt></dt><dd>{formatAlertTime(event.recoveredAt)}</dd></div></dl></section>
<section><h3></h3><div className="v2-alert-evidence"><div><small></small><strong>{alertValue(event)}</strong></div><b>VS</b><div><small></small><strong>{thresholdText(event)}</strong></div></div><dl><div><dt> ID</dt><dd>{event.sourceEventId || '—'}</dd></div><div><dt></dt><dd>{event.protocol || '—'}</dd></div><div><dt> / </dt><dd>{formatAlertTime(event.eventAt)} / {formatAlertTime(event.receivedAt)}</dd></div></dl></section>
<section><h3></h3><div className="v2-alert-timeline">{event.actions?.map((item) => <article key={item.id}><i /><div><strong>{actionLabels[item.action] ?? item.action}</strong><span>{item.actor} · {formatAlertTime(item.createdAt)}</span>{item.note ? <p>{item.note}</p> : null}</div></article>)}</div></section>
<section><h3></h3>{editable ? <><textarea maxLength={200} placeholder="请输入处置说明(选填)" value={note} onChange={(e) => onNote(e.target.value)} /><small className="v2-alert-note-count">{note.length}/200</small>{actionError ? <p className="v2-alert-action-error">{actionError}</p> : null}<div className="v2-alert-actions"><button className="is-primary" disabled={acting || !canAct(event.status, 'acknowledge')} onClick={() => onAction('acknowledge')}></button><button disabled={acting || !canAct(event.status, 'close')} onClick={() => onAction('close')}></button><button disabled={acting || !canAct(event.status, 'ignore')} onClick={() => onAction('ignore')}></button></div></> : <p className="v2-role-notice"></p>}</section>
<nav className="v2-alert-links"><Link to={`/vehicles/${encodeURIComponent(event.vin)}`}></Link><Link to={`/tracks?vin=${encodeURIComponent(event.vin)}`}></Link><Link to={`/history?vin=${encodeURIComponent(event.vin)}`}></Link></nav>
</aside>;
}
function EventWorkspace({ filters, setFilters, rules, unread, editable, onTab }: { filters: Filters; setFilters: (next: Filters) => void; rules: AlertRule[]; unread: number; editable: boolean; onTab: (tab: Tab) => void }) {
const [draft, setDraft] = useState(filters); const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(20); const [selectedID, setSelectedID] = useState(''); const [note, setNote] = useState(''); const queryClient = useQueryClient();
const query: AlertQuery = useMemo(() => ({ ...Object.fromEntries(Object.entries(filters).filter(([, value]) => value)), limit, offset }), [filters, limit, offset]);
const baseQuery = useMemo(() => ({ ...query, limit: undefined, offset: undefined }), [query]);
const summary = useQuery({ queryKey: ['alert-summary-v2', baseQuery], queryFn: () => api.alertSummaryV2(baseQuery), staleTime: 8_000 });
const events = useQuery({ queryKey: ['alert-events-v2', query], queryFn: () => api.alertEventsV2(query), placeholderData: (previous) => previous, staleTime: 5_000 });
const rows = events.data?.items ?? [];
useEffect(() => { if (rows.length && !rows.some((item) => item.id === selectedID)) setSelectedID(rows[0].id); }, [rows, selectedID]);
const detail = useQuery({ queryKey: ['alert-event-v2', selectedID], queryFn: () => api.alertEventV2(selectedID), enabled: Boolean(selectedID), staleTime: 3_000 });
const action = useMutation({ mutationFn: ({ name, event }: { name: 'acknowledge' | 'close' | 'ignore'; event: AlertEvent }) => api.actOnAlertV2(event.id, { version: event.version, action: name, note }), onSuccess: async (event) => { setNote(''); queryClient.setQueryData(['alert-event-v2', event.id], event); await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-events-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] })]); } });
const submit = (e: FormEvent) => { e.preventDefault(); setFilters(draft); setOffset(0); };
const quickStatus = (status: string) => { const next = { ...filters, status }; setDraft(next); setFilters(next); setOffset(0); };
const totalPages = Math.max(1, Math.ceil((events.data?.total ?? 0) / limit)); const page = Math.floor(offset / limit) + 1; const sums = summary.data;
return <><form className="v2-alert-filter" onSubmit={submit}><label><span></span><div><IconSearch /><input value={draft.keyword} onChange={(e) => setDraft({ ...draft, keyword: e.target.value })} placeholder="车牌 / VIN / 规则名称" /></div></label><label><span></span><select value={draft.severity} onChange={(e) => setDraft({ ...draft, severity: e.target.value })}><option value=""></option><option value="critical"></option><option value="major"></option><option value="minor"></option></select></label><label><span></span><select value={draft.status} onChange={(e) => setDraft({ ...draft, status: e.target.value })}><option value=""></option>{Object.entries(statusLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label><span></span><select value={draft.ruleId} onChange={(e) => setDraft({ ...draft, ruleId: e.target.value })}><option value=""></option>{rules.map((rule) => <option key={rule.id} value={rule.id}>{rule.name}</option>)}</select></label><label><span></span><select value={draft.protocol} onChange={(e) => setDraft({ ...draft, protocol: e.target.value })}><option value=""></option>{PROTOCOLS.map((item) => <option key={item}>{item}</option>)}</select></label><label><span></span><input type="datetime-local" value={draft.dateFrom} onChange={(e) => setDraft({ ...draft, dateFrom: e.target.value })} /></label><label><span></span><input type="datetime-local" value={draft.dateTo} onChange={(e) => setDraft({ ...draft, dateTo: e.target.value })} /></label><button className="v2-primary-button"></button><button className="v2-secondary-button" type="button" onClick={() => { setDraft(EMPTY_FILTERS); setFilters(EMPTY_FILTERS); setOffset(0); }}></button></form>
{summary.isError ? <InlineError message={summary.error instanceof Error ? summary.error.message : '告警汇总读取失败'} onRetry={() => summary.refetch()} /> : null}
<section className="v2-alert-kpis">{[['活跃告警', sums?.active, '', ''], ['未处理', sums?.unprocessed, 'unprocessed', 'unprocessed'], ['处理中', sums?.processing, 'processing', 'processing'], ['已恢复', sums?.recovered, 'recovered', 'recovered'], ['已关闭', sums?.closed, 'closed', 'closed'], ['已忽略', sums?.ignored, 'ignored', 'ignored'], ['未读通知', unread, 'notice', 'notice']].map(([label, value, tone, status]) => <button key={String(label)} type="button" className={`is-${tone}`} onClick={() => status === 'notice' ? onTab('notifications') : quickStatus(String(status))}><small>{label as string}</small><strong>{Number(value ?? 0).toLocaleString('zh-CN')}</strong></button>)}</section>
{events.isError ? <InlineError message={events.error instanceof Error ? events.error.message : '告警事件读取失败'} onRetry={() => events.refetch()} /> : null}
<div className="v2-alert-workspace"><section className="v2-alert-table-card"><header><strong></strong><div><span> {(events.data?.total ?? 0).toLocaleString('zh-CN')} </span><button onClick={() => Promise.all([events.refetch(), summary.refetch(), detail.refetch()])}><IconRefresh /></button></div></header><div className="v2-alert-table-scroll"><table><thead><tr><th /><th></th><th> / VIN</th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead><tbody><AlertRows rows={rows} selectedID={selectedID} onSelect={setSelectedID} /></tbody></table>{events.isFetching ? <div className="v2-alert-loading"><i /></div> : null}{!events.isFetching && !rows.length ? <div className="v2-alert-empty"></div> : null}</div><footer><span> {page} / {totalPages} </span><div><button disabled={page <= 1} onClick={() => setOffset(Math.max(0, offset - limit))}></button><button disabled={page >= totalPages} onClick={() => setOffset(offset + limit)}></button><select value={limit} onChange={(e) => { setLimit(Number(e.target.value)); setOffset(0); }}><option value="20">20 /</option><option value="50">50 /</option></select></div></footer></section><EventInspector event={detail.data ?? rows.find((row) => row.id === selectedID)} note={note} acting={action.isPending} actionError={action.error instanceof Error ? action.error.message : undefined} editable={editable} onNote={setNote} onAction={(name) => { const event = detail.data; if (event) action.mutate({ name, event }); }} /></div></>;
}
function emptyRule(): AlertRuleInput { return { id: '', name: '', description: '', severity: 'major', valueType: 'numeric', metric: 'speed_kmh', operator: 'gt', threshold: 80, thresholdHigh: 100, durationSec: 60, recoveryOperator: 'lte', recoveryThreshold: 75, repeatIntervalSec: 600, scopeProtocols: [], scopeVins: [], scopeOems: [], scopeModels: [], scopeCompanies: [], notificationChannels: ['in_app'], enabled: true, version: 0 }; }
function ruleDraft(rule: AlertRule): AlertRuleInput {
return {
id: rule.id, name: rule.name, description: rule.description, severity: rule.severity, valueType: rule.valueType,
metric: rule.metric, operator: rule.operator, threshold: rule.threshold, thresholdHigh: rule.thresholdHigh, booleanThreshold: rule.booleanThreshold,
durationSec: rule.durationSec, recoveryOperator: rule.recoveryOperator, recoveryThreshold: rule.recoveryThreshold,
repeatIntervalSec: rule.repeatIntervalSec, scopeProtocols: [...(rule.scopeProtocols ?? [])], scopeVins: [...(rule.scopeVins ?? [])], scopeOems: [...(rule.scopeOems ?? [])], scopeModels: [...(rule.scopeModels ?? [])], scopeCompanies: [...(rule.scopeCompanies ?? [])],
notificationChannels: [...(rule.notificationChannels ?? ['in_app'])], enabled: rule.enabled, version: rule.version
};
}
function RulesWorkspace({ rules, metrics }: { rules: AlertRule[]; metrics: MetricDefinition[] }) {
const queryClient = useQueryClient();
const [selectedID, setSelectedID] = useState('');
const [draft, setDraft] = useState<AlertRuleInput>(emptyRule());
useEffect(() => { if (!selectedID && rules[0]) { setSelectedID(rules[0].id); setDraft(ruleDraft(rules[0])); } }, [rules, selectedID]);
const save = useMutation({ mutationFn: api.saveAlertRuleV2, onSuccess: async (rule) => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); await queryClient.invalidateQueries({ queryKey: ['alert-rules-v2'] }); } });
const toggle = useMutation({ mutationFn: (rule: AlertRule) => api.setAlertRuleEnabledV2(rule.id, { version: rule.version, enabled: !rule.enabled }), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['alert-rules-v2'] }); } });
const operators = draft.valueType === 'boolean' ? BOOLEAN_OPERATORS : NUMERIC_OPERATORS;
const availableMetrics = metrics.filter((metric) => metric.alertable && metric.valueType === draft.valueType);
const catalogLabels = Object.fromEntries(metrics.map((metric) => [metric.key, metric.label]));
const setList = (key: 'scopeProtocols' | 'scopeVins' | 'scopeOems' | 'scopeModels' | 'scopeCompanies', value: string) => setDraft({ ...draft, [key]: value.split(',').map((item) => item.trim()).filter(Boolean) });
return <div className="v2-alert-rules">
<section className="v2-alert-rule-list"><header><strong></strong><button onClick={() => { setSelectedID('__new__'); setDraft(emptyRule()); }}>+ </button></header>{rules.map((rule) => <button className={selectedID === rule.id ? 'is-selected' : ''} key={rule.id} onClick={() => { setSelectedID(rule.id); setDraft(ruleDraft(rule)); }}><i className={`is-${rule.severity}`} /><span><strong>{rule.name}</strong><small>{ruleCondition(rule, catalogLabels)} · v{rule.version}</small></span><em className={rule.enabled ? 'is-enabled' : ''}>{rule.enabled ? '已启用' : '已停用'}</em></button>)}</section>
<form className="v2-alert-rule-editor" onSubmit={(event) => { event.preventDefault(); save.mutate(draft); }}>
<header><div><strong>{draft.version ? '编辑规则' : '新建规则'}</strong><span></span></div>{draft.version ? <button type="button" onClick={() => { const current = rules.find((item) => item.id === draft.id); if (current) toggle.mutate(current); }}>{draft.enabled ? '停用规则' : '启用规则'}</button> : null}</header>
<div className="v2-rule-form-grid">
<label><span></span><input required maxLength={80} value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} /></label>
<label><span></span><select value={draft.severity} onChange={(e) => setDraft({ ...draft, severity: e.target.value as AlertRuleInput['severity'] })}><option value="critical"></option><option value="major"></option><option value="minor"></option></select></label>
<label><span></span><select value={draft.valueType} onChange={(e) => { const valueType = e.target.value as AlertRuleInput['valueType']; const metric = metrics.find((item) => item.alertable && item.valueType === valueType)?.key ?? ''; setDraft({ ...draft, valueType, operator: valueType === 'boolean' ? 'eq' : 'gt', metric }); }}><option value="numeric"></option><option value="boolean"></option></select></label>
<label><span></span><select required disabled={!availableMetrics.length} value={draft.metric} onChange={(e) => setDraft({ ...draft, metric: e.target.value })}>{availableMetrics.map((metric) => <option key={metric.key} value={metric.key}>{metric.label}{metric.unit ? ` (${metric.unit})` : ''}</option>)}</select></label>
<label><span></span><select value={draft.operator} onChange={(e) => setDraft({ ...draft, operator: e.target.value, durationSec: e.target.value === 'changed' ? 0 : draft.durationSec })}>{operators.map((value) => <option key={value} value={value}>{operatorLabels[value]}</option>)}</select></label>
{draft.operator === 'changed' ? <label><span></span><input value="false ↔ true" disabled /></label> : draft.valueType === 'boolean' ? <label><span></span><select value={draft.booleanThreshold ? 'true' : 'false'} onChange={(e) => setDraft({ ...draft, booleanThreshold: e.target.value === 'true' })}><option value="true"></option><option value="false"></option></select></label> : <label><span>{draft.operator === 'between' || draft.operator === 'outside' ? '区间下限' : '触发阈值'}</span><input type="number" step="0.1" value={draft.threshold} onChange={(e) => setDraft({ ...draft, threshold: Number(e.target.value) })} /></label>}
{draft.operator === 'between' || draft.operator === 'outside' ? <label><span></span><input type="number" step="0.1" value={draft.thresholdHigh} onChange={(e) => setDraft({ ...draft, thresholdHigh: Number(e.target.value) })} /></label> : null}
<label><span></span><input type="number" min="0" max="86400" disabled={draft.operator === 'changed'} value={draft.durationSec} onChange={(e) => setDraft({ ...draft, durationSec: Number(e.target.value) })} /></label>
<label><span></span><select value={draft.recoveryOperator} onChange={(e) => setDraft({ ...draft, recoveryOperator: e.target.value })}><option value=""></option>{['gt', 'gte', 'lt', 'lte', 'eq', 'neq'].map((value) => <option key={value} value={value}>{operatorLabels[value]}</option>)}</select></label>
<label><span></span><input type="number" step="0.1" value={draft.recoveryThreshold} onChange={(e) => setDraft({ ...draft, recoveryThreshold: Number(e.target.value) })} /></label>
<label><span></span><input type="number" min="0" max="604800" value={draft.repeatIntervalSec} onChange={(e) => setDraft({ ...draft, repeatIntervalSec: Number(e.target.value) })} /></label>
<label className="is-wide"><span></span><input value={draft.scopeProtocols.join(',')} onChange={(e) => setList('scopeProtocols', e.target.value)} /></label>
<label className="is-wide"><span> VIN </span><input value={draft.scopeVins.join(',')} onChange={(e) => setList('scopeVins', e.target.value)} /></label>
<label className="is-wide"><span></span><input value={draft.scopeOems.join(',')} onChange={(e) => setList('scopeOems', e.target.value)} /></label>
<label className="is-wide"><span></span><input value={draft.scopeModels.join(',')} onChange={(e) => setList('scopeModels', e.target.value)} /></label>
<label className="is-wide"><span></span><input value={draft.scopeCompanies.join(',')} onChange={(e) => setList('scopeCompanies', e.target.value)} /></label>
<label className="is-wide"><span></span><textarea maxLength={500} value={draft.description} onChange={(e) => setDraft({ ...draft, description: e.target.value })} /></label>
</div>
<footer><div><b></b><span> / </span></div>{save.error ? <em>{save.error.message}</em> : null}<button className="v2-primary-button" disabled={save.isPending}>{save.isPending ? '保存中' : '保存规则'}</button></footer>
</form>
</div>;
}
function NotificationsWorkspace({ editable }: { editable: boolean }) {
const queryClient = useQueryClient(); const notifications = useQuery({ queryKey: ['alert-notifications-v2', 'all'], queryFn: () => api.alertNotificationsV2(new URLSearchParams({ limit: '100' })), staleTime: 5_000 });
const read = useMutation({ mutationFn: api.readAlertNotificationsV2, onSuccess: async () => { await Promise.all([queryClient.invalidateQueries({ queryKey: ['alert-notifications-v2'] }), queryClient.invalidateQueries({ queryKey: ['alert-summary-v2'] })]); } });
return <div className="v2-alert-notifications"><header><div><strong></strong><span></span></div>{editable ? <button disabled={!notifications.data?.items.some((item) => !item.read)} onClick={() => read.mutate(notifications.data?.items.filter((item) => !item.read).map((item) => item.id) ?? [])}></button> : <span className="v2-role-badge"></span>}</header>{notifications.isError ? <InlineError message={notifications.error.message} onRetry={() => notifications.refetch()} /> : null}<div>{notifications.data?.items.map((item) => <article className={item.read ? 'is-read' : ''} key={item.id}><i className={`is-${item.severity}`} /><div><strong>{item.title}</strong><p>{item.content}</p><span>{formatAlertTime(item.createdAt)} · {item.read ? '已读' : '未读'}</span></div>{editable && !item.read ? <button onClick={() => read.mutate([item.id])}></button> : null}</article>)}</div><footer><b></b><span>SMS / </span><span>Email / </span><span>WeCom / </span></footer></div>;
}
export default function AlertsPage() {
const { session } = usePlatformSession(); const operator = canOperate(session); const admin = canAdminister(session);
const [params, setParams] = useSearchParams(); const initialTab = (params.get('tab') as Tab) || 'events'; const [tab, setTabState] = useState<Tab>(['events', 'rules', 'notifications'].includes(initialTab) ? initialTab : 'events');
const initialFilters: Filters = { ...EMPTY_FILTERS, keyword: params.get('vin') ?? params.get('keyword') ?? '', severity: params.get('severity') ?? '', status: params.get('status') ?? '', ruleId: params.get('ruleId') ?? '', protocol: params.get('protocol') ?? '' };
const [filters, setFilterState] = useState(initialFilters); const rules = useQuery({ queryKey: ['alert-rules-v2'], queryFn: api.alertRulesV2, staleTime: 30_000 }); const metrics = useQuery({ queryKey: ['metric-catalog-v2'], queryFn: api.metricCatalog, staleTime: 300_000, enabled: admin }); const notices = useQuery({ queryKey: ['alert-notifications-v2', 'unread'], queryFn: () => api.alertNotificationsV2(new URLSearchParams({ unreadOnly: 'true', limit: '100' })), staleTime: 5_000 });
const setTab = (next: Tab) => { setTabState(next); const copy = new URLSearchParams(params); copy.set('tab', next); setParams(copy, { replace: true }); };
const setFilters = (next: Filters) => { setFilterState(next); const copy = new URLSearchParams(); if (tab !== 'events') copy.set('tab', tab); Object.entries(next).forEach(([key, value]) => { if (value) copy.set(key, value); }); setParams(copy, { replace: true }); };
const activeTab = tab === 'rules' && !admin ? 'events' : tab;
return <div className="v2-alert-page"><header className="v2-alert-heading"><div><h2></h2><p></p></div></header><nav className="v2-alert-tabs"><button className={activeTab === 'events' ? 'is-active' : ''} onClick={() => setTab('events')}><IconAlarm /></button>{admin ? <button className={activeTab === 'rules' ? 'is-active' : ''} onClick={() => setTab('rules')}></button> : null}<button className={activeTab === 'notifications' ? 'is-active' : ''} onClick={() => setTab('notifications')}><IconBell />{(notices.data?.total ?? 0) > 0 ? <b>{notices.data?.total}</b> : null}</button></nav>{rules.isError ? <InlineError message={rules.error.message} onRetry={() => rules.refetch()} /> : null}{activeTab === 'rules' && metrics.isError ? <InlineError message={metrics.error.message} onRetry={() => metrics.refetch()} /> : null}{activeTab === 'events' ? <EventWorkspace filters={filters} setFilters={setFilters} rules={rules.data ?? []} unread={notices.data?.total ?? 0} editable={operator} onTab={setTab} /> : activeTab === 'rules' ? <RulesWorkspace rules={rules.data ?? []} metrics={metrics.data?.metrics ?? []} /> : <NotificationsWorkspace editable={operator} />}</div>;
}