feat: dedicated Workflow Detail page at /workflows/:workflowId
Browse files- New WorkflowDetail page with pipeline, timeline, proposals, metrics
- Workflows list now navigates to /workflows/{id} (own page)
- Document Workspace remains at /documents/{docId}/{wfId} (doc-focused)
- Back button links to /workflows
- Both pages share same components (Pipeline, Timeline, ProposalReviewList)
- Separate concerns: Documents page = doc management, Workflows page = processing
frontend/src/App.jsx
CHANGED
|
@@ -15,6 +15,7 @@ import Knowledge from "./pages/Knowledge";
|
|
| 15 |
import KnowledgeDetail from "./pages/KnowledgeDetail";
|
| 16 |
import Search from "./pages/Search";
|
| 17 |
import Workflows from "./pages/Workflows";
|
|
|
|
| 18 |
import Activity from "./pages/Activity";
|
| 19 |
import Settings from "./pages/Settings";
|
| 20 |
|
|
@@ -37,6 +38,7 @@ export default function App() {
|
|
| 37 |
<Route path="/knowledge/:itemId" element={<KnowledgeDetail />} />
|
| 38 |
<Route path="/search" element={<Search />} />
|
| 39 |
<Route path="/workflows" element={<Workflows />} />
|
|
|
|
| 40 |
<Route path="/activity" element={<Activity />} />
|
| 41 |
<Route path="/settings" element={<Settings />} />
|
| 42 |
</Route>
|
|
|
|
| 15 |
import KnowledgeDetail from "./pages/KnowledgeDetail";
|
| 16 |
import Search from "./pages/Search";
|
| 17 |
import Workflows from "./pages/Workflows";
|
| 18 |
+
import WorkflowDetail from "./pages/WorkflowDetail";
|
| 19 |
import Activity from "./pages/Activity";
|
| 20 |
import Settings from "./pages/Settings";
|
| 21 |
|
|
|
|
| 38 |
<Route path="/knowledge/:itemId" element={<KnowledgeDetail />} />
|
| 39 |
<Route path="/search" element={<Search />} />
|
| 40 |
<Route path="/workflows" element={<Workflows />} />
|
| 41 |
+
<Route path="/workflows/:workflowId" element={<WorkflowDetail />} />
|
| 42 |
<Route path="/activity" element={<Activity />} />
|
| 43 |
<Route path="/settings" element={<Settings />} />
|
| 44 |
</Route>
|
frontend/src/pages/WorkflowDetail.css
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.dw-wfdetail__back {
|
| 2 |
+
display: inline-flex;
|
| 3 |
+
align-items: center;
|
| 4 |
+
gap: var(--space-2);
|
| 5 |
+
background: none;
|
| 6 |
+
border: none;
|
| 7 |
+
color: var(--color-accent-text);
|
| 8 |
+
font-size: var(--text-sm);
|
| 9 |
+
cursor: pointer;
|
| 10 |
+
padding: 0;
|
| 11 |
+
margin-bottom: var(--space-3);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
.dw-wfdetail__back:hover {
|
| 15 |
+
text-decoration: underline;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
.dw-wfdetail__status-message {
|
| 19 |
+
font-size: var(--text-sm);
|
| 20 |
+
color: var(--color-text-secondary);
|
| 21 |
+
margin: 0 0 var(--space-3);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
.dw-wfdetail__meta {
|
| 25 |
+
display: grid;
|
| 26 |
+
grid-template-columns: auto 1fr;
|
| 27 |
+
gap: var(--space-2) var(--space-4);
|
| 28 |
+
font-size: var(--text-sm);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.dw-wfdetail__meta dt {
|
| 32 |
+
color: var(--color-text-muted);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.dw-wfdetail__why {
|
| 36 |
+
display: flex;
|
| 37 |
+
flex-direction: column;
|
| 38 |
+
gap: var(--space-3);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.dw-wfdetail__why-item {
|
| 42 |
+
display: flex;
|
| 43 |
+
align-items: flex-start;
|
| 44 |
+
gap: var(--space-3);
|
| 45 |
+
padding: var(--space-3);
|
| 46 |
+
background: var(--color-surface-hover);
|
| 47 |
+
border-radius: var(--radius-md);
|
| 48 |
+
border-left: 3px solid var(--color-warning);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.dw-wfdetail__why-badge {
|
| 52 |
+
flex-shrink: 0;
|
| 53 |
+
font-size: 10px;
|
| 54 |
+
font-weight: 700;
|
| 55 |
+
padding: 2px 6px;
|
| 56 |
+
border-radius: var(--radius-sm);
|
| 57 |
+
text-transform: uppercase;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.dw-wfdetail__why-badge--fail {
|
| 61 |
+
background: var(--color-danger-soft);
|
| 62 |
+
color: var(--color-danger);
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.dw-wfdetail__why-badge--warning {
|
| 66 |
+
background: var(--color-warning-soft);
|
| 67 |
+
color: var(--color-warning);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.dw-wfdetail__why-rule {
|
| 71 |
+
font-size: var(--text-sm);
|
| 72 |
+
font-weight: 600;
|
| 73 |
+
color: var(--color-text-primary);
|
| 74 |
+
margin: 0;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.dw-wfdetail__why-msg {
|
| 78 |
+
font-size: var(--text-sm);
|
| 79 |
+
color: var(--color-text-secondary);
|
| 80 |
+
margin: var(--space-1) 0 0;
|
| 81 |
+
}
|
frontend/src/pages/WorkflowDetail.jsx
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 2 |
+
import { useParams, useNavigate } from "react-router-dom";
|
| 3 |
+
import { getWorkflow } from "../api/workflows";
|
| 4 |
+
import { getWorkflowMetrics } from "../api/metrics";
|
| 5 |
+
import { listPendingProposals } from "../api/proposals";
|
| 6 |
+
import { WorkflowStatusBadge } from "../components/workflow/WorkflowStatusBadge";
|
| 7 |
+
import { WorkflowPipeline } from "../components/workflow/WorkflowPipeline";
|
| 8 |
+
import { WorkflowTimeline } from "../components/workflow/WorkflowTimeline";
|
| 9 |
+
import { ProposalReviewList } from "../components/proposals/ProposalReviewList";
|
| 10 |
+
import { Card, CardHeader, CardBody, LoadingState, EmptyState, Glossary } from "../components/ui";
|
| 11 |
+
import { GLOSSARY } from "../utils/labels";
|
| 12 |
+
import "./WorkflowDetail.css";
|
| 13 |
+
|
| 14 |
+
const ACTIVE_STATUSES = new Set(["PENDING", "RUNNING"]);
|
| 15 |
+
const POLL_INTERVAL_MS = 3000;
|
| 16 |
+
|
| 17 |
+
const STATUS_MESSAGES = {
|
| 18 |
+
PENDING: "This workflow is queued and will begin shortly.",
|
| 19 |
+
RUNNING: "The document is being processed. Knowledge extraction and reconciliation are in progress.",
|
| 20 |
+
WAITING_FOR_REVIEW: "Human review required. Please review the proposals below.",
|
| 21 |
+
COMPLETED: "This workflow has finished. All knowledge has been committed.",
|
| 22 |
+
FAILED: "This workflow run ended in a failed state.",
|
| 23 |
+
CANCELLED: "This workflow run was cancelled.",
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export default function WorkflowDetail() {
|
| 27 |
+
const { workflowId } = useParams();
|
| 28 |
+
const navigate = useNavigate();
|
| 29 |
+
const [workflow, setWorkflow] = useState(null);
|
| 30 |
+
const [loading, setLoading] = useState(true);
|
| 31 |
+
const [error, setError] = useState(null);
|
| 32 |
+
const [metrics, setMetrics] = useState(null);
|
| 33 |
+
const [timelineProposals, setTimelineProposals] = useState([]);
|
| 34 |
+
const pollRef = useRef(null);
|
| 35 |
+
|
| 36 |
+
const fetchWorkflow = useCallback(async () => {
|
| 37 |
+
if (!workflowId) return;
|
| 38 |
+
try {
|
| 39 |
+
const result = await getWorkflow(workflowId);
|
| 40 |
+
if (result?.detail) {
|
| 41 |
+
setError(result.detail);
|
| 42 |
+
setWorkflow(null);
|
| 43 |
+
} else {
|
| 44 |
+
setWorkflow(result);
|
| 45 |
+
setError(null);
|
| 46 |
+
if (result.status && !ACTIVE_STATUSES.has(result.status)) {
|
| 47 |
+
const m = await getWorkflowMetrics(workflowId);
|
| 48 |
+
if (m && !m.detail) setMetrics(m);
|
| 49 |
+
}
|
| 50 |
+
if (result.workspace_id && result.document_version_id) {
|
| 51 |
+
const props = await listPendingProposals(result.workspace_id, result.document_version_id);
|
| 52 |
+
if (Array.isArray(props)) setTimelineProposals(props);
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
} catch {
|
| 56 |
+
setError("Couldn't load workflow.");
|
| 57 |
+
} finally {
|
| 58 |
+
setLoading(false);
|
| 59 |
+
}
|
| 60 |
+
}, [workflowId]);
|
| 61 |
+
|
| 62 |
+
useEffect(() => { setLoading(true); fetchWorkflow(); }, [fetchWorkflow]);
|
| 63 |
+
|
| 64 |
+
useEffect(() => {
|
| 65 |
+
if (!workflow) return;
|
| 66 |
+
if (ACTIVE_STATUSES.has(workflow.status)) {
|
| 67 |
+
pollRef.current = setTimeout(fetchWorkflow, POLL_INTERVAL_MS);
|
| 68 |
+
}
|
| 69 |
+
return () => { if (pollRef.current) clearTimeout(pollRef.current); };
|
| 70 |
+
}, [workflow, fetchWorkflow]);
|
| 71 |
+
|
| 72 |
+
if (!workflowId) return <EmptyState title="No workflow selected" />;
|
| 73 |
+
if (loading) return <LoadingState label="Loading workflow…" />;
|
| 74 |
+
if (error) return <EmptyState title="Error" description={error} />;
|
| 75 |
+
if (!workflow) return <EmptyState title="Workflow not found" />;
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<div className="dw-page">
|
| 79 |
+
<button className="dw-wfdetail__back" onClick={() => navigate("/workflows")}>
|
| 80 |
+
← Workflows
|
| 81 |
+
</button>
|
| 82 |
+
<header className="dw-page__header">
|
| 83 |
+
<h1>{workflow.filename || "Workflow"}</h1>
|
| 84 |
+
</header>
|
| 85 |
+
|
| 86 |
+
<Glossary terms={GLOSSARY.workflows} />
|
| 87 |
+
|
| 88 |
+
<Card>
|
| 89 |
+
<CardHeader
|
| 90 |
+
title="Workflow status"
|
| 91 |
+
action={<WorkflowStatusBadge status={workflow.status} />}
|
| 92 |
+
/>
|
| 93 |
+
<CardBody>
|
| 94 |
+
<WorkflowPipeline
|
| 95 |
+
currentNode={workflow.current_node}
|
| 96 |
+
workflowStatus={workflow.status}
|
| 97 |
+
/>
|
| 98 |
+
<p className="dw-wfdetail__status-message">
|
| 99 |
+
{STATUS_MESSAGES[workflow.status] || ""}
|
| 100 |
+
</p>
|
| 101 |
+
<dl className="dw-wfdetail__meta">
|
| 102 |
+
<dt>Started</dt>
|
| 103 |
+
<dd>{new Date(workflow.started_at).toLocaleString()}</dd>
|
| 104 |
+
<dt>Completed</dt>
|
| 105 |
+
<dd>{workflow.completed_at ? new Date(workflow.completed_at).toLocaleString() : "—"}</dd>
|
| 106 |
+
<dt>Document</dt>
|
| 107 |
+
<dd>{workflow.filename || "—"}</dd>
|
| 108 |
+
</dl>
|
| 109 |
+
</CardBody>
|
| 110 |
+
</Card>
|
| 111 |
+
|
| 112 |
+
{workflow.status === "WAITING_FOR_REVIEW" && workflow.validation_results && (
|
| 113 |
+
<Card>
|
| 114 |
+
<CardHeader title="Why is this waiting?" />
|
| 115 |
+
<CardBody>
|
| 116 |
+
<div className="dw-wfdetail__why">
|
| 117 |
+
{workflow.validation_results
|
| 118 |
+
.filter((r) => r.status === "FAIL" || r.status === "WARNING")
|
| 119 |
+
.map((r, i) => (
|
| 120 |
+
<div key={i} className="dw-wfdetail__why-item">
|
| 121 |
+
<span className={`dw-wfdetail__why-badge dw-wfdetail__why-badge--${r.status.toLowerCase()}`}>
|
| 122 |
+
{r.status}
|
| 123 |
+
</span>
|
| 124 |
+
<div>
|
| 125 |
+
<p className="dw-wfdetail__why-rule">{r.rule_name || "Validation Rule"}</p>
|
| 126 |
+
<p className="dw-wfdetail__why-msg">{r.message}</p>
|
| 127 |
+
</div>
|
| 128 |
+
</div>
|
| 129 |
+
))}
|
| 130 |
+
</div>
|
| 131 |
+
</CardBody>
|
| 132 |
+
</Card>
|
| 133 |
+
)}
|
| 134 |
+
|
| 135 |
+
<Card>
|
| 136 |
+
<CardHeader title="Event timeline" />
|
| 137 |
+
<CardBody>
|
| 138 |
+
<WorkflowTimeline workflow={workflow} proposals={timelineProposals} />
|
| 139 |
+
</CardBody>
|
| 140 |
+
</Card>
|
| 141 |
+
|
| 142 |
+
{workflow.status === "WAITING_FOR_REVIEW" && (
|
| 143 |
+
<Card>
|
| 144 |
+
<CardHeader
|
| 145 |
+
title="Pending review"
|
| 146 |
+
subtitle="This workflow is paused until these proposals are approved or rejected."
|
| 147 |
+
/>
|
| 148 |
+
<CardBody>
|
| 149 |
+
<ProposalReviewList
|
| 150 |
+
workspaceId={workflow.workspace_id}
|
| 151 |
+
documentVersionId={workflow.document_version_id}
|
| 152 |
+
onDecision={fetchWorkflow}
|
| 153 |
+
/>
|
| 154 |
+
</CardBody>
|
| 155 |
+
</Card>
|
| 156 |
+
)}
|
| 157 |
+
|
| 158 |
+
{metrics && metrics.stages && metrics.stages.length > 0 && (
|
| 159 |
+
<Card>
|
| 160 |
+
<CardHeader title="Run cost and timing" />
|
| 161 |
+
<CardBody>
|
| 162 |
+
<div className="dw-metrics">
|
| 163 |
+
<div className="dw-metrics__summary">
|
| 164 |
+
{metrics.total_elapsed_ms != null && (
|
| 165 |
+
<div className="dw-metrics__stat">
|
| 166 |
+
<span className="dw-metrics__stat-value">
|
| 167 |
+
{metrics.total_elapsed_ms > 1000
|
| 168 |
+
? `${(metrics.total_elapsed_ms / 1000).toFixed(1)}s`
|
| 169 |
+
: `${Math.round(metrics.total_elapsed_ms)}ms`}
|
| 170 |
+
</span>
|
| 171 |
+
<span className="dw-metrics__stat-label">Total time</span>
|
| 172 |
+
</div>
|
| 173 |
+
)}
|
| 174 |
+
{metrics.total_tokens > 0 && (
|
| 175 |
+
<div className="dw-metrics__stat">
|
| 176 |
+
<span className="dw-metrics__stat-value">{metrics.total_tokens.toLocaleString()}</span>
|
| 177 |
+
<span className="dw-metrics__stat-label">Tokens used</span>
|
| 178 |
+
</div>
|
| 179 |
+
)}
|
| 180 |
+
{metrics.total_llm_calls > 0 && (
|
| 181 |
+
<div className="dw-metrics__stat">
|
| 182 |
+
<span className="dw-metrics__stat-value">{metrics.total_llm_calls}</span>
|
| 183 |
+
<span className="dw-metrics__stat-label">LLM calls</span>
|
| 184 |
+
</div>
|
| 185 |
+
)}
|
| 186 |
+
</div>
|
| 187 |
+
<div className="dw-metrics__stages">
|
| 188 |
+
<p className="dw-metrics__stages-title">Time per stage</p>
|
| 189 |
+
{metrics.stages.map((stage) => (
|
| 190 |
+
<div key={stage.name} className="dw-metrics__stage-row">
|
| 191 |
+
<span className="dw-metrics__stage-name">{stage.name}</span>
|
| 192 |
+
<div className="dw-metrics__stage-bar-track">
|
| 193 |
+
<div
|
| 194 |
+
className="dw-metrics__stage-bar-fill"
|
| 195 |
+
style={{ width: `${Math.min(100, (stage.elapsed_ms / (metrics.total_elapsed_ms || 1)) * 100)}%` }}
|
| 196 |
+
/>
|
| 197 |
+
</div>
|
| 198 |
+
<span className="dw-metrics__stage-time">
|
| 199 |
+
{stage.elapsed_ms > 1000 ? `${(stage.elapsed_ms / 1000).toFixed(1)}s` : `${Math.round(stage.elapsed_ms)}ms`}
|
| 200 |
+
</span>
|
| 201 |
+
{stage.input_tokens > 0 && (
|
| 202 |
+
<span className="dw-metrics__stage-tokens">
|
| 203 |
+
{(stage.input_tokens + stage.output_tokens).toLocaleString()} tok
|
| 204 |
+
</span>
|
| 205 |
+
)}
|
| 206 |
+
</div>
|
| 207 |
+
))}
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
</CardBody>
|
| 211 |
+
</Card>
|
| 212 |
+
)}
|
| 213 |
+
</div>
|
| 214 |
+
);
|
| 215 |
+
}
|
frontend/src/pages/Workflows.jsx
CHANGED
|
@@ -62,9 +62,7 @@ export default function Workflows() {
|
|
| 62 |
key={w.id}
|
| 63 |
interactive
|
| 64 |
onClick={() => {
|
| 65 |
-
|
| 66 |
-
navigate(`/documents/${w.document_id}/${w.id}`);
|
| 67 |
-
}
|
| 68 |
}}
|
| 69 |
>
|
| 70 |
<CardBody>
|
|
|
|
| 62 |
key={w.id}
|
| 63 |
interactive
|
| 64 |
onClick={() => {
|
| 65 |
+
navigate(`/workflows/${w.workflow_id}`);
|
|
|
|
|
|
|
| 66 |
}}
|
| 67 |
>
|
| 68 |
<CardBody>
|