perf(web): React.memo on list rows (EventRow, Row, HeldRow, FleetRow, EmptyRow, LogRow, QueueTable + badges), useMemo for filters/derived (Stream/Reports/Queue/Calendar/Dashboard), route code-split (lazy+Suspense in App) to cut main chunk; pure helpers unchanged
Some checks failed
CI / verify (push) Failing after 5m46s
Some checks failed
CI / verify (push) Failing after 5m46s
This commit is contained in:
parent
02a67a56ca
commit
1708effd57
11 changed files with 186 additions and 144 deletions
137
web/src/App.tsx
137
web/src/App.tsx
|
|
@ -1,26 +1,27 @@
|
|||
import { useEffect } from 'react';
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
|
||||
import { navigate, useHashRoute } from './useHashRoute';
|
||||
import { SectionLabel } from './ui';
|
||||
import { ControlView } from './views/ControlView';
|
||||
import { TriageView } from './views/TriageView';
|
||||
import { DetailView } from './views/DetailView';
|
||||
import { ReportsView } from './views/ReportsView';
|
||||
import { MarketsView } from './views/MarketsView';
|
||||
import { QueueView } from './views/QueueView';
|
||||
import { PastebinView } from './views/PastebinView';
|
||||
import { CampaignsView } from './views/CampaignsView';
|
||||
import { IntroThreadView } from './views/IntroThreadView';
|
||||
import { HostsView } from './views/HostsView';
|
||||
import { SettingsView } from './views/SettingsView';
|
||||
import { CalendarView } from './views/CalendarView';
|
||||
import { ProspectsView } from './views/ProspectsView';
|
||||
import { StreamView } from './views/StreamView';
|
||||
import { DashboardView } from './views/DashboardView';
|
||||
import { BackfillView } from './views/BackfillView';
|
||||
import { VoiceView } from './views/VoiceView';
|
||||
import { AutopilotView } from './views/AutopilotView';
|
||||
import { ModelView } from './views/ModelView';
|
||||
|
||||
const ControlView = lazy(() => import('./views/ControlView').then((m) => ({ default: m.ControlView })));
|
||||
const TriageView = lazy(() => import('./views/TriageView').then((m) => ({ default: m.TriageView })));
|
||||
const DetailView = lazy(() => import('./views/DetailView').then((m) => ({ default: m.DetailView })));
|
||||
const ReportsView = lazy(() => import('./views/ReportsView').then((m) => ({ default: m.ReportsView })));
|
||||
const MarketsView = lazy(() => import('./views/MarketsView').then((m) => ({ default: m.MarketsView })));
|
||||
const QueueView = lazy(() => import('./views/QueueView').then((m) => ({ default: m.QueueView })));
|
||||
const PastebinView = lazy(() => import('./views/PastebinView').then((m) => ({ default: m.PastebinView })));
|
||||
const CampaignsView = lazy(() => import('./views/CampaignsView').then((m) => ({ default: m.CampaignsView })));
|
||||
const IntroThreadView = lazy(() => import('./views/IntroThreadView').then((m) => ({ default: m.IntroThreadView })));
|
||||
const HostsView = lazy(() => import('./views/HostsView').then((m) => ({ default: m.HostsView })));
|
||||
const SettingsView = lazy(() => import('./views/SettingsView').then((m) => ({ default: m.SettingsView })));
|
||||
const CalendarView = lazy(() => import('./views/CalendarView').then((m) => ({ default: m.CalendarView })));
|
||||
const ProspectsView = lazy(() => import('./views/ProspectsView').then((m) => ({ default: m.ProspectsView })));
|
||||
const StreamView = lazy(() => import('./views/StreamView').then((m) => ({ default: m.StreamView })));
|
||||
const DashboardView = lazy(() => import('./views/DashboardView').then((m) => ({ default: m.DashboardView })));
|
||||
const BackfillView = lazy(() => import('./views/BackfillView').then((m) => ({ default: m.BackfillView })));
|
||||
const VoiceView = lazy(() => import('./views/VoiceView').then((m) => ({ default: m.VoiceView })));
|
||||
const AutopilotView = lazy(() => import('./views/AutopilotView').then((m) => ({ default: m.AutopilotView })));
|
||||
const ModelView = lazy(() => import('./views/ModelView').then((m) => ({ default: m.ModelView })));
|
||||
|
||||
interface NavItem {
|
||||
readonly view: string;
|
||||
|
|
@ -151,49 +152,55 @@ export function App(): JSX.Element {
|
|||
}
|
||||
|
||||
function ViewRouter({ view, param }: { view: string; param: string | null }): JSX.Element {
|
||||
switch (view) {
|
||||
// ── built ──
|
||||
case 'triage':
|
||||
return <TriageView />;
|
||||
case 'prospect':
|
||||
return param ? <DetailView handle={param} /> : <TriageView />;
|
||||
case 'intro':
|
||||
return param ? <IntroThreadView id={param} /> : <ReportsView />;
|
||||
case 'queue':
|
||||
return <QueueView />;
|
||||
case 'campaigns':
|
||||
return <CampaignsView />;
|
||||
case 'reports':
|
||||
return <ReportsView />;
|
||||
case 'markets':
|
||||
return <MarketsView />;
|
||||
case 'pastebin':
|
||||
return <PastebinView />;
|
||||
case 'services':
|
||||
case 'hosts':
|
||||
return <HostsView />;
|
||||
case 'control':
|
||||
return <ControlView />;
|
||||
// ── planned (P-1 shell parity; built in Waves A/B) ──
|
||||
case 'dashboard':
|
||||
return <DashboardView />;
|
||||
case 'prospects':
|
||||
return <ProspectsView />;
|
||||
case 'stream':
|
||||
return <StreamView />;
|
||||
case 'backfill':
|
||||
return <BackfillView />;
|
||||
case 'calendar':
|
||||
return <CalendarView />;
|
||||
case 'model':
|
||||
return <ModelView />;
|
||||
case 'voice':
|
||||
return <VoiceView />;
|
||||
case 'settings':
|
||||
return <SettingsView />;
|
||||
case 'autopilot':
|
||||
return <AutopilotView />;
|
||||
default:
|
||||
return <ControlView />;
|
||||
}
|
||||
return (
|
||||
<Suspense fallback={<div style={{ padding: 20, color: '#6b6170' }}>Loading view…</div>}>
|
||||
{(() => {
|
||||
switch (view) {
|
||||
// ── built ──
|
||||
case 'triage':
|
||||
return <TriageView />;
|
||||
case 'prospect':
|
||||
return param ? <DetailView handle={param} /> : <TriageView />;
|
||||
case 'intro':
|
||||
return param ? <IntroThreadView id={param} /> : <ReportsView />;
|
||||
case 'queue':
|
||||
return <QueueView />;
|
||||
case 'campaigns':
|
||||
return <CampaignsView />;
|
||||
case 'reports':
|
||||
return <ReportsView />;
|
||||
case 'markets':
|
||||
return <MarketsView />;
|
||||
case 'pastebin':
|
||||
return <PastebinView />;
|
||||
case 'services':
|
||||
case 'hosts':
|
||||
return <HostsView />;
|
||||
case 'control':
|
||||
return <ControlView />;
|
||||
// ── planned (P-1 shell parity; built in Waves A/B) ──
|
||||
case 'dashboard':
|
||||
return <DashboardView />;
|
||||
case 'prospects':
|
||||
return <ProspectsView />;
|
||||
case 'stream':
|
||||
return <StreamView />;
|
||||
case 'backfill':
|
||||
return <BackfillView />;
|
||||
case 'calendar':
|
||||
return <CalendarView />;
|
||||
case 'model':
|
||||
return <ModelView />;
|
||||
case 'voice':
|
||||
return <VoiceView />;
|
||||
case 'settings':
|
||||
return <SettingsView />;
|
||||
case 'autopilot':
|
||||
return <AutopilotView />;
|
||||
default:
|
||||
return <ControlView />;
|
||||
}
|
||||
})()}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import { getActivity, type ActivityItem } from '../api';
|
||||
import { formatTime, usePoll } from '../usePoll';
|
||||
|
||||
function Row({ item }: { item: ActivityItem }): JSX.Element {
|
||||
const Row = memo(function Row({ item }: { item: ActivityItem }): JSX.Element {
|
||||
return (
|
||||
<div className="logrow">
|
||||
<span className="logrow__time">{formatTime(item.createdAt)}</span>
|
||||
|
|
@ -11,7 +13,7 @@ function Row({ item }: { item: ActivityItem }): JSX.Element {
|
|||
{item.holdReason ? <span className="logrow__reason">{item.holdReason}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function ActivityFeed(): JSX.Element {
|
||||
const { data, error, loading } = usePoll(() => getActivity(50), 15_000);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { memo } from 'react';
|
||||
|
||||
import { getHeldQueue, type ActivityItem } from '../api';
|
||||
import { formatTime, usePoll } from '../usePoll';
|
||||
|
||||
function HeldRow({ item }: { item: ActivityItem }): JSX.Element {
|
||||
const HeldRow = memo(function HeldRow({ item }: { item: ActivityItem }): JSX.Element {
|
||||
return (
|
||||
<div className="held">
|
||||
<div className="held__top">
|
||||
|
|
@ -14,7 +16,7 @@ function HeldRow({ item }: { item: ActivityItem }): JSX.Element {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function HeldQueue(): JSX.Element {
|
||||
const { data, error, loading } = usePoll(() => getHeldQueue(50), 30_000);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { usePoll } from '../usePoll';
|
||||
import { getBookings, type BookingItem } from '../api';
|
||||
import { navigate } from '../useHashRoute';
|
||||
|
|
@ -13,10 +15,14 @@ export function CalendarView(): JSX.Element {
|
|||
if (loading || !data) return <Card><Muted>Loading bookings…</Muted></Card>;
|
||||
|
||||
// Group by day (already grouped in backend sample, but client can too)
|
||||
const groups = data.items.reduce<Record<string, BookingItem[]>>((acc, b) => {
|
||||
(acc[b.day] ||= []).push(b);
|
||||
return acc;
|
||||
}, {});
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
data.items.reduce<Record<string, BookingItem[]>>((acc, b) => {
|
||||
(acc[b.day] ||= []).push(b);
|
||||
return acc;
|
||||
}, {}),
|
||||
[data.items],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack $gap={12}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
|
|
@ -72,22 +72,30 @@ export function DashboardView(): JSX.Element {
|
|||
|
||||
const { kpis, density, insights, actions } = data;
|
||||
|
||||
const densityRows: readonly { key: string; count: number }[] = density.map((d) => ({
|
||||
key: `${d.hour}h`,
|
||||
count: d.count,
|
||||
}));
|
||||
const densityRows: readonly { key: string; count: number }[] = useMemo(
|
||||
() => density.map((d) => ({ key: `${d.hour}h`, count: d.count })),
|
||||
[density],
|
||||
);
|
||||
|
||||
const kpiEntries = Object.entries(kpis);
|
||||
const volumeRows: readonly { key: string; count: number }[] = kpiEntries
|
||||
.filter(([, v]) => typeof v === 'number')
|
||||
.map(([k, v]) => ({
|
||||
key: k.replace(/7d|InRange/gi, '').trim() || k,
|
||||
count: Number(v),
|
||||
}));
|
||||
const kpiEntries = useMemo(() => Object.entries(kpis), [kpis]);
|
||||
const volumeRows: readonly { key: string; count: number }[] = useMemo(
|
||||
() =>
|
||||
kpiEntries
|
||||
.filter(([, v]) => typeof v === 'number')
|
||||
.map(([k, v]) => ({
|
||||
key: k.replace(/7d|InRange/gi, '').trim() || k,
|
||||
count: Number(v),
|
||||
})),
|
||||
[kpiEntries],
|
||||
);
|
||||
|
||||
const peak = density.length
|
||||
? density.reduce((a, b) => (b.count > a.count ? b : a))
|
||||
: null;
|
||||
const peak = useMemo(
|
||||
() =>
|
||||
density.length
|
||||
? density.reduce((a, b) => (b.count > a.count ? b : a))
|
||||
: null,
|
||||
[density],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack $gap={14}>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
getGpuStatus,
|
||||
|
|
@ -333,7 +333,7 @@ export function HostsView(): JSX.Element {
|
|||
);
|
||||
}
|
||||
|
||||
function FleetRow({ label, ip, highlight }: { label: string; ip: string; highlight?: boolean }): JSX.Element {
|
||||
const FleetRow = memo(function FleetRow({ label, ip, highlight }: { label: string; ip: string; highlight?: boolean }): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -352,7 +352,7 @@ function FleetRow({ label, ip, highlight }: { label: string; ip: string; highlig
|
|||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function CodeSnippet({ label, code }: { label: string; code: string }): JSX.Element {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
abortTask,
|
||||
|
|
@ -82,9 +82,13 @@ export function QueueView(): JSX.Element {
|
|||
|
||||
// Handle search is client-side over the polled items (type/priority hit the API).
|
||||
const needle = search.trim().toLowerCase();
|
||||
const visible = needle
|
||||
? tasks.filter((t) => `${t.handle ?? ''} ${t.prospectName ?? ''}`.toLowerCase().includes(needle))
|
||||
: tasks;
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
needle
|
||||
? tasks.filter((t) => `${t.handle ?? ''} ${t.prospectName ?? ''}`.toLowerCase().includes(needle))
|
||||
: tasks,
|
||||
[needle, tasks],
|
||||
);
|
||||
|
||||
const toggle = (id: string): void =>
|
||||
setSelected((prev) => {
|
||||
|
|
@ -141,29 +145,29 @@ export function QueueView(): JSX.Element {
|
|||
};
|
||||
|
||||
// ─── Row actions ────────────────────────────────────────────────────────────
|
||||
const onRun = (t: ProspectorTask): void => {
|
||||
const onRun = useCallback((t: ProspectorTask): void => {
|
||||
if (!window.confirm(`Run ${t.id} now? A send-type task may dispatch a message.`)) return;
|
||||
void guard(() => runTask(t.id));
|
||||
};
|
||||
const onEscalate = (t: ProspectorTask): void => {
|
||||
}, [guard]);
|
||||
const onEscalate = useCallback((t: ProspectorTask): void => {
|
||||
if (!window.confirm(`Escalate ${t.id} to Quinn (human owner)?`)) return;
|
||||
void guard(() => escalateTask(t.id));
|
||||
};
|
||||
const onCancel = (t: ProspectorTask): void => {
|
||||
}, [guard]);
|
||||
const onCancel = useCallback((t: ProspectorTask): void => {
|
||||
if (!window.confirm(`Cancel ${t.id}? It is removed from the runner queue.`)) return;
|
||||
void guard(() => cancelTask(t.id));
|
||||
};
|
||||
const onAbort = (t: ProspectorTask): void => {
|
||||
}, [guard]);
|
||||
const onAbort = useCallback((t: ProspectorTask): void => {
|
||||
if (!window.confirm(`Abort ${t.id} mid-run?`)) return;
|
||||
void guard(() => abortTask(t.id));
|
||||
};
|
||||
const onRequeue = (t: ProspectorTask): void => {
|
||||
}, [guard]);
|
||||
const onRequeue = useCallback((t: ProspectorTask): void => {
|
||||
void guard(() => requeueTask(t.id));
|
||||
};
|
||||
const onViewProspect = (handle: string): void => {
|
||||
}, [guard]);
|
||||
const onViewProspect = useCallback((handle: string): void => {
|
||||
setDetailTask(null);
|
||||
navigate('prospect', handle);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="view-stack">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { memo, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
|
||||
import {
|
||||
getProviderGraph,
|
||||
|
|
@ -37,10 +37,14 @@ function ProviderGraphSection(): JSX.Element {
|
|||
const min = minMr ? Number(minMr) : 0;
|
||||
const text = q.trim().toLowerCase();
|
||||
const nodes: GraphNode[] = data?.nodes ?? [];
|
||||
const rows = nodes.filter(
|
||||
(n) =>
|
||||
(!text || n.handle.toLowerCase().includes(text)) &&
|
||||
(!min || (n.compositeMrScore ?? 0) >= min),
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
nodes.filter(
|
||||
(n) =>
|
||||
(!text || n.handle.toLowerCase().includes(text)) &&
|
||||
(!min || (n.compositeMrScore ?? 0) >= min),
|
||||
),
|
||||
[nodes, text, min],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -85,7 +89,7 @@ function WarmIntrosSection(): JSX.Element {
|
|||
const [statusFilter, setStatusFilter] = useState('');
|
||||
|
||||
const items: IntroThread[] = data?.items ?? [];
|
||||
const rows = items.filter((i) => !statusFilter || i.status === statusFilter);
|
||||
const rows = useMemo(() => items.filter((i) => !statusFilter || i.status === statusFilter), [items, statusFilter]);
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
|
|
@ -171,8 +175,11 @@ function MarketplaceSection(): JSX.Element {
|
|||
|
||||
const text = q.trim().toLowerCase();
|
||||
const items: MarketplaceRouting[] = data?.items ?? [];
|
||||
const rows = items.filter(
|
||||
(r) => (!text || r.prospectHandle.toLowerCase().includes(text)) && (!status || r.status === status),
|
||||
const rows = useMemo(
|
||||
() => items.filter(
|
||||
(r) => (!text || r.prospectHandle.toLowerCase().includes(text)) && (!status || r.status === status),
|
||||
),
|
||||
[items, text, status],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -250,13 +257,13 @@ function Td({ children }: { children: ReactNode }): JSX.Element {
|
|||
return <td style={CELL}>{children}</td>;
|
||||
}
|
||||
|
||||
function EmptyRow({ span, text }: { span: number; text: string }): JSX.Element {
|
||||
const EmptyRow = memo(function EmptyRow({ span, text }: { span: number; text: string }): JSX.Element {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={span} style={{ ...CELL, color: 'var(--muted)' }}>{text}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const STATUS_PILL: Record<string, string> = {
|
||||
proposed: 'pill--hold',
|
||||
|
|
@ -270,18 +277,18 @@ const STATUS_PILL: Record<string, string> = {
|
|||
rejected: 'pill--bad',
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }): JSX.Element {
|
||||
const StatusBadge = memo(function StatusBadge({ status }: { status: string }): JSX.Element {
|
||||
return <span className={`pill ${STATUS_PILL[status] ?? 'pill'}`}>{status}</span>;
|
||||
}
|
||||
});
|
||||
|
||||
function Stage({ label, value, accent }: { label: string; value: number; accent?: boolean }): JSX.Element {
|
||||
const Stage = memo(function Stage({ label, value, accent }: { label: string; value: number; accent?: boolean }): JSX.Element {
|
||||
return (
|
||||
<div className="stage">
|
||||
<div className={`stage__value${accent ? ' stage__value--accent' : ''}`}>{value}</div>
|
||||
<div className="stage__label">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function VolumeChart({ points }: { points: readonly VolumePoint[] }): JSX.Element {
|
||||
if (points.length === 0) return <div className="muted">No activity in range.</div>;
|
||||
|
|
@ -302,7 +309,7 @@ function VolumeChart({ points }: { points: readonly VolumePoint[] }): JSX.Elemen
|
|||
);
|
||||
}
|
||||
|
||||
function ClassificationBars({ rows, bilingual }: { rows: readonly CountRow[]; bilingual: boolean }): JSX.Element {
|
||||
const ClassificationBars = memo(function ClassificationBars({ rows, bilingual }: { rows: readonly CountRow[]; bilingual: boolean }): JSX.Element {
|
||||
if (rows.length === 0) return <div className="muted">No classifications in range.</div>;
|
||||
const max = Math.max(1, ...rows.map((r) => r.count));
|
||||
return (
|
||||
|
|
@ -318,4 +325,4 @@ function ClassificationBars({ rows, bilingual }: { rows: readonly CountRow[]; bi
|
|||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
|
||||
import { getStream, type StreamEvent } from '../api';
|
||||
import { navigate } from '../useHashRoute';
|
||||
|
|
@ -57,7 +57,7 @@ function getDirGlyphAndColor(e: StreamEvent): { glyph: string; color: string } {
|
|||
};
|
||||
}
|
||||
|
||||
function EventRow({ event }: { event: StreamEvent }): JSX.Element {
|
||||
const EventRow = memo(function EventRow({ event }: { event: StreamEvent }): JSX.Element {
|
||||
const { glyph, color: dirColor } = getDirGlyphAndColor(event);
|
||||
const badge = getKindBadge(event);
|
||||
const tone = getPillTone(event);
|
||||
|
|
@ -133,7 +133,7 @@ function EventRow({ event }: { event: StreamEvent }): JSX.Element {
|
|||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function StreamView(): JSX.Element {
|
||||
const { data, error, loading } = usePoll(() => getStream(100), 20000);
|
||||
|
|
@ -143,11 +143,11 @@ export function StreamView(): JSX.Element {
|
|||
|
||||
const items: StreamEvent[] = data?.items ?? [];
|
||||
|
||||
const filtered = items.filter((e) => {
|
||||
const filtered = useMemo(() => items.filter((e) => {
|
||||
const kindOk = streamKind === 'all' || e.kind === streamKind;
|
||||
const dirOk = streamDir === 'all' || e.dir === streamDir;
|
||||
return kindOk && dirOk;
|
||||
});
|
||||
}), [items, streamKind, streamDir]);
|
||||
|
||||
const showLoading = loading && items.length === 0;
|
||||
const showError = !!error;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { CSSProperties } from 'react';
|
||||
import { memo, type CSSProperties } from 'react';
|
||||
|
||||
import type { ProspectorTask, TaskPriority, TaskStatus, TaskType } from '../../api';
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ export interface QueueTableProps {
|
|||
* Empty states are real: the table head always renders and an in-table row
|
||||
* states the clear/idle condition rather than hiding the surface.
|
||||
*/
|
||||
export function QueueTable(props: QueueTableProps): JSX.Element {
|
||||
export const QueueTable = memo(function QueueTable(props: QueueTableProps): JSX.Element {
|
||||
const { tab, tasks } = props;
|
||||
const colCount = tab === 'queued' ? 8 : tab === 'processing' ? 6 : 6;
|
||||
|
||||
|
|
@ -145,7 +145,7 @@ export function QueueTable(props: QueueTableProps): JSX.Element {
|
|||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Shared badges + time helpers (imported by TaskDetailModal too — DRY) ──────
|
||||
|
||||
|
|
@ -171,7 +171,7 @@ const STATUS_COLORS: Record<TaskStatus, { bg: string; fg: string }> = {
|
|||
aborted: { bg: 'rgba(248, 113, 113, 0.18)', fg: '#f87171' },
|
||||
};
|
||||
|
||||
export function Badge({ bg, fg, label }: { bg: string; fg: string; label: string }): JSX.Element {
|
||||
export const Badge = memo(function Badge({ bg, fg, label }: { bg: string; fg: string; label: string }): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
|
|
@ -188,22 +188,22 @@ export function Badge({ bg, fg, label }: { bg: string; fg: string; label: string
|
|||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function TypeBadge({ type }: { type: TaskType }): JSX.Element {
|
||||
export const TypeBadge = memo(function TypeBadge({ type }: { type: TaskType }): JSX.Element {
|
||||
const c = TYPE_COLORS[type];
|
||||
return <Badge bg={c.bg} fg={c.fg} label={type} />;
|
||||
}
|
||||
});
|
||||
|
||||
export function PriorityBadge({ priority }: { priority: TaskPriority }): JSX.Element {
|
||||
export const PriorityBadge = memo(function PriorityBadge({ priority }: { priority: TaskPriority }): JSX.Element {
|
||||
const c = PRIORITY_COLORS[priority];
|
||||
return <Badge bg={c.bg} fg={c.fg} label={priority} />;
|
||||
}
|
||||
});
|
||||
|
||||
export function StatusBadge({ status }: { status: TaskStatus }): JSX.Element {
|
||||
export const StatusBadge = memo(function StatusBadge({ status }: { status: TaskStatus }): JSX.Element {
|
||||
const c = STATUS_COLORS[status];
|
||||
return <Badge bg={c.bg} fg={c.fg} label={status} />;
|
||||
}
|
||||
});
|
||||
|
||||
export function shortId(id: string): string {
|
||||
return id.length > 8 ? `${id.slice(0, 8)}` : id;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { memo, useEffect, useRef } from 'react';
|
||||
|
||||
import type { TaskLogEntry } from '../../api';
|
||||
|
||||
const LogRow = memo(function LogRow({ e }: { e: TaskLogEntry }): JSX.Element {
|
||||
return (
|
||||
<div className="logrow">
|
||||
<span className="logrow__time">{clock(e.createdAt)}</span>
|
||||
<span style={{ color: '#6ee7b7', overflow: 'hidden', textOverflow: 'ellipsis' }}>{e.message}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Terminal-style runner activity panel. Renders the auto-runner poll-loop log
|
||||
* (TaskLogEntry[]) newest-at-bottom and auto-scrolls to the tail on each update,
|
||||
|
|
@ -25,10 +34,7 @@ export function RunnerLog({ entries }: { entries: TaskLogEntry[] }): JSX.Element
|
|||
<div className="muted">Runner idle — no recent activity.</div>
|
||||
) : (
|
||||
entries.map((e) => (
|
||||
<div key={e.id} className="logrow">
|
||||
<span className="logrow__time">{clock(e.createdAt)}</span>
|
||||
<span style={{ color: '#6ee7b7', overflow: 'hidden', textOverflow: 'ellipsis' }}>{e.message}</span>
|
||||
</div>
|
||||
<LogRow key={e.id} e={e} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue