Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions internal/api/missions.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"log"
"net/http"
"strconv"
"time"

"github.com/mlund01/squadron-wire/protocol"
Expand Down Expand Up @@ -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()})
Expand Down
8 changes: 6 additions & 2 deletions web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,12 @@ export async function resumeMission(instanceId: string, missionId: string, missi
});
}

export async function getMissionHistory(instanceId: string): Promise<MissionHistoryResponse> {
return fetchJSON<MissionHistoryResponse>(`/instances/${instanceId}/history`);
export async function getMissionHistory(instanceId: string, offset = 0, limit = 50): Promise<MissionHistoryResponse> {
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<MissionHistoryResponse>(`/instances/${instanceId}/history${qs ? '?' + qs : ''}`);
}

export async function sendChatMessage(instanceId: string, agentName: string, message: string, sessionId?: string): Promise<ChatMessageResponse> {
Expand Down
127 changes: 116 additions & 11 deletions web/src/pages/MissionDetail.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;

Expand Down Expand Up @@ -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 <p className="text-sm text-muted-foreground p-4">No runs yet for this mission.</p>;
}

return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-32">Status</TableHead>
<TableHead>Started</TableHead>
<TableHead className="w-32 text-right">Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageRuns.map((r) => (
<TableRow
key={r.id}
className="cursor-pointer"
onClick={() => onSelectRun(r.id)}
>
<TableCell>
<StatusBadge status={r.status} />
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatTime(r.startedAt)}
</TableCell>
<TableCell className="text-xs text-muted-foreground tabular-nums text-right">
{r.finishedAt ? formatDuration(r.startedAt, r.finishedAt) : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="shrink-0 flex items-center justify-between px-4 py-2 border-t text-xs text-muted-foreground">
<span>
Showing {start + 1}–{Math.min(start + RUNS_PAGE_SIZE, runs.length)} of {runs.length}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 px-2"
disabled={currentPage === 0}
onClick={() => onPageChange(currentPage - 1)}
>
<ChevronLeft className="size-3.5" />
</Button>
<span className="tabular-nums">
Page {currentPage + 1} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
className="h-7 px-2"
disabled={currentPage >= totalPages - 1}
onClick={() => onPageChange(currentPage + 1)}
>
<ChevronRight className="size-3.5" />
</Button>
</div>
</div>
</div>
);
}

/* ── Main page component ── */

export function MissionDetail() {
Expand All @@ -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,
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -656,13 +750,6 @@ export function MissionDetail() {
)}
</div>
<div className="flex items-center gap-2">
{runCount > 0 && (
<Button asChild variant="outline" size="sm">
<Link to={`/instances/${id}/missions?view=history&q=${encodeURIComponent(mission.name)}`}>
{runCount} {runCount === 1 ? 'run' : 'runs'}
</Link>
</Button>
)}
<ScheduleTriggerPopover mission={mission} instanceName={instance.name} />
<Button
variant={instance.connected ? 'default' : 'secondary'}
Expand Down Expand Up @@ -743,6 +830,12 @@ export function MissionDetail() {
{agents?.length ?? 0}
</Badge>
</TabsTrigger>
<TabsTrigger value="history">
History
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 ml-0.5">
{runCount}
</Badge>
</TabsTrigger>
</TabsList>
<div className="ml-auto">
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={togglePanel}>
Expand Down Expand Up @@ -810,6 +903,18 @@ export function MissionDetail() {
onSelectAgent={setSelectedAgent}
/>
</TabsContent>
<TabsContent value="history" className="h-full m-0">
<RunsTabContent
runs={missionRuns}
page={runsPage}
onPageChange={setRunsPage}
onSelectRun={(runId) =>
navigate(`/instances/${id}/runs/${runId}`, {
state: { from: { kind: 'mission', name: mission.name } },
})
}
/>
</TabsContent>
</div>
</Tabs>
</div>
Expand Down
18 changes: 16 additions & 2 deletions web/src/pages/MissionInstanceDetail.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -3294,7 +3308,7 @@ export function MissionInstanceDetail() {
<div className="shrink-0 px-8 py-4 border-b">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to={`/instances/${id}/history`} className="text-muted-foreground hover:text-foreground">
<Link to={backTarget(id, location.state, mission.name)} className="text-muted-foreground hover:text-foreground">
<ChevronLeft className="h-4 w-4" />
</Link>
<div>
Expand Down
Loading
Loading