feat(workflows): M6 — dashboard run-history + DAG view
A per-app "Workflows" tab: list definitions, browse run history, and inspect a run's per-step progress with a static layered DAG. - API: the run-detail endpoint now returns each step's `depends_on` (loaded from the definition via a new `get_workflow_by_id` reader) so the dashboard can draw the graph edges — the run's step rows don't carry them. - dashboard `$lib/api`: a `workflows` namespace (list / runs / start / run) + the matching types. - `apps/[slug]/workflows/+page.svelte`: workflow table → drill into runs → drill into a run. The run view renders a step table plus an SVG DAG (nodes = steps colored by status, laid out by longest-path level; edges = depends_on, arrowed) and a "Start run" button. Nested/parked steps show their child run. - AppTabBar + the per-app layout gain the Workflows tab (admin-only). Tests: `npm run check` clean (0 errors); two Playwright smoke tests (navigation tabs — workflows loads cleanly + renders its empty state) pass against the full stack. With M6 the v1.2 Workflows track (M1 schema/validation → M2 durable orchestrator → M3 when/templating → M4 nested → M5 SDK/API/CLI → M6 dashboard) is COMPLETE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -989,6 +989,23 @@ pub async fn get_workflow_by_name(
|
|||||||
row.map(TryInto::try_into).transpose()
|
row.map(TryInto::try_into).transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve an app-owned workflow by id (app-scoped). Backs the run-detail
|
||||||
|
/// API's DAG-edge lookup (`depends_on` lives on the definition, not the run).
|
||||||
|
pub async fn get_workflow_by_id(
|
||||||
|
pool: &PgPool,
|
||||||
|
app_id: AppId,
|
||||||
|
id: WorkflowId,
|
||||||
|
) -> Result<Option<Workflow>, WorkflowRepoError> {
|
||||||
|
let row: Option<WorkflowRow> = sqlx::query_as(&format!(
|
||||||
|
"SELECT {SELECT_COLS} FROM workflows WHERE app_id = $1 AND id = $2"
|
||||||
|
))
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
/// Start a nested sub-workflow run and **park** the parent step on it. In one
|
/// Start a nested sub-workflow run and **park** the parent step on it. In one
|
||||||
/// token-gated transaction: the parent step (still claimed by the caller) is
|
/// token-gated transaction: the parent step (still claimed by the caller) is
|
||||||
/// set `running` with `claim_token` cleared and `child_run_id` pointing at a
|
/// set `running` with `claim_token` cleared and `child_run_id` pointing at a
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ use uuid::Uuid;
|
|||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||||
use crate::workflow_repo::{
|
use crate::workflow_repo::{
|
||||||
get_run, get_workflow_by_name, list_run_steps, list_runs_for_workflow, list_workflows_for_app,
|
get_run, get_workflow_by_id, get_workflow_by_name, list_run_steps, list_runs_for_workflow,
|
||||||
start_run, NewWorkflowRun, WorkflowRun, WorkflowRunStep,
|
list_workflows_for_app, start_run, NewWorkflowRun, WorkflowRun, WorkflowRunStep,
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
@@ -103,10 +103,12 @@ struct StepDto {
|
|||||||
output: Option<Value>,
|
output: Option<Value>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
child_run_id: Option<Uuid>,
|
child_run_id: Option<Uuid>,
|
||||||
|
/// DAG edges (from the definition) so the dashboard can draw the graph.
|
||||||
|
depends_on: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<WorkflowRunStep> for StepDto {
|
impl StepDto {
|
||||||
fn from(s: WorkflowRunStep) -> Self {
|
fn from_step(s: WorkflowRunStep, depends_on: Vec<String>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
name: s.step_name,
|
name: s.step_name,
|
||||||
status: s.status.as_str().to_string(),
|
status: s.status.as_str().to_string(),
|
||||||
@@ -115,6 +117,7 @@ impl From<WorkflowRunStep> for StepDto {
|
|||||||
output: s.output,
|
output: s.output,
|
||||||
error: s.error,
|
error: s.error,
|
||||||
child_run_id: s.child_run_id.map(WorkflowRunId::into_inner),
|
child_run_id: s.child_run_id.map(WorkflowRunId::into_inner),
|
||||||
|
depends_on,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,8 +222,29 @@ async fn get_run_detail(
|
|||||||
let steps = list_run_steps(&s.pool, run.id)
|
let steps = list_run_steps(&s.pool, run.id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| WorkflowsApiError::Backend(e.to_string()))?;
|
.map_err(|e| WorkflowsApiError::Backend(e.to_string()))?;
|
||||||
|
// Load the definition for the per-step `depends_on` DAG edges (they live on
|
||||||
|
// the definition, not the run's step rows).
|
||||||
|
let deps: std::collections::HashMap<String, Vec<String>> =
|
||||||
|
match get_workflow_by_id(&s.pool, app_id, run.workflow_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WorkflowsApiError::Backend(e.to_string()))?
|
||||||
|
{
|
||||||
|
Some(w) => w
|
||||||
|
.definition
|
||||||
|
.steps
|
||||||
|
.into_iter()
|
||||||
|
.map(|st| (st.name, st.depends_on))
|
||||||
|
.collect(),
|
||||||
|
None => std::collections::HashMap::new(),
|
||||||
|
};
|
||||||
let run_dto: RunDto = run.into();
|
let run_dto: RunDto = run.into();
|
||||||
let step_dtos: Vec<StepDto> = steps.into_iter().map(Into::into).collect();
|
let step_dtos: Vec<StepDto> = steps
|
||||||
|
.into_iter()
|
||||||
|
.map(|st| {
|
||||||
|
let d = deps.get(&st.step_name).cloned().unwrap_or_default();
|
||||||
|
StepDto::from_step(st, d)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
Ok(Json(json!({ "run": run_dto, "steps": step_dtos })))
|
Ok(Json(json!({ "run": run_dto, "steps": step_dtos })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,12 @@
|
|||||||
{ id: 'users', label: 'Users', href: `${base}/apps/${slug}/users`, adminOnly: true },
|
{ id: 'users', label: 'Users', href: `${base}/apps/${slug}/users`, adminOnly: true },
|
||||||
{ id: 'files', label: 'Files', href: `${base}/apps/${slug}/files`, adminOnly: true },
|
{ id: 'files', label: 'Files', href: `${base}/apps/${slug}/files`, adminOnly: true },
|
||||||
{ id: 'queues', label: 'Queues', href: `${base}/apps/${slug}/queues`, adminOnly: true },
|
{ id: 'queues', label: 'Queues', href: `${base}/apps/${slug}/queues`, adminOnly: true },
|
||||||
|
{
|
||||||
|
id: 'workflows',
|
||||||
|
label: 'Workflows',
|
||||||
|
href: `${base}/apps/${slug}/workflows`,
|
||||||
|
adminOnly: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'dead-letters',
|
id: 'dead-letters',
|
||||||
label: 'Dead letters',
|
label: 'Dead letters',
|
||||||
|
|||||||
@@ -444,6 +444,57 @@ export interface CreateQueueTriggerInput {
|
|||||||
retry_base_ms?: number;
|
retry_base_ms?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.2 Workflows — read/run types (admin workflows API).
|
||||||
|
export interface WorkflowSummary {
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
steps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkflowRunStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'running'
|
||||||
|
| 'succeeded'
|
||||||
|
| 'failed'
|
||||||
|
| 'canceled';
|
||||||
|
|
||||||
|
export interface WorkflowRun {
|
||||||
|
id: string;
|
||||||
|
workflow_id?: string;
|
||||||
|
status: WorkflowRunStatus;
|
||||||
|
error?: string | null;
|
||||||
|
workflow_depth: number;
|
||||||
|
created_at: string;
|
||||||
|
input?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
started_at?: string | null;
|
||||||
|
finished_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkflowStepStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'ready'
|
||||||
|
| 'running'
|
||||||
|
| 'succeeded'
|
||||||
|
| 'failed'
|
||||||
|
| 'skipped';
|
||||||
|
|
||||||
|
export interface WorkflowRunStep {
|
||||||
|
name: string;
|
||||||
|
status: WorkflowStepStatus;
|
||||||
|
attempt: number;
|
||||||
|
max_attempts: number;
|
||||||
|
error?: string | null;
|
||||||
|
child_run_id?: string | null;
|
||||||
|
depends_on: string[];
|
||||||
|
output?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowRunDetail {
|
||||||
|
run: WorkflowRun;
|
||||||
|
steps: WorkflowRunStep[];
|
||||||
|
}
|
||||||
|
|
||||||
// v1.1.9 — read-only queue inspection types (admin queues API).
|
// v1.1.9 — read-only queue inspection types (admin queues API).
|
||||||
export interface QueueSummary {
|
export interface QueueSummary {
|
||||||
queue_name: string;
|
queue_name: string;
|
||||||
@@ -1214,6 +1265,26 @@ export const api = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
workflows: {
|
||||||
|
list: (idOrSlug: string) =>
|
||||||
|
adminRequest<WorkflowSummary[]>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflows`
|
||||||
|
),
|
||||||
|
runs: (idOrSlug: string, name: string) =>
|
||||||
|
adminRequest<WorkflowRun[]>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflows/${encodeURIComponent(name)}/runs`
|
||||||
|
),
|
||||||
|
start: (idOrSlug: string, name: string, input: unknown) =>
|
||||||
|
adminRequest<WorkflowRun>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflows/${encodeURIComponent(name)}/runs`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ input }) }
|
||||||
|
),
|
||||||
|
run: (idOrSlug: string, runId: string) =>
|
||||||
|
adminRequest<WorkflowRunDetail>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/workflow-runs/${encodeURIComponent(runId)}`
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
topics: {
|
topics: {
|
||||||
list: (idOrSlug: string) =>
|
list: (idOrSlug: string) =>
|
||||||
adminRequest<{ topics: Topic[] }>(
|
adminRequest<{ topics: Topic[] }>(
|
||||||
|
|||||||
@@ -70,6 +70,8 @@
|
|||||||
return 'files';
|
return 'files';
|
||||||
case 'queues':
|
case 'queues':
|
||||||
return 'queues';
|
return 'queues';
|
||||||
|
case 'workflows':
|
||||||
|
return 'workflows';
|
||||||
case 'dead-letters':
|
case 'dead-letters':
|
||||||
return 'dead-letters';
|
return 'dead-letters';
|
||||||
default:
|
default:
|
||||||
|
|||||||
433
dashboard/src/routes/apps/[slug]/workflows/+page.svelte
Normal file
433
dashboard/src/routes/apps/[slug]/workflows/+page.svelte
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { getContext } from 'svelte';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
ApiError,
|
||||||
|
type WorkflowSummary,
|
||||||
|
type WorkflowRun,
|
||||||
|
type WorkflowRunDetail,
|
||||||
|
type WorkflowRunStep
|
||||||
|
} from '$lib/api';
|
||||||
|
import { APP_CTX_KEY, type AppContext } from '../+layout.svelte';
|
||||||
|
|
||||||
|
let slug = $derived(page.params.slug ?? '');
|
||||||
|
const ctx = getContext<AppContext>(APP_CTX_KEY);
|
||||||
|
const appStore = ctx?.app;
|
||||||
|
let appName = $derived($appStore?.name ?? slug);
|
||||||
|
|
||||||
|
let workflows = $state<WorkflowSummary[]>([]);
|
||||||
|
let selectedWf = $state<string | null>(null);
|
||||||
|
let runs = $state<WorkflowRun[]>([]);
|
||||||
|
let detail = $state<WorkflowRunDetail | null>(null);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
async function loadWorkflows() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
workflows = await api.workflows.list(slug);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectWorkflow(name: string) {
|
||||||
|
selectedWf = name;
|
||||||
|
detail = null;
|
||||||
|
runs = [];
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
runs = await api.workflows.runs(slug, name);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectRun(runId: string) {
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
detail = await api.workflows.run(slug, runId);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRun() {
|
||||||
|
if (!selectedWf) return;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const run = await api.workflows.start(slug, selectedWf, null);
|
||||||
|
await selectWorkflow(selectedWf);
|
||||||
|
await selectRun(run.id);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void slug;
|
||||||
|
void loadWorkflows();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- static depth-1 DAG layout: layered by longest-path level ---------
|
||||||
|
const COL_W = 170;
|
||||||
|
const ROW_H = 64;
|
||||||
|
const NODE_W = 140;
|
||||||
|
const NODE_H = 42;
|
||||||
|
|
||||||
|
let layout = $derived(computeLayout(detail?.steps ?? []));
|
||||||
|
|
||||||
|
function computeLayout(steps: WorkflowRunStep[]) {
|
||||||
|
const byName = new Map(steps.map((s) => [s.name, s]));
|
||||||
|
const level = new Map<string, number>();
|
||||||
|
function lvl(name: string, seen: Set<string> = new Set()): number {
|
||||||
|
const cached = level.get(name);
|
||||||
|
if (cached !== undefined) return cached;
|
||||||
|
if (seen.has(name)) return 0; // cycle guard (validation forbids these)
|
||||||
|
seen.add(name);
|
||||||
|
const deps = byName.get(name)?.depends_on ?? [];
|
||||||
|
const v = deps.length === 0 ? 0 : Math.max(...deps.map((d) => lvl(d, seen) + 1));
|
||||||
|
level.set(name, v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
for (const s of steps) lvl(s.name);
|
||||||
|
|
||||||
|
const cols = new Map<number, WorkflowRunStep[]>();
|
||||||
|
for (const s of steps) {
|
||||||
|
const l = level.get(s.name) ?? 0;
|
||||||
|
(cols.get(l) ?? cols.set(l, []).get(l)!).push(s);
|
||||||
|
}
|
||||||
|
const pos = new Map<string, { x: number; y: number }>();
|
||||||
|
const nodes: { step: WorkflowRunStep; x: number; y: number }[] = [];
|
||||||
|
for (const [l, arr] of [...cols.entries()].sort((a, b) => a[0] - b[0])) {
|
||||||
|
arr.forEach((s, i) => {
|
||||||
|
const x = l * COL_W + 10;
|
||||||
|
const y = i * ROW_H + 10;
|
||||||
|
nodes.push({ step: s, x, y });
|
||||||
|
pos.set(s.name, { x, y });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const edges: { x1: number; y1: number; x2: number; y2: number }[] = [];
|
||||||
|
for (const s of steps) {
|
||||||
|
for (const d of s.depends_on) {
|
||||||
|
const from = pos.get(d);
|
||||||
|
const to = pos.get(s.name);
|
||||||
|
if (from && to) {
|
||||||
|
edges.push({
|
||||||
|
x1: from.x + NODE_W,
|
||||||
|
y1: from.y + NODE_H / 2,
|
||||||
|
x2: to.x,
|
||||||
|
y2: to.y + NODE_H / 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const maxLevel = Math.max(0, ...[...cols.keys()]);
|
||||||
|
const maxRows = Math.max(1, ...[...cols.values()].map((a) => a.length));
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
width: (maxLevel + 1) * COL_W + 20,
|
||||||
|
height: maxRows * ROW_H + 20
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function short(id: string) {
|
||||||
|
return id.slice(0, 8);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Workflows — {appName} — PiCloud</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<header class="panel-head">
|
||||||
|
<h2>Workflows</h2>
|
||||||
|
<p class="subtle">
|
||||||
|
Durable DAG workflows (v1.2). Definitions are authored declaratively
|
||||||
|
(<code>[[workflows]]</code> in <code>picloud.toml</code>); start runs and
|
||||||
|
inspect their per-step progress here.
|
||||||
|
</p>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button type="button" class="secondary" onclick={() => loadWorkflows()} disabled={loading}>
|
||||||
|
{loading ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading && workflows.length === 0}
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
{:else if workflows.length === 0}
|
||||||
|
<p class="muted" data-testid="workflows-empty-state">
|
||||||
|
No workflows in this app yet. Author one with a <code>[[workflows]]</code>
|
||||||
|
block in your manifest and <code>pic apply</code>.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="workflows-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Workflow</th><th class="num">Steps</th><th>Enabled</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each workflows as w (w.name)}
|
||||||
|
<tr class:selected={selectedWf === w.name}>
|
||||||
|
<td><code>{w.name}</code></td>
|
||||||
|
<td class="num">{w.steps}</td>
|
||||||
|
<td>{w.enabled ? 'yes' : 'no'}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="link" onclick={() => selectWorkflow(w.name)}>
|
||||||
|
runs →
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedWf}
|
||||||
|
<section class="runs-panel">
|
||||||
|
<div class="runs-head">
|
||||||
|
<h3>Runs — <code>{selectedWf}</code></h3>
|
||||||
|
<button type="button" class="secondary" onclick={() => startRun()}>Start run</button>
|
||||||
|
</div>
|
||||||
|
{#if runs.length === 0}
|
||||||
|
<p class="muted">No runs yet.</p>
|
||||||
|
{:else}
|
||||||
|
<table class="grid" data-testid="runs-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Run</th><th>Status</th><th class="num">Depth</th><th>Created</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each runs as r (r.id)}
|
||||||
|
<tr class:selected={detail?.run.id === r.id}>
|
||||||
|
<td><code>{short(r.id)}</code></td>
|
||||||
|
<td><span class="badge st-{r.status}">{r.status}</span></td>
|
||||||
|
<td class="num">{r.workflow_depth}</td>
|
||||||
|
<td class="muted">{r.created_at}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="link" onclick={() => selectRun(r.id)}>detail →</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if detail}
|
||||||
|
<section class="detail-panel" data-testid="run-detail">
|
||||||
|
<h3>
|
||||||
|
Run <code>{short(detail.run.id)}</code>
|
||||||
|
<span class="badge st-{detail.run.status}">{detail.run.status}</span>
|
||||||
|
</h3>
|
||||||
|
{#if detail.run.error}
|
||||||
|
<p class="error">{detail.run.error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h4>DAG</h4>
|
||||||
|
<div class="dag-wrap">
|
||||||
|
<svg width={layout.width} height={layout.height} role="img" aria-label="workflow DAG">
|
||||||
|
{#each layout.edges as e}
|
||||||
|
<line
|
||||||
|
x1={e.x1}
|
||||||
|
y1={e.y1}
|
||||||
|
x2={e.x2}
|
||||||
|
y2={e.y2}
|
||||||
|
stroke="var(--border)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
marker-end="url(#arrow)"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
<defs>
|
||||||
|
<marker id="arrow" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
|
||||||
|
<path d="M0,0 L7,3 L0,6 Z" fill="var(--text-muted)" />
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
{#each layout.nodes as n (n.step.name)}
|
||||||
|
<g transform="translate({n.x},{n.y})">
|
||||||
|
<rect
|
||||||
|
width={NODE_W}
|
||||||
|
height={NODE_H}
|
||||||
|
rx="6"
|
||||||
|
class="node st-{n.step.status}"
|
||||||
|
/>
|
||||||
|
<text x={NODE_W / 2} y={NODE_H / 2 + 4} text-anchor="middle" class="node-label">
|
||||||
|
{n.step.name}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
{/each}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Steps</h4>
|
||||||
|
<table class="grid" data-testid="steps-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Step</th><th>Status</th><th class="num">Attempt</th><th>Depends on</th><th>Error</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each detail.steps as s (s.name)}
|
||||||
|
<tr>
|
||||||
|
<td><code>{s.name}</code></td>
|
||||||
|
<td><span class="badge st-{s.status}">{s.status}</span></td>
|
||||||
|
<td class="num">{s.attempt}/{s.max_attempts}</td>
|
||||||
|
<td class="muted">{s.depends_on.join(', ')}</td>
|
||||||
|
<td class="err-cell">{s.error ?? ''}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panel-head {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 1rem 0 0.4rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.subtle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
button.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.4rem 0.85rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.secondary:hover:not(:disabled) {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
button.link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-accent, #4f8cff);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
table.grid {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
table.grid th,
|
||||||
|
table.grid td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
tr.selected {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
.err-cell {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
max-width: 24rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.runs-panel,
|
||||||
|
.detail-panel {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.runs-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.dag-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
}
|
||||||
|
.node {
|
||||||
|
fill: var(--bg-surface, #1b1e26);
|
||||||
|
stroke: var(--border);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
.node-label {
|
||||||
|
fill: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
/* Status colors, shared by badges + DAG node strokes. */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.st-succeeded {
|
||||||
|
color: #2ecc71;
|
||||||
|
stroke: #2ecc71;
|
||||||
|
}
|
||||||
|
.st-failed {
|
||||||
|
color: #e74c3c;
|
||||||
|
stroke: #e74c3c;
|
||||||
|
}
|
||||||
|
.st-running {
|
||||||
|
color: #4f8cff;
|
||||||
|
stroke: #4f8cff;
|
||||||
|
}
|
||||||
|
.st-ready {
|
||||||
|
color: #f0ad4e;
|
||||||
|
stroke: #f0ad4e;
|
||||||
|
}
|
||||||
|
.st-skipped {
|
||||||
|
color: var(--text-muted);
|
||||||
|
stroke: var(--text-muted);
|
||||||
|
}
|
||||||
|
.st-pending,
|
||||||
|
.st-canceled {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -105,6 +105,7 @@ const TABS: Array<{ subpath: string; label: string }> = [
|
|||||||
{ subpath: '', label: 'main' },
|
{ subpath: '', label: 'main' },
|
||||||
{ subpath: '/queues', label: 'queues' },
|
{ subpath: '/queues', label: 'queues' },
|
||||||
{ subpath: '/queues/never-enqueued', label: 'queue drilldown' },
|
{ subpath: '/queues/never-enqueued', label: 'queue drilldown' },
|
||||||
|
{ subpath: '/workflows', label: 'workflows' },
|
||||||
{ subpath: '/files', label: 'files' },
|
{ subpath: '/files', label: 'files' },
|
||||||
{ subpath: '/dead-letters', label: 'dead-letters' },
|
{ subpath: '/dead-letters', label: 'dead-letters' },
|
||||||
{ subpath: '/users', label: 'app users' },
|
{ subpath: '/users', label: 'app users' },
|
||||||
@@ -154,4 +155,21 @@ test.describe('per-app tab navigation accepts slug URLs', () => {
|
|||||||
// DOM element.
|
// DOM element.
|
||||||
await expect(page.getByTestId('queues-empty-state')).toBeVisible();
|
await expect(page.getByTestId('queues-empty-state')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('workflows tab renders its empty state (v1.2)', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('wf');
|
||||||
|
cleanup.app(slug);
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug, name: slug }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
await expectTabLoadsCleanly(page, `/admin/apps/${slug}/workflows`);
|
||||||
|
// A fresh app has no workflows → the empty-state anchors the smoke test.
|
||||||
|
await expect(page.getByTestId('workflows-empty-state')).toBeVisible();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user