Skip to content
Open
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
173 changes: 163 additions & 10 deletions src/components/layout/workflows-panel.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
<div className="flex flex-col gap-0.5 py-1.5 border-b border-border/40 last:border-0">
<div className="flex items-center gap-2">
<RunStatusBadge status={run.status} />
<span className="text-[11px] text-muted-foreground flex-1">
{formatDateRelative(timestamp, "Never")}
</span>
{run.project_id && (
<a
href={`https://jobs.stakwork.com/admin/projects/${run.project_id}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="View on Stakwork"
data-testid="stakwork-link"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
{run.error && (
<p className="text-[10px] text-destructive/70 pl-0.5">{run.error}</p>
)}
</div>
)
}

function WorkflowRunsSection({
item,
runsCache,
}: {
item: WorkflowMarketplaceItem
runsCache: React.MutableRefObject<Record<string, StakworkRun[]>>
}) {
const [runs, setRuns] = useState<StakworkRun[] | null>(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 (
<div className="px-3 pb-2 pt-1 bg-muted/10 border-t border-border/40" data-testid="runs-section">
{loading ? (
<div className="flex items-center justify-center py-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : !runs || runs.length === 0 ? (
<p className="text-[11px] text-muted-foreground py-2 text-center">No runs yet</p>
) : (
<div className="flex flex-col">
{runs.map((run) => (
<WorkflowRunRow key={run.ref_id} run={run} />
))}
</div>
)}
</div>
)
}

function WorkflowCard({
item,
isAdmin,
isExpanded,
onToggle,
runsCache,
}: {
item: WorkflowMarketplaceItem
isAdmin?: boolean
isExpanded?: boolean
onToggle?: () => void
runsCache?: React.MutableRefObject<Record<string, StakworkRun[]>>
}) {
const meta = WORKFLOW_TYPE_META[item.source_type]
const Icon = meta?.icon ?? Cpu
const tone = meta?.tone ?? "slate"
const displayName = getWorkflowDisplayName(item)

return (
<div className="rounded-xl border border-border/60 bg-card/40 px-3 py-3 hover:bg-card/60 hover:border-border transition-colors flex items-center gap-3">
const cardHeader = (
<>
{/* Tinted icon tile */}
<div
className={cn(
Expand All @@ -48,12 +161,12 @@ function WorkflowCard({ item }: { item: WorkflowMarketplaceItem }) {
<Icon className="h-4 w-4" data-testid={`workflow-icon-${item.source_type}`} />
</div>

{/* Name — single source of truth, no duplicate sub-text */}
{/* Name */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{displayName}</p>
</div>

{/* Kind chip + enabled dot */}
{/* Kind chip + enabled dot + optional chevron */}
<div className="flex items-center gap-1.5 shrink-0">
<span
className={cn(
Expand All @@ -73,7 +186,35 @@ function WorkflowCard({ item }: { item: WorkflowMarketplaceItem }) {
aria-label={item.enabled ? "Enabled" : "Disabled"}
data-testid={item.enabled ? "dot-enabled" : "dot-disabled"}
/>
{isAdmin && (
isExpanded
? <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
: <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
)}
</div>
</>
)

if (isAdmin) {
return (
<div className="rounded-xl border border-border/60 bg-card/40 hover:border-border transition-colors flex flex-col overflow-hidden">
<button
onClick={onToggle}
className="flex items-center gap-3 px-3 py-3 cursor-pointer w-full text-left hover:bg-card/60 transition-colors"
aria-expanded={isExpanded}
>
{cardHeader}
</button>
{isExpanded && runsCache && (
<WorkflowRunsSection item={item} runsCache={runsCache} />
)}
</div>
)
}

return (
<div className="rounded-xl border border-border/60 bg-card/40 px-3 py-3 hover:bg-card/60 hover:border-border transition-colors flex items-center gap-3">
{cardHeader}
</div>
)
}
Expand All @@ -82,6 +223,9 @@ export function WorkflowsPanel({ onClose }: { onClose: () => void }) {
const [workflows, setWorkflows] = useState<WorkflowMarketplaceItem[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<FilterKind>("all")
const [expandedId, setExpandedId] = useState<string | null>(null)
const runsCache = useRef<Record<string, StakworkRun[]>>({})
const isAdmin = useUserStore((s) => s.isAdmin)

useEffect(() => {
let cancelled = false
Expand Down Expand Up @@ -167,7 +311,16 @@ export function WorkflowsPanel({ onClose }: { onClose: () => void }) {
) : (
<div className="py-2 px-3 flex flex-col gap-2">
{filtered.map((item) => (
<WorkflowCard key={item.ref_id} item={item} />
<WorkflowCard
key={item.ref_id}
item={item}
isAdmin={isAdmin}
isExpanded={expandedId === item.ref_id}
onToggle={() =>
setExpandedId((prev) => (prev === item.ref_id ? null : item.ref_id))
}
runsCache={runsCache}
/>
))}
</div>
)}
Expand Down
22 changes: 1 addition & 21 deletions src/components/modals/janitor-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<StakworkRun["status"], string> = {
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 (
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${colours[status]}`}>
{status}
</span>
)
}
import { RunStatusBadge } from "@/components/ui/run-status-badge"

export function JanitorSettings({ open }: { open: boolean }) {
const [configs, setConfigs] = useState<CronConfig[] | null>(null)
Expand Down
24 changes: 24 additions & 0 deletions src/components/ui/run-status-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"use client"

import type { StakworkRun } from "@/lib/graph-api"

export function RunStatusBadge({ status }: { status: StakworkRun["status"] }) {
const colours: Record<StakworkRun["status"], string> = {
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 (
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${colours[status]}`}>
{status}
</span>
)
}
Loading
Loading