diff --git a/src/components/layout/workflows-panel.tsx b/src/components/layout/workflows-panel.tsx
index 1895bd0..44edff2 100644
--- a/src/components/layout/workflows-panel.tsx
+++ b/src/components/layout/workflows-panel.tsx
@@ -1,10 +1,13 @@
"use client"
-import { useEffect, useState } from "react"
-import { Cpu, Loader2, X } from "lucide-react"
+import { useEffect, useRef, useState } from "react"
+import { ChevronDown, ChevronUp, Cpu, ExternalLink, Loader2, X } from "lucide-react"
import { ScrollArea } from "@/components/ui/scroll-area"
-import { getWorkflowMarketplace, type WorkflowMarketplaceItem, type CronKind } from "@/lib/graph-api"
-import { isMocksEnabled, MOCK_WORKFLOW_MARKETPLACE } from "@/lib/mock-data"
+import { RunStatusBadge } from "@/components/ui/run-status-badge"
+import { getWorkflowMarketplace, getCronRuns, type WorkflowMarketplaceItem, type CronKind, type StakworkRun } from "@/lib/graph-api"
+import { isMocksEnabled, MOCK_WORKFLOW_MARKETPLACE, MOCK_STAKWORK_RUNS } from "@/lib/mock-data"
+import { formatDateRelative } from "@/lib/date-format"
+import { useUserStore } from "@/stores/user-store"
import { cn } from "@/lib/utils"
import {
WORKFLOW_TYPE_META,
@@ -30,14 +33,124 @@ function kindBadgeClass(kind: CronKind): string {
: "bg-violet-500/15 text-violet-400 border border-violet-500/25"
}
-function WorkflowCard({ item }: { item: WorkflowMarketplaceItem }) {
+function WorkflowRunRow({ run }: { run: StakworkRun }) {
+ const timestamp = run.finished_at ?? run.started_at ?? run.created_at
+ return (
+
+
+
+
+ {formatDateRelative(timestamp, "Never")}
+
+ {run.project_id && (
+
+
+
+ )}
+
+ {run.error && (
+
{run.error}
+ )}
+
+ )
+}
+
+function WorkflowRunsSection({
+ item,
+ runsCache,
+}: {
+ item: WorkflowMarketplaceItem
+ runsCache: React.MutableRefObject>
+}) {
+ const [runs, setRuns] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ useEffect(() => {
+ if (runsCache.current[item.source_type]) {
+ setRuns(runsCache.current[item.source_type])
+ return
+ }
+ let cancelled = false
+ setLoading(true)
+ async function fetchRuns() {
+ try {
+ if (isMocksEnabled()) {
+ const filtered = MOCK_STAKWORK_RUNS.filter(
+ (r) => r.source_type === item.source_type
+ ).slice(0, 10)
+ if (!cancelled) {
+ runsCache.current[item.source_type] = filtered
+ setRuns(filtered)
+ }
+ } else {
+ const { runs: fetched } = await getCronRuns({
+ source_type: item.source_type,
+ kind: item.kind,
+ limit: 10,
+ })
+ if (!cancelled) {
+ runsCache.current[item.source_type] = fetched
+ setRuns(fetched)
+ }
+ }
+ } catch {
+ /* silent */
+ } finally {
+ if (!cancelled) setLoading(false)
+ }
+ }
+ fetchRuns()
+ return () => {
+ cancelled = true
+ }
+ }, [item.source_type, item.kind, runsCache])
+
+ return (
+
+ {loading ? (
+
+
+
+ ) : !runs || runs.length === 0 ? (
+
No runs yet
+ ) : (
+
+ {runs.map((run) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+function WorkflowCard({
+ item,
+ isAdmin,
+ isExpanded,
+ onToggle,
+ runsCache,
+}: {
+ item: WorkflowMarketplaceItem
+ isAdmin?: boolean
+ isExpanded?: boolean
+ onToggle?: () => void
+ runsCache?: React.MutableRefObject>
+}) {
const meta = WORKFLOW_TYPE_META[item.source_type]
const Icon = meta?.icon ?? Cpu
const tone = meta?.tone ?? "slate"
const displayName = getWorkflowDisplayName(item)
- return (
-
+ const cardHeader = (
+ <>
{/* Tinted icon tile */}
- {/* Name — single source of truth, no duplicate sub-text */}
+ {/* Name */}
- {/* Kind chip + enabled dot */}
+ {/* Kind chip + enabled dot + optional chevron */}
+ {isAdmin && (
+ isExpanded
+ ?
+ :
+ )}
+ >
+ )
+
+ if (isAdmin) {
+ return (
+
+
+ {isExpanded && runsCache && (
+
+ )}
+
+ )
+ }
+
+ return (
+
+ {cardHeader}
)
}
@@ -82,6 +223,9 @@ export function WorkflowsPanel({ onClose }: { onClose: () => void }) {
const [workflows, setWorkflows] = useState([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState("all")
+ const [expandedId, setExpandedId] = useState(null)
+ const runsCache = useRef>({})
+ const isAdmin = useUserStore((s) => s.isAdmin)
useEffect(() => {
let cancelled = false
@@ -167,7 +311,16 @@ export function WorkflowsPanel({ onClose }: { onClose: () => void }) {
) : (
{filtered.map((item) => (
-
+
+ setExpandedId((prev) => (prev === item.ref_id ? null : item.ref_id))
+ }
+ runsCache={runsCache}
+ />
))}
)}
diff --git a/src/components/modals/janitor-settings.tsx b/src/components/modals/janitor-settings.tsx
index 1d1bf03..7254d8c 100644
--- a/src/components/modals/janitor-settings.tsx
+++ b/src/components/modals/janitor-settings.tsx
@@ -15,27 +15,7 @@ import {
updateCronConfig,
} from "@/lib/graph-api"
import { isMocksEnabled, MOCK_CRON_CONFIGS, MOCK_STAKWORK_RUNS } from "@/lib/mock-data"
-
-function RunStatusBadge({ status }: { status: StakworkRun["status"] }) {
- const colours: Record = {
- completed: "bg-green-500/15 text-green-400",
- error: "bg-destructive/15 text-destructive",
- in_progress: "bg-blue-500/15 text-blue-400",
- pending: "bg-yellow-500/15 text-yellow-400",
- halted: "bg-orange-500/15 text-orange-400",
- PENDING: "bg-yellow-500/15 text-yellow-400",
- RUNNING: "bg-blue-500/15 text-blue-400",
- COMPLETED: "bg-green-500/15 text-green-400",
- FAILED: "bg-destructive/15 text-destructive",
- ERROR: "bg-destructive/15 text-destructive",
- HALTED: "bg-orange-500/15 text-orange-400",
- }
- return (
-
- {status}
-
- )
-}
+import { RunStatusBadge } from "@/components/ui/run-status-badge"
export function JanitorSettings({ open }: { open: boolean }) {
const [configs, setConfigs] = useState(null)
diff --git a/src/components/ui/run-status-badge.tsx b/src/components/ui/run-status-badge.tsx
new file mode 100644
index 0000000..be16140
--- /dev/null
+++ b/src/components/ui/run-status-badge.tsx
@@ -0,0 +1,24 @@
+"use client"
+
+import type { StakworkRun } from "@/lib/graph-api"
+
+export function RunStatusBadge({ status }: { status: StakworkRun["status"] }) {
+ const colours: Record = {
+ completed: "bg-green-500/15 text-green-400",
+ error: "bg-destructive/15 text-destructive",
+ in_progress: "bg-blue-500/15 text-blue-400",
+ pending: "bg-yellow-500/15 text-yellow-400",
+ halted: "bg-orange-500/15 text-orange-400",
+ PENDING: "bg-yellow-500/15 text-yellow-400",
+ RUNNING: "bg-blue-500/15 text-blue-400",
+ COMPLETED: "bg-green-500/15 text-green-400",
+ FAILED: "bg-destructive/15 text-destructive",
+ ERROR: "bg-destructive/15 text-destructive",
+ HALTED: "bg-orange-500/15 text-orange-400",
+ }
+ return (
+
+ {status}
+
+ )
+}
diff --git a/src/lib/__tests__/workflows-panel.test.tsx b/src/lib/__tests__/workflows-panel.test.tsx
index 3fe52be..5cb2c28 100644
--- a/src/lib/__tests__/workflows-panel.test.tsx
+++ b/src/lib/__tests__/workflows-panel.test.tsx
@@ -1,11 +1,18 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
-import type { WorkflowMarketplaceItem } from "@/lib/graph-api"
+import type { WorkflowMarketplaceItem, StakworkRun } from "@/lib/graph-api"
// Mock modules before importing the component
vi.mock("@/lib/graph-api", () => ({
getWorkflowMarketplace: vi.fn(),
+ getCronRuns: vi.fn(),
+}))
+
+const userState = { isAdmin: false }
+vi.mock("@/stores/user-store", () => ({
+ useUserStore: (sel?: (s: typeof userState) => unknown) =>
+ sel ? sel(userState) : userState,
}))
const MOCK_ITEMS: WorkflowMarketplaceItem[] = [
@@ -47,13 +54,17 @@ vi.mock("@/lib/mock-data", () => ({
{ ref_id: "rc-deduplication", source_type: "deduplication", kind: "janitor", enabled: true, label: "Deduplication" },
{ ref_id: "rc-content-review", source_type: "content_review", kind: "janitor", enabled: false, label: "Content Review" },
],
+ MOCK_STAKWORK_RUNS: [],
}))
import { WorkflowsPanel } from "@/components/layout/workflows-panel"
describe("WorkflowsPanel", () => {
- beforeEach(() => {
+ beforeEach(async () => {
vi.clearAllMocks()
+ userState.isAdmin = false
+ const { isMocksEnabled } = await import("@/lib/mock-data")
+ vi.mocked(isMocksEnabled).mockReturnValue(false)
})
it("renders loading spinner while fetching", async () => {
@@ -196,4 +207,253 @@ describe("WorkflowsPanel", () => {
expect(screen.queryByText("twitter_handle")).not.toBeInTheDocument()
})
})
+
+ // ── Admin accordion tests ────────────────────────────────────────────────
+
+ describe("admin accordion", () => {
+ beforeEach(() => {
+ userState.isAdmin = true
+ })
+
+ it("non-admin: cards render as plain divs — no buttons with aria-expanded, no chevrons", async () => {
+ userState.isAdmin = false
+ const { getWorkflowMarketplace } = await import("@/lib/graph-api")
+ vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS)
+
+ render( {}} />)
+ await waitFor(() => screen.getByText("Deduplication"))
+
+ // Only filter chip buttons (All, Ingestion, Janitor) + Close button = 4
+ const allButtons = screen.getAllByRole("button")
+ expect(allButtons.length).toBe(4)
+ expect(document.querySelector("[aria-expanded]")).toBeNull()
+ })
+
+ it("admin: cards are buttons with aria-expanded attribute", async () => {
+ const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api")
+ vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS)
+ vi.mocked(getCronRuns).mockResolvedValue({ runs: [] })
+
+ render( {}} />)
+ await waitFor(() => screen.getByText("Deduplication"))
+
+ const expandableButtons = screen
+ .getAllByRole("button")
+ .filter((b) => b.hasAttribute("aria-expanded"))
+ // 4 workflow cards
+ expect(expandableButtons.length).toBe(4)
+ })
+
+ it("clicking a card expands it showing runs section", async () => {
+ const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api")
+ vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS)
+ vi.mocked(getCronRuns).mockResolvedValue({ runs: [] })
+
+ render( {}} />)
+ await waitFor(() => screen.getByText("Deduplication"))
+
+ const cardButtons = screen
+ .getAllByRole("button")
+ .filter((b) => b.hasAttribute("aria-expanded"))
+
+ await userEvent.click(cardButtons[0])
+
+ await waitFor(() => {
+ expect(document.querySelector("[data-testid='runs-section']")).toBeTruthy()
+ })
+ })
+
+ it("clicking an expanded card collapses it", async () => {
+ const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api")
+ vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS)
+ vi.mocked(getCronRuns).mockResolvedValue({ runs: [] })
+
+ render( {}} />)
+ await waitFor(() => screen.getByText("Deduplication"))
+
+ const cardButtons = screen
+ .getAllByRole("button")
+ .filter((b) => b.hasAttribute("aria-expanded"))
+
+ // Expand
+ await userEvent.click(cardButtons[0])
+ await waitFor(() => expect(document.querySelector("[data-testid='runs-section']")).toBeTruthy())
+
+ // Collapse
+ await userEvent.click(cardButtons[0])
+ await waitFor(() => expect(document.querySelector("[data-testid='runs-section']")).toBeNull())
+ })
+
+ it("opening a second card collapses the first (only one open at a time)", async () => {
+ const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api")
+ vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS)
+ vi.mocked(getCronRuns).mockResolvedValue({ runs: [] })
+
+ render( {}} />)
+ await waitFor(() => screen.getByText("Deduplication"))
+
+ const cardButtons = screen
+ .getAllByRole("button")
+ .filter((b) => b.hasAttribute("aria-expanded"))
+
+ await userEvent.click(cardButtons[0])
+ await waitFor(() => expect(document.querySelector("[data-testid='runs-section']")).toBeTruthy())
+
+ await userEvent.click(cardButtons[1])
+ await waitFor(() => {
+ const sections = document.querySelectorAll("[data-testid='runs-section']")
+ expect(sections.length).toBe(1)
+ })
+ })
+
+ it("shows 'No runs yet' when API returns empty array", async () => {
+ const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api")
+ vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS)
+ vi.mocked(getCronRuns).mockResolvedValue({ runs: [] })
+
+ render( {}} />)
+ await waitFor(() => screen.getByText("Deduplication"))
+
+ const cardButtons = screen
+ .getAllByRole("button")
+ .filter((b) => b.hasAttribute("aria-expanded"))
+ await userEvent.click(cardButtons[0])
+
+ await waitFor(() => {
+ expect(screen.getByText("No runs yet")).toBeInTheDocument()
+ })
+ })
+
+ it("renders run rows with RunStatusBadge and relative timestamp", async () => {
+ const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api")
+ vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS)
+ const mockRun: StakworkRun = {
+ ref_id: "run-001",
+ source_type: "twitter_handle",
+ kind: "source",
+ status: "completed",
+ finished_at: Math.floor(Date.now() / 1000) - 3600,
+ }
+ vi.mocked(getCronRuns).mockResolvedValue({ runs: [mockRun] })
+
+ render( {}} />)
+ await waitFor(() => screen.getByText("Twitter Handle"))
+
+ const cardButtons = screen
+ .getAllByRole("button")
+ .filter((b) => b.hasAttribute("aria-expanded"))
+ await userEvent.click(cardButtons[0])
+
+ await waitFor(() => {
+ expect(screen.getByText("completed")).toBeInTheDocument()
+ })
+ })
+
+ it("renders 'View on Stakwork' external link when project_id is present", async () => {
+ const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api")
+ vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS)
+ const mockRun: StakworkRun = {
+ ref_id: "run-001",
+ source_type: "twitter_handle",
+ kind: "source",
+ status: "completed",
+ finished_at: Math.floor(Date.now() / 1000) - 3600,
+ project_id: 123456,
+ }
+ vi.mocked(getCronRuns).mockResolvedValue({ runs: [mockRun] })
+
+ render( {}} />)
+ await waitFor(() => screen.getByText("Twitter Handle"))
+
+ const cardButtons = screen
+ .getAllByRole("button")
+ .filter((b) => b.hasAttribute("aria-expanded"))
+ await userEvent.click(cardButtons[0])
+
+ await waitFor(() => {
+ const link = document.querySelector("[data-testid='stakwork-link']") as HTMLAnchorElement
+ expect(link).toBeTruthy()
+ expect(link.href).toBe("https://jobs.stakwork.com/admin/projects/123456")
+ })
+ })
+
+ it("does not render stakwork link when project_id is absent", async () => {
+ const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api")
+ vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS)
+ const mockRun: StakworkRun = {
+ ref_id: "run-001",
+ source_type: "twitter_handle",
+ kind: "source",
+ status: "completed",
+ finished_at: Math.floor(Date.now() / 1000) - 3600,
+ }
+ vi.mocked(getCronRuns).mockResolvedValue({ runs: [mockRun] })
+
+ render( {}} />)
+ await waitFor(() => screen.getByText("Twitter Handle"))
+
+ const cardButtons = screen
+ .getAllByRole("button")
+ .filter((b) => b.hasAttribute("aria-expanded"))
+ await userEvent.click(cardButtons[0])
+
+ await waitFor(() => {
+ expect(document.querySelector("[data-testid='stakwork-link']")).toBeNull()
+ })
+ })
+
+ it("renders error text below run row when error field is set", async () => {
+ const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api")
+ vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS)
+ const mockRun: StakworkRun = {
+ ref_id: "run-002",
+ source_type: "twitter_handle",
+ kind: "source",
+ status: "error",
+ error: "Stakwork dispatch timeout",
+ finished_at: Math.floor(Date.now() / 1000) - 60,
+ }
+ vi.mocked(getCronRuns).mockResolvedValue({ runs: [mockRun] })
+
+ render( {}} />)
+ await waitFor(() => screen.getByText("Twitter Handle"))
+
+ const cardButtons = screen
+ .getAllByRole("button")
+ .filter((b) => b.hasAttribute("aria-expanded"))
+ await userEvent.click(cardButtons[0])
+
+ await waitFor(() => {
+ expect(screen.getByText("Stakwork dispatch timeout")).toBeInTheDocument()
+ })
+ })
+
+ it("re-expanding a card does not call getCronRuns again (cache hit)", async () => {
+ const { getWorkflowMarketplace, getCronRuns } = await import("@/lib/graph-api")
+ vi.mocked(getWorkflowMarketplace).mockResolvedValue(MOCK_ITEMS)
+ vi.mocked(getCronRuns).mockResolvedValue({ runs: [] })
+
+ render( {}} />)
+ await waitFor(() => screen.getByText("Twitter Handle"))
+
+ const cardButtons = screen
+ .getAllByRole("button")
+ .filter((b) => b.hasAttribute("aria-expanded"))
+
+ // Expand
+ await userEvent.click(cardButtons[0])
+ await waitFor(() => expect(document.querySelector("[data-testid='runs-section']")).toBeTruthy())
+
+ // Collapse
+ await userEvent.click(cardButtons[0])
+ await waitFor(() => expect(document.querySelector("[data-testid='runs-section']")).toBeNull())
+
+ // Re-expand
+ await userEvent.click(cardButtons[0])
+ await waitFor(() => expect(document.querySelector("[data-testid='runs-section']")).toBeTruthy())
+
+ // getCronRuns should have been called only once — cache used on re-expand
+ expect(vi.mocked(getCronRuns)).toHaveBeenCalledTimes(1)
+ })
+ })
})
diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts
index 24a5b29..3784815 100644
--- a/src/lib/graph-api.ts
+++ b/src/lib/graph-api.ts
@@ -372,6 +372,7 @@ export interface StakworkRun {
created_at?: number
started_at?: number
finished_at?: number
+ project_id?: string | number
}
export async function getCronConfig(
diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts
index b478879..666efd4 100644
--- a/src/lib/mock-data.ts
+++ b/src/lib/mock-data.ts
@@ -786,6 +786,7 @@ export const MOCK_STAKWORK_RUNS: StakworkRun[] = [
created_at: mockRunTs(120),
started_at: mockRunTs(119),
finished_at: mockRunTs(110),
+ project_id: 123456,
},
{
ref_id: "run-002",
@@ -833,6 +834,7 @@ export const MOCK_STAKWORK_RUNS: StakworkRun[] = [
trigger: "MANUAL",
status: "pending",
created_at: mockRunTs(1),
+ project_id: 789012,
},
{
ref_id: "mock-run-cr-1",