diff --git a/internal/api/missions.go b/internal/api/missions.go index cd4df80..b39b319 100644 --- a/internal/api/missions.go +++ b/internal/api/missions.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "net/http" + "strconv" "time" "github.com/mlund01/squadron-wire/protocol" @@ -475,9 +476,22 @@ func handleMissionHistory(h *hub.Hub) http.HandlerFunc { return } + limit := 50 + offset := 0 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 500 { + limit = n + } + } + if v := r.URL.Query().Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + offset = n + } + } + req, err := protocol.NewRequest(protocol.TypeGetMissions, &protocol.GetMissionsPayload{ - Limit: 50, - Offset: 0, + Limit: limit, + Offset: offset, }) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d640b9b..ae0c494 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -80,8 +80,12 @@ export async function resumeMission(instanceId: string, missionId: string, missi }); } -export async function getMissionHistory(instanceId: string): Promise { - return fetchJSON(`/instances/${instanceId}/history`); +export async function getMissionHistory(instanceId: string, offset = 0, limit = 50): Promise { + const params = new URLSearchParams(); + if (offset) params.set('offset', String(offset)); + if (limit !== 50) params.set('limit', String(limit)); + const qs = params.toString(); + return fetchJSON(`/instances/${instanceId}/history${qs ? '?' + qs : ''}`); } export async function sendChatMessage(instanceId: string, agentName: string, message: string, sessionId?: string): Promise { diff --git a/web/src/pages/MissionDetail.tsx b/web/src/pages/MissionDetail.tsx index b60d92a..0115523 100644 --- a/web/src/pages/MissionDetail.tsx +++ b/web/src/pages/MissionDetail.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; -import { useParams, Link, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { ReactFlow, @@ -23,14 +23,18 @@ import { Badge } from '@/components/ui/badge'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { cn } from '@/lib/utils'; import { useResizablePanel } from '@/hooks/use-resizable-panel'; import { ZoomControls } from '@/components/zoom-controls'; import { NodeChip } from '@/components/node-chip'; import { RunMissionDialog } from '@/components/RunMissionDialog'; -import type { TaskInfo, AgentInfo, DatasetInfo, MissionInfo, ScheduleInfo, TriggerInfo } from '@/api/types'; +import { StatusBadge, formatTime, formatDuration } from '@/lib/mission-utils'; +import type { TaskInfo, AgentInfo, DatasetInfo, MissionInfo, MissionRecordInfo, ScheduleInfo, TriggerInfo } from '@/api/types'; import { RouterEdge } from '@/components/RouterEdge'; +const RUNS_PAGE_SIZE = 10; + const NODE_WIDTH = 260; const NODE_HEIGHT = 100; @@ -549,6 +553,90 @@ function AgentsTabContent({ ); } +function RunsTabContent({ + runs, + page, + onPageChange, + onSelectRun, +}: { + runs: MissionRecordInfo[]; + page: number; + onPageChange: (page: number) => void; + onSelectRun: (runId: string) => void; +}) { + const totalPages = Math.max(1, Math.ceil(runs.length / RUNS_PAGE_SIZE)); + const currentPage = Math.min(page, totalPages - 1); + const start = currentPage * RUNS_PAGE_SIZE; + const pageRuns = runs.slice(start, start + RUNS_PAGE_SIZE); + + if (runs.length === 0) { + return

No runs yet for this mission.

; + } + + return ( +
+
+ + + + Status + Started + Duration + + + + {pageRuns.map((r) => ( + onSelectRun(r.id)} + > + + + + + {formatTime(r.startedAt)} + + + {r.finishedAt ? formatDuration(r.startedAt, r.finishedAt) : '—'} + + + ))} + +
+
+
+ + Showing {start + 1}–{Math.min(start + RUNS_PAGE_SIZE, runs.length)} of {runs.length} + +
+ + + Page {currentPage + 1} of {totalPages} + + +
+
+
+ ); +} + /* ── Main page component ── */ export function MissionDetail() { @@ -562,6 +650,7 @@ export function MissionDetail() { const [activeTab, setActiveTab] = useState('general'); const [runningMission, setRunningMission] = useState(false); const [showRunDialog, setShowRunDialog] = useState(false); + const [runsPage, setRunsPage] = useState(0); const { panelHeight, @@ -589,7 +678,10 @@ export function MissionDetail() { }); const mission = instance?.config.missions?.find((m) => m.name === name); - const runCount = history?.missions?.filter((m) => m.name === name).length ?? 0; + const missionRuns = useMemo(() => { + return (history?.missions ?? []).filter((m) => m.name === name); + }, [history?.missions, name]); + const runCount = missionRuns.length; const missionAgentNames = new Set(mission?.agents ?? []); const agents = instance?.config.agents?.filter((a) => missionAgentNames.has(a.name)); @@ -627,7 +719,9 @@ export function MissionDetail() { setRunningMission(true); try { const result = await runMission(id, name, {}); - navigate(`/instances/${id}/runs/${result.missionId}`); + navigate(`/instances/${id}/runs/${result.missionId}`, { + state: { from: { kind: 'mission', name } }, + }); } catch { setRunningMission(false); } @@ -656,13 +750,6 @@ export function MissionDetail() { )}
- {runCount > 0 && ( - - )}
diff --git a/web/src/pages/MissionInstanceDetail.tsx b/web/src/pages/MissionInstanceDetail.tsx index 317397b..854dc32 100644 --- a/web/src/pages/MissionInstanceDetail.tsx +++ b/web/src/pages/MissionInstanceDetail.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useCallback, useEffect, useRef, Fragment } from 'react'; -import { useParams, Link } from 'react-router-dom'; +import { useParams, Link, useLocation } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ReactFlow, @@ -3088,8 +3088,22 @@ function EventsTab({ instanceId, missionId, isRunning }: { instanceId: string; m /* ── Main page component ── */ +type RunBackFrom = { kind: 'mission'; name: string } | { kind: 'history' }; + +function backTarget(instanceId: string | undefined, state: unknown, missionName: string): string { + const from = (state as { from?: RunBackFrom } | null)?.from; + if (from?.kind === 'mission') { + return `/instances/${instanceId}/missions/${from.name}`; + } + if (from?.kind === 'history') { + return `/instances/${instanceId}/missions?view=history&q=${encodeURIComponent(missionName)}`; + } + return `/instances/${instanceId}/history`; +} + export function MissionInstanceDetail() { const { id, mid } = useParams<{ id: string; mid: string }>(); + const location = useLocation(); const queryClient = useQueryClient(); const { resolvedTheme } = useTheme(); const isDefcon5 = resolvedTheme === 'defcon5'; @@ -3294,7 +3308,7 @@ export function MissionInstanceDetail() {
- +
diff --git a/web/src/pages/MissionsPage.tsx b/web/src/pages/MissionsPage.tsx index db2ec09..ba9c0a8 100644 --- a/web/src/pages/MissionsPage.tsx +++ b/web/src/pages/MissionsPage.tsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { getInstance, getMissionHistory, runMission } from '@/api/client'; import { Button } from '@/components/ui/button'; @@ -30,6 +30,8 @@ type ViewKey = 'missions' | 'history'; type FilterKey = 'all' | 'active' | 'scheduled'; type HistoryFilterKey = 'all' | 'running' | 'completed' | 'failed'; +const HISTORY_PAGE_SIZE = 50; + function buildMissionMiniGraph(mission: MissionInfo): { nodes: MiniNode[]; edges: MiniEdge[] } { const nodes: MiniNode[] = []; const edges: MiniEdge[] = []; @@ -76,8 +78,7 @@ export function MissionsPage() { const [dialogMission, setDialogMission] = useState(null); const [filter, setFilter] = useState('all'); const [historyFilter, setHistoryFilter] = useState('all'); - // Seed the search input from a ?q= query param (e.g. when arriving from a - // mission detail page's "N runs" link). + const loadMoreRef = useRef(null); const [search, setSearch] = useState(() => searchParams.get('q') ?? ''); const { data: instance, isLoading } = useQuery({ @@ -89,15 +90,32 @@ export function MissionsPage() { }); // Poll every 3s so running / just-finished missions light up (and stop - // breathing) quickly enough to feel live. - const { data: history } = useQuery({ - queryKey: ['history', id], - queryFn: () => getMissionHistory(id!), + // breathing) quickly enough to feel live. Infinite paging drives history scroll. + const { + data: historyData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteQuery({ + queryKey: ['history-infinite', id], + queryFn: ({ pageParam }) => getMissionHistory(id!, pageParam as number, HISTORY_PAGE_SIZE), + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + const loaded = allPages.reduce((n, p) => n + p.missions.length, 0); + return loaded < lastPage.total ? loaded : undefined; + }, enabled: !!id && !!instance?.connected, refetchInterval: 3000, refetchIntervalInBackground: false, }); + const history = useMemo(() => { + if (!historyData) return undefined; + const missions = historyData.pages.flatMap((p) => p.missions); + const total = historyData.pages[historyData.pages.length - 1]?.total ?? 0; + return { missions, total }; + }, [historyData]); + const missions = useMemo(() => instance?.config.missions ?? [], [instance]); // Per-mission indices from history: most-recent run + count of currently-running runs @@ -172,6 +190,22 @@ export function MissionsPage() { }); }, [runs, historyFilter, search]); + useEffect(() => { + if (view !== 'history') return; + const el = loadMoreRef.current; + if (!el) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting) && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { rootMargin: '300px' }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [view, hasNextPage, isFetchingNextPage, fetchNextPage]); + const handleRun = async (mission: MissionInfo) => { if (!id) return; if (mission.inputs && mission.inputs.length > 0) { @@ -183,8 +217,11 @@ export function MissionsPage() { const result = await runMission(id, mission.name, {}); // Kick an immediate refetch so the breathing card appears without // waiting for the next poll interval. + queryClient.invalidateQueries({ queryKey: ['history-infinite', id] }); queryClient.invalidateQueries({ queryKey: ['history', id] }); - navigate(`/instances/${id}/runs/${result.missionId}`); + navigate(`/instances/${id}/runs/${result.missionId}`, { + state: { from: { kind: 'history' } }, + }); } catch { setRunningMission(null); } @@ -334,7 +371,7 @@ export function MissionsPage() { navigate(`/instances/${id}/runs/${m.id}`)} + onClick={() => navigate(`/instances/${id}/runs/${m.id}`, { state: { from: { kind: 'history' } } })} > {m.name} @@ -353,8 +390,10 @@ export function MissionsPage() {
)} +

Showing {visibleRuns.length} of {totalRuns} run{totalRuns !== 1 ? 's' : ''} + {isFetchingNextPage && ' · loading more...'}

)