@aion0/forge 0.10.80 → 0.10.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE_NOTES.md +4 -11
- package/components/Dashboard.tsx +55 -26
- package/components/InlinePipelineView.tsx +22 -5
- package/components/PipelineHistory.tsx +306 -0
- package/lib/connectors/test-runner.ts +10 -1
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.82
|
|
2
2
|
|
|
3
|
-
Released: 2026-06-
|
|
3
|
+
Released: 2026-06-15
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.81
|
|
6
6
|
|
|
7
|
-
### Other
|
|
8
|
-
- feat(chat): read_project_file + full task output + anti-fabrication guidance
|
|
9
|
-
- feat(pipeline): per-run tmux/headless backend selection
|
|
10
|
-
- feat(tasks): tmux backend — reliable completion detection + interactive session view
|
|
11
|
-
- feat(tasks): show backend + agent badges in TaskDetail header
|
|
12
|
-
- feat(tasks): tmux backend — interactive claude via Stop hook completion detection
|
|
13
7
|
|
|
14
|
-
|
|
15
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.79...v0.10.80
|
|
8
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.81...v0.10.82
|
package/components/Dashboard.tsx
CHANGED
|
@@ -14,6 +14,7 @@ const CodeViewer = lazy(() => import('./CodeViewer'));
|
|
|
14
14
|
const ProjectManager = lazy(() => import('./ProjectManager'));
|
|
15
15
|
const BrowserPanel = lazy(() => import('./BrowserPanel'));
|
|
16
16
|
const PipelineView = lazy(() => import('./PipelineView'));
|
|
17
|
+
const PipelineHistory = lazy(() => import('./PipelineHistory'));
|
|
17
18
|
const JobsView = lazy(() => import('./JobsView'));
|
|
18
19
|
const SchedulesView = lazy(() => import('./SchedulesView'));
|
|
19
20
|
const HelpDialog = lazy(() => import('./HelpDialog'));
|
|
@@ -103,7 +104,7 @@ function FloatingBrowser({ onClose }: { onClose: () => void }) {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
export default function Dashboard({ user }: { user: any }) {
|
|
106
|
-
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'jobs' | 'schedules' | 'workspace' | 'skills' | 'logs' | 'usage'>('
|
|
107
|
+
const [viewMode, setViewMode] = useState<'tasks' | 'sessions' | 'terminal' | 'docs' | 'projects' | 'pipelines' | 'history' | 'jobs' | 'schedules' | 'workspace' | 'skills' | 'logs' | 'usage'>('history');
|
|
107
108
|
|
|
108
109
|
// Honour `?view=<mode>` from the URL so external links (eg the VSCode
|
|
109
110
|
// extension) can deep-link straight into a section. Only views that have a
|
|
@@ -116,7 +117,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
116
117
|
if (raw) {
|
|
117
118
|
const aliases: Record<string, string> = { workspace: 'projects', sessions: 'projects' };
|
|
118
119
|
const v = aliases[raw] || raw;
|
|
119
|
-
const valid = ['tasks', 'terminal', 'docs', 'projects', 'pipelines', 'jobs', 'schedules', 'skills', 'logs', 'usage'];
|
|
120
|
+
const valid = ['tasks', 'terminal', 'docs', 'projects', 'pipelines', 'history', 'jobs', 'schedules', 'skills', 'logs', 'usage'];
|
|
120
121
|
if (valid.includes(v)) setViewMode(v as any);
|
|
121
122
|
}
|
|
122
123
|
// Optional deep-link to a specific pipeline run — used by the extension
|
|
@@ -137,6 +138,24 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
137
138
|
const [browserDragging, setBrowserDragging] = useState(false);
|
|
138
139
|
const [tasks, setTasks] = useState<Task[]>([]);
|
|
139
140
|
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
|
141
|
+
|
|
142
|
+
// Write the current section back into the URL so clicking a menu updates the
|
|
143
|
+
// address bar and links are shareable / bookmarkable. replaceState → the
|
|
144
|
+
// Dashboard never remounts (terminals stay alive) and history isn't spammed.
|
|
145
|
+
// Skip the very first run so the incoming deep-link (read on mount above) is
|
|
146
|
+
// not clobbered before it's consumed.
|
|
147
|
+
const didSyncUrl = useRef(false);
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (!didSyncUrl.current) { didSyncUrl.current = true; return; }
|
|
150
|
+
const params = new URLSearchParams(window.location.search);
|
|
151
|
+
params.set('view', viewMode);
|
|
152
|
+
if (viewMode === 'tasks' && activeTaskId) params.set('taskId', activeTaskId);
|
|
153
|
+
else params.delete('taskId');
|
|
154
|
+
// pipeline deep-link id only makes sense inside the pipelines view.
|
|
155
|
+
if (viewMode !== 'pipelines') { params.delete('pipeline'); params.delete('pipelineId'); }
|
|
156
|
+
window.history.replaceState(null, '', `${window.location.pathname}?${params.toString()}`);
|
|
157
|
+
}, [viewMode, activeTaskId]);
|
|
158
|
+
|
|
140
159
|
const [showNewTask, setShowNewTask] = useState(false);
|
|
141
160
|
const [showSettings, setShowSettings] = useState(false);
|
|
142
161
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
|
@@ -488,6 +507,27 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
488
507
|
|
|
489
508
|
{/* View mode toggle */}
|
|
490
509
|
<div className="flex items-center bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
510
|
+
{/* Automation — first. Sub-tabs: pipelines / tasks / schedules.
|
|
511
|
+
Jobs is deprecated and hidden from the nav (backend still
|
|
512
|
+
present in case of reversion, but no UI entry point). */}
|
|
513
|
+
<button
|
|
514
|
+
onClick={() => {
|
|
515
|
+
if (!['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode)) setViewMode('history');
|
|
516
|
+
}}
|
|
517
|
+
className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
|
|
518
|
+
['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode)
|
|
519
|
+
? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
|
|
520
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
521
|
+
}`}
|
|
522
|
+
>
|
|
523
|
+
Automation
|
|
524
|
+
</button>
|
|
525
|
+
{/* Activity sub-pill — sits next to Automation since its content
|
|
526
|
+
(running pipelines + upcoming schedules + recent runs) is the
|
|
527
|
+
live read-side of Automation. Click anywhere → dropdown with
|
|
528
|
+
3 sections + a "view" jump to the run. */}
|
|
529
|
+
<Suspense fallback={null}><ActivityPanel /></Suspense>
|
|
530
|
+
<span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
|
|
491
531
|
{/* Workspace */}
|
|
492
532
|
{(['terminal', 'projects'] as const).map(mode => (
|
|
493
533
|
<button
|
|
@@ -515,27 +555,6 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
515
555
|
Docs
|
|
516
556
|
</button>
|
|
517
557
|
<span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
|
|
518
|
-
{/* Automation — sub-tabs: tasks / pipelines / schedules.
|
|
519
|
-
Jobs is deprecated and hidden from the nav (backend still
|
|
520
|
-
present in case of reversion, but no UI entry point). */}
|
|
521
|
-
<button
|
|
522
|
-
onClick={() => {
|
|
523
|
-
if (!['tasks', 'pipelines', 'schedules'].includes(viewMode)) setViewMode('schedules');
|
|
524
|
-
}}
|
|
525
|
-
className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
|
|
526
|
-
['tasks', 'pipelines', 'schedules'].includes(viewMode)
|
|
527
|
-
? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
|
|
528
|
-
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
529
|
-
}`}
|
|
530
|
-
>
|
|
531
|
-
Automation
|
|
532
|
-
</button>
|
|
533
|
-
{/* Activity sub-pill — sits next to Automation since its content
|
|
534
|
-
(running pipelines + upcoming schedules + recent runs) is the
|
|
535
|
-
live read-side of Automation. Click anywhere → dropdown with
|
|
536
|
-
3 sections + a "view" jump to the run. */}
|
|
537
|
-
<Suspense fallback={null}><ActivityPanel /></Suspense>
|
|
538
|
-
<span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
|
|
539
558
|
{/* Marketplace */}
|
|
540
559
|
<button
|
|
541
560
|
onClick={() => setViewMode('skills')}
|
|
@@ -911,10 +930,10 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
911
930
|
|
|
912
931
|
{/* Automation secondary toolbar — sub-tabs + context actions.
|
|
913
932
|
Lives below the main header so it never squishes the top nav. */}
|
|
914
|
-
{['tasks', 'pipelines', 'schedules'].includes(viewMode) && (
|
|
933
|
+
{['tasks', 'pipelines', 'schedules', 'history'].includes(viewMode) && (
|
|
915
934
|
<div className="h-8 border-b border-[var(--border)] flex items-center justify-between px-4 shrink-0 bg-[var(--bg-tertiary)]/40">
|
|
916
935
|
<div className="flex items-center gap-1">
|
|
917
|
-
{(['
|
|
936
|
+
{(['history', 'pipelines', 'tasks', 'schedules'] as const).map((m) => (
|
|
918
937
|
<button
|
|
919
938
|
key={m}
|
|
920
939
|
onClick={() => setViewMode(m)}
|
|
@@ -924,7 +943,7 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
924
943
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
925
944
|
}`}
|
|
926
945
|
>
|
|
927
|
-
{{ tasks: 'Tasks', pipelines: 'Pipelines', schedules: 'Schedules' }[m]}
|
|
946
|
+
{{ tasks: 'Tasks', pipelines: 'Pipelines', schedules: 'Schedules', history: 'History' }[m]}
|
|
928
947
|
</button>
|
|
929
948
|
))}
|
|
930
949
|
{viewMode === 'tasks' && (
|
|
@@ -1058,6 +1077,16 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
1058
1077
|
</Suspense>
|
|
1059
1078
|
)}
|
|
1060
1079
|
|
|
1080
|
+
{/* History — flat list of all pipeline runs, expandable */}
|
|
1081
|
+
{viewMode === 'history' && (
|
|
1082
|
+
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
1083
|
+
<PipelineHistory
|
|
1084
|
+
onViewTask={(taskId) => { setViewMode('tasks'); setActiveTaskId(taskId); }}
|
|
1085
|
+
onViewPipeline={(pipelineId) => { setPendingPipelineId(pipelineId); setViewMode('pipelines'); }}
|
|
1086
|
+
/>
|
|
1087
|
+
</Suspense>
|
|
1088
|
+
)}
|
|
1089
|
+
|
|
1061
1090
|
{/* Jobs — scheduled connector polls (Forge web read-mostly; create via extension) */}
|
|
1062
1091
|
{viewMode === 'jobs' && (
|
|
1063
1092
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
@@ -52,10 +52,12 @@ function InlineLiveLog({ log }: { log: TaskLogEntry[] }) {
|
|
|
52
52
|
|
|
53
53
|
// ─── DAG node card ────────────────────────────────────────
|
|
54
54
|
|
|
55
|
-
function InlineDagNode({ nodeId, node }: { nodeId: string; node: any }) {
|
|
55
|
+
function InlineDagNode({ nodeId, node, onViewTask }: { nodeId: string; node: any; onViewTask?: (taskId: string) => void }) {
|
|
56
56
|
const isRunning = node.status === 'running';
|
|
57
57
|
const log = useTaskStreamInline(node.taskId, isRunning);
|
|
58
58
|
const statusIcon = node.status === 'done' ? '✅' : node.status === 'failed' ? '❌' : node.status === 'running' ? '🔄' : node.status === 'skipped' ? '⏭' : '⏳';
|
|
59
|
+
const outputs: Record<string, string> = node.outputs || {};
|
|
60
|
+
const outputKeys = Object.keys(outputs).filter(k => (outputs[k] ?? '').length > 0);
|
|
59
61
|
return (
|
|
60
62
|
<div className={`border rounded p-2 ${
|
|
61
63
|
isRunning ? 'border-yellow-500/40 bg-yellow-500/5' :
|
|
@@ -66,18 +68,33 @@ function InlineDagNode({ nodeId, node }: { nodeId: string; node: any }) {
|
|
|
66
68
|
<div className="flex items-center gap-1.5 text-[9px]">
|
|
67
69
|
<span>{statusIcon}</span>
|
|
68
70
|
<span className="font-semibold text-[var(--text-primary)]">{nodeId}</span>
|
|
69
|
-
{node.taskId &&
|
|
71
|
+
{node.taskId && (
|
|
72
|
+
onViewTask
|
|
73
|
+
? <button onClick={() => onViewTask(node.taskId)} className="text-[7px] text-[var(--accent)] font-mono hover:underline">task:{node.taskId} ↗</button>
|
|
74
|
+
: <span className="text-[7px] text-[var(--accent)] font-mono">task:{node.taskId}</span>
|
|
75
|
+
)}
|
|
70
76
|
<span className="text-[var(--text-secondary)] ml-auto">{node.status}</span>
|
|
71
77
|
</div>
|
|
72
78
|
{isRunning && <div className="mt-1.5"><InlineLiveLog log={log} /></div>}
|
|
73
|
-
{node.error && <div className="text-[8px] text-red-400 mt-1">{node.error}</div>}
|
|
79
|
+
{node.error && <div className="text-[8px] text-red-400 mt-1 whitespace-pre-wrap break-words">{node.error}</div>}
|
|
80
|
+
{outputKeys.map((key) => {
|
|
81
|
+
const isDiff = key === 'diff' || /diff/i.test(key);
|
|
82
|
+
return (
|
|
83
|
+
<details key={key} className="mt-1 text-[8px]">
|
|
84
|
+
<summary className="cursor-pointer text-[var(--accent)]">
|
|
85
|
+
{isDiff ? 'diff' : key === 'result' || key === 'report' || key === 'summary' ? `result: ${key}` : `output: ${key}`} ({outputs[key].length} chars)
|
|
86
|
+
</summary>
|
|
87
|
+
<pre className="mt-1 max-h-[200px] overflow-auto bg-[var(--bg-primary)] rounded p-1.5 whitespace-pre-wrap break-words text-[var(--text-secondary)]">{outputs[key].slice(0, 8000)}{outputs[key].length > 8000 ? '\n…(truncated)' : ''}</pre>
|
|
88
|
+
</details>
|
|
89
|
+
);
|
|
90
|
+
})}
|
|
74
91
|
</div>
|
|
75
92
|
);
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
// ─── Main component ───────────────────────────────────────
|
|
79
96
|
|
|
80
|
-
export default function InlinePipelineView({ pipeline, onRefresh }: { pipeline: any; onRefresh: () => void }) {
|
|
97
|
+
export default function InlinePipelineView({ pipeline, onRefresh, onViewTask }: { pipeline: any; onRefresh: () => void; onViewTask?: (taskId: string) => void }) {
|
|
81
98
|
useEffect(() => {
|
|
82
99
|
if (pipeline.status !== 'running') return;
|
|
83
100
|
const timer = setInterval(onRefresh, 3000);
|
|
@@ -97,7 +114,7 @@ export default function InlinePipelineView({ pipeline, onRefresh }: { pipeline:
|
|
|
97
114
|
) : (
|
|
98
115
|
<div className="px-3 py-2 space-y-1.5">
|
|
99
116
|
{pipeline.nodeOrder.map((nodeId: string) => (
|
|
100
|
-
<InlineDagNode key={nodeId} nodeId={nodeId} node={pipeline.nodes[nodeId]} />
|
|
117
|
+
<InlineDagNode key={nodeId} nodeId={nodeId} node={pipeline.nodes[nodeId]} onViewTask={onViewTask} />
|
|
101
118
|
))}
|
|
102
119
|
</div>
|
|
103
120
|
)}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
|
|
4
|
+
|
|
5
|
+
const InlinePipelineView = lazy(() => import('./InlinePipelineView'));
|
|
6
|
+
|
|
7
|
+
// Light summary shape returned by GET /api/pipelines (heavy node outputs stripped).
|
|
8
|
+
interface PipelineSummary {
|
|
9
|
+
id: string;
|
|
10
|
+
workflowName: string;
|
|
11
|
+
status: 'running' | 'done' | 'failed' | 'cancelled';
|
|
12
|
+
type?: string;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
completedAt?: string;
|
|
15
|
+
nodeOrder: string[];
|
|
16
|
+
nodes: Record<string, { status: string; iterations?: number }>;
|
|
17
|
+
input?: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const STATUS_META: Record<string, { icon: string; cls: string }> = {
|
|
21
|
+
running: { icon: '🔄', cls: 'text-yellow-400' },
|
|
22
|
+
done: { icon: '✅', cls: 'text-green-400' },
|
|
23
|
+
failed: { icon: '❌', cls: 'text-red-400' },
|
|
24
|
+
cancelled: { icon: '⏹', cls: 'text-[var(--text-secondary)]' },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function relTime(iso?: string): string {
|
|
28
|
+
if (!iso) return '';
|
|
29
|
+
const t = new Date(iso).getTime();
|
|
30
|
+
if (Number.isNaN(t)) return '';
|
|
31
|
+
const s = Math.floor((Date.now() - t) / 1000);
|
|
32
|
+
if (s < 60) return `${s}s ago`;
|
|
33
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
34
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
35
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A compact one-liner of the most useful input fields. */
|
|
39
|
+
function inputSummary(input?: Record<string, string>): string {
|
|
40
|
+
if (!input) return '';
|
|
41
|
+
const keys = ['bug_id', 'mr_url', 'mr_iid', 'project', 'issue_id', 'base_branch'];
|
|
42
|
+
const parts: string[] = [];
|
|
43
|
+
for (const k of keys) {
|
|
44
|
+
if (input[k]) parts.push(`${k}=${String(input[k]).slice(0, 40)}`);
|
|
45
|
+
}
|
|
46
|
+
return parts.join(' · ');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function PipelineHistory({ onViewTask, onViewPipeline }: {
|
|
50
|
+
onViewTask: (taskId: string) => void;
|
|
51
|
+
onViewPipeline: (pipelineId: string) => void;
|
|
52
|
+
}) {
|
|
53
|
+
const PAGE = 20;
|
|
54
|
+
const [list, setList] = useState<PipelineSummary[]>([]);
|
|
55
|
+
const [loading, setLoading] = useState(true);
|
|
56
|
+
const [hasMore, setHasMore] = useState(false);
|
|
57
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
58
|
+
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
59
|
+
const [fullById, setFullById] = useState<Record<string, any>>({});
|
|
60
|
+
const [statusFilter, setStatusFilter] = useState<'all' | 'running' | 'done' | 'failed'>('all');
|
|
61
|
+
const [search, setSearch] = useState('');
|
|
62
|
+
const [showCleanup, setShowCleanup] = useState(false);
|
|
63
|
+
const expandedRef = useRef(expandedId);
|
|
64
|
+
expandedRef.current = expandedId;
|
|
65
|
+
|
|
66
|
+
// Merge a fetched page into the list by id (fresh copy wins), newest-first.
|
|
67
|
+
// Lets the 4s poll refresh running runs without dropping older loaded pages.
|
|
68
|
+
const mergeIn = useCallback((incoming: PipelineSummary[]) => {
|
|
69
|
+
setList(prev => {
|
|
70
|
+
const byId = new Map(prev.map(p => [p.id, p]));
|
|
71
|
+
for (const p of incoming) byId.set(p.id, p);
|
|
72
|
+
return [...byId.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
73
|
+
});
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
// First page (newest 20). Also the 4s poll target — keeps running runs fresh.
|
|
77
|
+
const fetchFirst = useCallback(async () => {
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetch(`/api/pipelines?limit=${PAGE}`);
|
|
80
|
+
if (res.ok) {
|
|
81
|
+
const page: PipelineSummary[] = await res.json();
|
|
82
|
+
mergeIn(page);
|
|
83
|
+
setHasMore(page.length === PAGE);
|
|
84
|
+
}
|
|
85
|
+
} catch { /* keep prior list */ }
|
|
86
|
+
finally { setLoading(false); }
|
|
87
|
+
}, [mergeIn]);
|
|
88
|
+
|
|
89
|
+
// Cursor pagination: fetch the next 20 older than the oldest loaded run.
|
|
90
|
+
const loadMore = useCallback(async () => {
|
|
91
|
+
setLoadingMore(true);
|
|
92
|
+
try {
|
|
93
|
+
const oldest = list.length ? list[list.length - 1].createdAt : undefined;
|
|
94
|
+
const res = await fetch(`/api/pipelines?limit=${PAGE}${oldest ? `&before=${encodeURIComponent(oldest)}` : ''}`);
|
|
95
|
+
if (res.ok) {
|
|
96
|
+
const page: PipelineSummary[] = await res.json();
|
|
97
|
+
mergeIn(page);
|
|
98
|
+
setHasMore(page.length === PAGE);
|
|
99
|
+
}
|
|
100
|
+
} catch { /* ignore */ }
|
|
101
|
+
finally { setLoadingMore(false); }
|
|
102
|
+
}, [list, mergeIn]);
|
|
103
|
+
|
|
104
|
+
const fetchFull = useCallback(async (id: string) => {
|
|
105
|
+
try {
|
|
106
|
+
const res = await fetch(`/api/pipelines/${id}`);
|
|
107
|
+
if (res.ok) {
|
|
108
|
+
const full = await res.json();
|
|
109
|
+
setFullById(prev => ({ ...prev, [id]: full }));
|
|
110
|
+
}
|
|
111
|
+
} catch { /* ignore */ }
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
// Initial load + poll every 4s so running pipelines update live.
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
fetchFirst();
|
|
117
|
+
const t = setInterval(() => {
|
|
118
|
+
fetchFirst();
|
|
119
|
+
const id = expandedRef.current;
|
|
120
|
+
if (id) fetchFull(id); // keep the open run fresh while it runs
|
|
121
|
+
}, 4000);
|
|
122
|
+
return () => clearInterval(t);
|
|
123
|
+
}, [fetchFirst, fetchFull]);
|
|
124
|
+
|
|
125
|
+
const toggle = (id: string) => {
|
|
126
|
+
if (expandedId === id) { setExpandedId(null); return; }
|
|
127
|
+
setExpandedId(id);
|
|
128
|
+
if (!fullById[id]) fetchFull(id);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const op = async (id: string, action: 'cancel' | 'delete') => {
|
|
132
|
+
if (action === 'delete' && !confirm('Delete this pipeline run? This cannot be undone.')) return;
|
|
133
|
+
try {
|
|
134
|
+
await fetch(`/api/pipelines/${id}`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'Content-Type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({ action }),
|
|
138
|
+
});
|
|
139
|
+
if (action === 'delete') { setExpandedId(null); setList(prev => prev.filter(p => p.id !== id)); }
|
|
140
|
+
fetchFirst();
|
|
141
|
+
if (action === 'cancel') fetchFull(id);
|
|
142
|
+
} catch { /* ignore */ }
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Unified cleanup — bulk-delete terminal (done/failed/cancelled) runs.
|
|
146
|
+
// Running/pending runs are always skipped server-side.
|
|
147
|
+
const cleanup = async (olderThanDays: number, label: string) => {
|
|
148
|
+
if (!confirm(`Delete ${label}? Running pipelines are kept. This cannot be undone.`)) return;
|
|
149
|
+
setShowCleanup(false);
|
|
150
|
+
try {
|
|
151
|
+
const res = await fetch('/api/pipelines/bulk-delete', {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
headers: { 'Content-Type': 'application/json' },
|
|
154
|
+
body: JSON.stringify({ older_than_days: olderThanDays }),
|
|
155
|
+
});
|
|
156
|
+
const data = res.ok ? await res.json() : null;
|
|
157
|
+
// Many rows gone — rebuild the list from scratch.
|
|
158
|
+
setExpandedId(null);
|
|
159
|
+
setList([]);
|
|
160
|
+
setLoading(true);
|
|
161
|
+
await fetchFirst();
|
|
162
|
+
if (data) alert(`Cleaned up ${data.removed} pipeline run${data.removed === 1 ? '' : 's'}.`);
|
|
163
|
+
} catch { /* ignore */ }
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const filtered = list.filter(p => {
|
|
167
|
+
if (statusFilter !== 'all' && p.status !== statusFilter) return false;
|
|
168
|
+
if (search) {
|
|
169
|
+
const hay = `${p.workflowName} ${inputSummary(p.input)} ${p.id}`.toLowerCase();
|
|
170
|
+
if (!hay.includes(search.toLowerCase())) return false;
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const runningCount = list.filter(p => p.status === 'running').length;
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
179
|
+
{/* Filter bar */}
|
|
180
|
+
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--border)] shrink-0">
|
|
181
|
+
<div className="flex items-center gap-1">
|
|
182
|
+
{(['all', 'running', 'done', 'failed'] as const).map(s => (
|
|
183
|
+
<button
|
|
184
|
+
key={s}
|
|
185
|
+
onClick={() => setStatusFilter(s)}
|
|
186
|
+
className={`text-[11px] px-2 py-0.5 rounded ${
|
|
187
|
+
statusFilter === s
|
|
188
|
+
? 'bg-[var(--accent)]/15 text-[var(--accent)]'
|
|
189
|
+
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
190
|
+
}`}
|
|
191
|
+
>
|
|
192
|
+
{s === 'all' ? 'All' : s[0].toUpperCase() + s.slice(1)}
|
|
193
|
+
{s === 'running' && runningCount > 0 ? ` (${runningCount})` : ''}
|
|
194
|
+
</button>
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
<input
|
|
198
|
+
value={search}
|
|
199
|
+
onChange={e => setSearch(e.target.value)}
|
|
200
|
+
placeholder="Filter by workflow / bug_id / id…"
|
|
201
|
+
className="text-[11px] px-2 py-0.5 rounded bg-[var(--bg-tertiary)] border border-[var(--border)] text-[var(--text-primary)] flex-1 max-w-[320px] outline-none focus:border-[var(--accent)]"
|
|
202
|
+
/>
|
|
203
|
+
<span className="text-[10px] text-[var(--text-secondary)] ml-auto">{filtered.length} run{filtered.length === 1 ? '' : 's'}</span>
|
|
204
|
+
{/* Unified cleanup */}
|
|
205
|
+
<div className="relative">
|
|
206
|
+
<button
|
|
207
|
+
onClick={() => setShowCleanup(v => !v)}
|
|
208
|
+
className="text-[11px] px-2 py-0.5 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border)]"
|
|
209
|
+
title="Bulk-delete finished pipeline runs"
|
|
210
|
+
>
|
|
211
|
+
Cleanup ▾
|
|
212
|
+
</button>
|
|
213
|
+
{showCleanup && (
|
|
214
|
+
<>
|
|
215
|
+
<div className="fixed inset-0 z-10" onClick={() => setShowCleanup(false)} />
|
|
216
|
+
<div className="absolute right-0 mt-1 z-20 bg-[var(--bg-secondary)] border border-[var(--border)] rounded shadow-lg py-1 w-56 text-[11px]">
|
|
217
|
+
<div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] uppercase tracking-wide">Delete finished runs</div>
|
|
218
|
+
<button onClick={() => cleanup(30, 'finished runs older than 30 days')} className="w-full text-left px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]">Older than 30 days</button>
|
|
219
|
+
<button onClick={() => cleanup(7, 'finished runs older than 7 days')} className="w-full text-left px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]">Older than 7 days</button>
|
|
220
|
+
<button onClick={() => cleanup(0, 'ALL finished runs')} className="w-full text-left px-3 py-1.5 hover:bg-[var(--bg-tertiary)] text-red-400">All finished (done/failed/cancelled)</button>
|
|
221
|
+
<div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] border-t border-[var(--border)] mt-1">Running pipelines are always kept.</div>
|
|
222
|
+
</div>
|
|
223
|
+
</>
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
<button onClick={fetchFirst} className="text-[11px] px-2 py-0.5 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)]" title="Refresh">↻</button>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{/* List */}
|
|
230
|
+
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
231
|
+
{loading && list.length === 0 ? (
|
|
232
|
+
<div className="flex items-center justify-center h-32 text-[var(--text-secondary)] text-[12px]">Loading…</div>
|
|
233
|
+
) : filtered.length === 0 ? (
|
|
234
|
+
<div className="flex items-center justify-center h-32 text-[var(--text-secondary)] text-[12px]">No pipeline runs.</div>
|
|
235
|
+
) : (
|
|
236
|
+
<div className="divide-y divide-[var(--border)]">
|
|
237
|
+
{filtered.map(p => {
|
|
238
|
+
const meta = STATUS_META[p.status] || STATUS_META.cancelled;
|
|
239
|
+
const total = p.nodeOrder.length;
|
|
240
|
+
const done = Object.values(p.nodes).filter(n => n.status === 'done').length;
|
|
241
|
+
const isOpen = expandedId === p.id;
|
|
242
|
+
const full = fullById[p.id];
|
|
243
|
+
return (
|
|
244
|
+
<div key={p.id}>
|
|
245
|
+
{/* Row header */}
|
|
246
|
+
<div
|
|
247
|
+
onClick={() => toggle(p.id)}
|
|
248
|
+
className="flex items-center gap-2 px-4 py-2 cursor-pointer hover:bg-[var(--bg-tertiary)]/40"
|
|
249
|
+
>
|
|
250
|
+
<span className={`text-[11px] w-4 text-center transition-transform ${isOpen ? 'rotate-90' : ''}`}>▸</span>
|
|
251
|
+
<span className="text-[12px]">{meta.icon}</span>
|
|
252
|
+
<span className="text-[12px] font-medium text-[var(--text-primary)] truncate max-w-[220px]">{p.workflowName}</span>
|
|
253
|
+
<span className={`text-[10px] ${meta.cls}`}>{p.status}</span>
|
|
254
|
+
<span className="text-[10px] text-[var(--text-secondary)]">{done}/{total} steps</span>
|
|
255
|
+
{inputSummary(p.input) && (
|
|
256
|
+
<span className="text-[10px] text-[var(--text-secondary)] truncate max-w-[280px] font-mono">{inputSummary(p.input)}</span>
|
|
257
|
+
)}
|
|
258
|
+
<span className="text-[10px] text-[var(--text-secondary)] ml-auto whitespace-nowrap">{relTime(p.createdAt)}</span>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
{/* Expanded detail */}
|
|
262
|
+
{isOpen && (
|
|
263
|
+
<div className="bg-[var(--bg-tertiary)]/30 border-t border-[var(--border)]">
|
|
264
|
+
{/* Action bar */}
|
|
265
|
+
<div className="flex items-center gap-2 px-4 py-1.5 text-[11px]">
|
|
266
|
+
<button
|
|
267
|
+
onClick={() => onViewPipeline(p.id)}
|
|
268
|
+
className="px-2 py-0.5 rounded bg-[var(--accent)]/15 text-[var(--accent)] hover:bg-[var(--accent)]/25"
|
|
269
|
+
>
|
|
270
|
+
Open in Pipelines ↗
|
|
271
|
+
</button>
|
|
272
|
+
{p.status === 'running' && (
|
|
273
|
+
<button onClick={() => op(p.id, 'cancel')} className="px-2 py-0.5 rounded text-yellow-400 hover:bg-yellow-400/10">Cancel</button>
|
|
274
|
+
)}
|
|
275
|
+
<button onClick={() => op(p.id, 'delete')} className="px-2 py-0.5 rounded text-red-400 hover:bg-red-400/10">Delete</button>
|
|
276
|
+
<span className="text-[10px] text-[var(--text-secondary)] ml-auto font-mono">id:{p.id}</span>
|
|
277
|
+
</div>
|
|
278
|
+
{full ? (
|
|
279
|
+
<Suspense fallback={<div className="px-4 py-2 text-[10px] text-[var(--text-secondary)]">Loading detail…</div>}>
|
|
280
|
+
<InlinePipelineView pipeline={full} onRefresh={() => fetchFull(p.id)} onViewTask={onViewTask} />
|
|
281
|
+
</Suspense>
|
|
282
|
+
) : (
|
|
283
|
+
<div className="px-4 py-3 text-[10px] text-[var(--text-secondary)]">Loading detail…</div>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
})}
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
{hasMore && !(loading && list.length === 0) && (
|
|
293
|
+
<div className="flex justify-center py-3">
|
|
294
|
+
<button
|
|
295
|
+
onClick={loadMore}
|
|
296
|
+
disabled={loadingMore}
|
|
297
|
+
className="text-[11px] px-3 py-1 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
|
298
|
+
>
|
|
299
|
+
{loadingMore ? 'Loading…' : 'Load more'}
|
|
300
|
+
</button>
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
@@ -130,7 +130,16 @@ async function runHttpProbe(
|
|
|
130
130
|
const t0 = Date.now();
|
|
131
131
|
let res: Response;
|
|
132
132
|
try {
|
|
133
|
-
|
|
133
|
+
// Honour connector-level http.verify_tls — self-signed appliances (Jenkins,
|
|
134
|
+
// NAC, ESXi …) need undici with rejectUnauthorized:false, same as http.ts.
|
|
135
|
+
const fetchInit = { method, headers, body, signal: ctrl.signal };
|
|
136
|
+
if (def.http?.verify_tls === false) {
|
|
137
|
+
const { fetch: undiciFetch, Agent } = await import('undici');
|
|
138
|
+
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
|
139
|
+
res = await undiciFetch(url, { ...fetchInit, dispatcher } as any) as unknown as Response;
|
|
140
|
+
} else {
|
|
141
|
+
res = await fetch(url, fetchInit);
|
|
142
|
+
}
|
|
134
143
|
} catch (e) {
|
|
135
144
|
clearTimeout(timer);
|
|
136
145
|
const err = e as Error & { cause?: unknown };
|
package/package.json
CHANGED