diff --git a/app/(oss)/orchestration/page.tsx b/app/(oss)/orchestration/page.tsx new file mode 100644 index 0000000..776d434 --- /dev/null +++ b/app/(oss)/orchestration/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { OrchestrationLayout } from "@/app/components/OrchestrationLayout"; +import { OrchestrationHome } from "@/app/components/OrchestrationHome"; + +export default function OrchestrationsPage() { + return ( + + + + ); +} \ No newline at end of file diff --git a/app/(oss)/orchestration/plans/[planId]/page.tsx b/app/(oss)/orchestration/plans/[planId]/page.tsx new file mode 100644 index 0000000..f047c24 --- /dev/null +++ b/app/(oss)/orchestration/plans/[planId]/page.tsx @@ -0,0 +1,17 @@ +'use client'; + +import React from 'react'; +import { useParams } from 'next/navigation'; +import { OrchestrationLayout } from '@/app/components/OrchestrationLayout'; +import { PlanDetail } from '@/app/components/PlanDetail'; + +export default function PlanDetailPage() { + const params = useParams(); + const planId = params.planId as string; + + return ( + + + + ); +} \ No newline at end of file diff --git a/app/(oss)/orchestration/plans/page.tsx b/app/(oss)/orchestration/plans/page.tsx new file mode 100644 index 0000000..ea77c93 --- /dev/null +++ b/app/(oss)/orchestration/plans/page.tsx @@ -0,0 +1,16 @@ +'use client'; + +import React from 'react'; +import { OrchestrationLayout } from '@/app/components/OrchestrationLayout'; +import { PlanBrowser } from '@/app/components/PlanBrowser'; + +export default function PlansPage() { + return ( + + + + ); +} \ No newline at end of file diff --git a/app/(oss)/orchestration/runs/[runId]/page.tsx b/app/(oss)/orchestration/runs/[runId]/page.tsx new file mode 100644 index 0000000..43bcba2 --- /dev/null +++ b/app/(oss)/orchestration/runs/[runId]/page.tsx @@ -0,0 +1,17 @@ +'use client'; + +import React from 'react'; +import { useParams } from 'next/navigation'; +import { OrchestrationLayout } from '@/app/components/OrchestrationLayout'; +import { RunDetail } from '@/app/components/RunDetail'; + +export default function RunDetailPage() { + const params = useParams(); + const runId = params.runId as string; + + return ( + + + + ); +} \ No newline at end of file diff --git a/app/(oss)/orchestration/runs/page.tsx b/app/(oss)/orchestration/runs/page.tsx new file mode 100644 index 0000000..1b03902 --- /dev/null +++ b/app/(oss)/orchestration/runs/page.tsx @@ -0,0 +1,65 @@ +'use client'; + +import React, { Suspense } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { OrchestrationLayout } from '@/app/components/OrchestrationLayout'; +import { RunBrowser } from '@/app/components/RunBrowser'; +import { RunQuery, RunStatus } from '@/app/lib/types'; +import { parseScope } from '@/app/lib/scope'; + +function RunsPageContent() { + const searchParams = useSearchParams(); + + // Parse initial query from URL parameters + const initialQuery: Partial = {}; + + const statusParam = searchParams.get('status') || searchParams.get('statuses'); + if (statusParam) { + const statuses = statusParam.split(',') as RunStatus[]; + initialQuery.statuses = statuses; + } + + const planIdParam = searchParams.get('planId') || searchParams.get('planIds'); + if (planIdParam) { + initialQuery.planIds = planIdParam.split(',').filter(Boolean); + } + + const limitParam = searchParams.get('limit'); + if (limitParam) { + const limit = Number(limitParam); + if (!Number.isNaN(limit) && limit > 0) { + initialQuery.limit = limit; + } + } + + const scopeParam = searchParams.get('scope'); + const parsedScope = parseScope(scopeParam); + const service = searchParams.get('service'); + const environment = searchParams.get('environment'); + const team = searchParams.get('team'); + + if (parsedScope) { + initialQuery.scope = parsedScope; + } else if (service || environment || team) { + initialQuery.scope = { + service: service || undefined, + environment: environment || undefined, + team: team || undefined, + }; + } + + return ; +} + +export default function RunsPage() { + return ( + + Loading...}> + + + + ); +} diff --git a/app/components/(enterprise)/copilot/CollapsibleCodeBlock.tsx b/app/components/(enterprise)/copilot/CollapsibleCodeBlock.tsx index 4cd001d..bdcd9bd 100644 --- a/app/components/(enterprise)/copilot/CollapsibleCodeBlock.tsx +++ b/app/components/(enterprise)/copilot/CollapsibleCodeBlock.tsx @@ -1,6 +1,5 @@ -import React, { useState } from "react"; +import { useState } from "react"; import { CodeBlock } from "@/app/lib/ui"; -import { trace } from "next/dist/trace"; export function CollapsibleCodeBlock({ code, @@ -16,13 +15,13 @@ export function CollapsibleCodeBlock({ const [isExpanded, setIsExpanded] = useState(defaultOpen); return ( -
+
+
+ )} +
+ ); +} diff --git a/app/components/PlanCard.tsx b/app/components/PlanCard.tsx new file mode 100644 index 0000000..42c4e6b --- /dev/null +++ b/app/components/PlanCard.tsx @@ -0,0 +1,122 @@ +"use client"; + +import Link from "next/link"; +import { OrchestrationPlan } from "@/app/lib/types"; + +interface PlanCardProps { + plan: OrchestrationPlan; +} + +export function PlanCard({ plan }: PlanCardProps) { + // Get step type counts for display + const stepTypeCounts = plan.steps.reduce((acc, step) => { + acc[step.type] = (acc[step.type] || 0) + 1; + return acc; + }, {} as Record); + + // Get plan type from tags for styling + const planType = plan.tags.type || 'workflow'; + + // Color scheme based on plan type + const typeColors = { + playbook: { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-700', badge: 'bg-red-100 text-red-800' }, + runbook: { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', badge: 'bg-blue-100 text-blue-800' }, + 'release-checklist': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-700', badge: 'bg-green-100 text-green-800' }, + workflow: { bg: 'bg-slate-50', border: 'border-slate-200', text: 'text-slate-700', badge: 'bg-slate-100 text-slate-800' }, + }; + + const colors = typeColors[planType as keyof typeof typeColors] || typeColors.workflow; + + return ( + + {/* Header */} +
+
+

+ {plan.title} +

+

+ {plan.description} +

+
+
+ + {/* Step Count and Type Breakdown */} +
+
+
+ + + +
+ + {plan.steps.length} step{plan.steps.length !== 1 ? 's' : ''} + +
+ + {/* Manual steps indicator */} + {stepTypeCounts.manual > 0 && ( +
+ + + + + {stepTypeCounts.manual} manual + +
+ )} +
+ + {/* Tags */} +
+ {plan.tags.type && ( + + {plan.tags.type} + + )} + {Object.entries(plan.tags) + .filter(([key]) => key !== 'type') + .slice(0, 3) + .map(([key, value]) => ( + + {key}: {value} + + ))} + {Object.keys(plan.tags).length > 4 && ( + + +{Object.keys(plan.tags).length - 4} more + + )} +
+ + {/* Step Type Summary */} +
+ {Object.entries(stepTypeCounts).map(([type, count]) => ( + +
+ {count} {type} + + ))} +
+ + {/* Version info if available */} + {plan.version && ( +
+ Version: {plan.version} +
+ )} + + ); +} diff --git a/app/components/PlanDetail.tsx b/app/components/PlanDetail.tsx new file mode 100644 index 0000000..1893583 --- /dev/null +++ b/app/components/PlanDetail.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useAsyncState } from '@/app/lib/hooks'; +import { getPlan, startRun } from '@/app/lib/orchestration'; +import { WorkflowVisualizer } from './WorkflowVisualizer'; +import { MarkdownText } from './MarkdownText'; +import { OrchestrationPlan } from '@/app/lib/types'; + +interface PlanDetailProps { + planId: string; +} + +export function PlanDetail({ planId }: PlanDetailProps) { + const [plan, setPlan] = useState(null); + const planState = useAsyncState(); + const { start, succeed, fail } = planState; + const [isStartingRun, setIsStartingRun] = useState(false); + const [startRunError, setStartRunError] = useState(null); + + // Load plan data + useEffect(() => { + const loadPlan = async () => { + start(); + try { + const planData = await getPlan(planId); + setPlan(planData); + succeed(); + } catch (error) { + fail(error); + } + }; + + loadPlan(); + }, [planId, start, succeed, fail]); + + const handleStartRun = async () => { + if (!plan) return; + + setIsStartingRun(true); + setStartRunError(null); + + try { + const run = await startRun(plan.id); + // Navigate to the run detail page + window.location.href = `/orchestration/runs/${run.id}`; + } catch (error) { + setStartRunError(error instanceof Error ? error.message : 'Failed to start run'); + } finally { + setIsStartingRun(false); + } + }; + + const handleStepClick = (stepId: string) => { + console.log('Step clicked:', stepId); + // TODO: Show step details in a modal or sidebar + }; + + if (planState.loading) { + return ( +
+
Loading plan...
+
+ ); + } + + if (planState.error) { + return ( +
+
Error loading plan
+
{planState.error}
+
+ ); + } + + if (!plan) { + return ( +
+
Plan not found
+
+ The requested plan could not be found. +
+
+ ); + } + + return ( +
+ {/* Plan Header */} +
+
+
+

+ {plan.title} +

+ + {plan.description && ( +

+ {plan.description} +

+ )} +
+ +
+ + + {startRunError && ( +
+ {startRunError} +
+ )} +
+
+
+ Steps: {plan.steps.length} +
+ {plan.version && ( +
+ Version: {plan.version} +
+ )} + {Object.keys(plan.tags).length > 0 && ( +
+ Tags:{' '} + {Object.entries(plan.tags).map(([key, value]) => ( + + {key}: {value} + + ))} +
+ )} + +
+
+
+ + {/* Workflow Visualizer */} +
+

+ Workflow Steps +

+ + +
+ + {/* Step Details */} +
+

+ Step Details +

+ +
+ {plan.steps.map((step) => ( +
+
+
+
+ + + {step.type.charAt(0).toUpperCase()} + + {step.type} + +
+ +

+ {step.title} +

+ + {step.description && ( +

+ +

+ )} +
+
+
+ Step Id +
+
{step.id}
+
+
+ + {step.dependsOn && step.dependsOn.length > 0 && ( +
+ Depends on + {step.dependsOn.map(dep => ( + + {dep} + + ))} +
+ )} +
+ ))} +
+
+
+ ); +} diff --git a/app/components/PlanFilters.tsx b/app/components/PlanFilters.tsx new file mode 100644 index 0000000..f7135bf --- /dev/null +++ b/app/components/PlanFilters.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState } from "react"; +import { PlanQuery } from "@/app/lib/types"; + +interface PlanFiltersProps { + onFilterChange: (filters: Partial) => void; + loading?: boolean; +} + +export function PlanFilters({ onFilterChange, loading }: PlanFiltersProps) { + const [query, setQuery] = useState(""); + const [selectedType, setSelectedType] = useState(""); + const [showAdvanced, setShowAdvanced] = useState(false); + const [service, setService] = useState(""); + const [team, setTeam] = useState(""); + const [environment, setEnvironment] = useState(""); + + const handleSearch = () => { + const filters: Partial = { + query: query || undefined, + tags: selectedType ? { type: selectedType } : undefined, + scope: (service || team || environment) ? { + service: service || undefined, + team: team || undefined, + environment: environment || undefined, + } : undefined, + }; + + onFilterChange(filters); + }; + + const handleReset = () => { + setQuery(""); + setSelectedType(""); + setService(""); + setTeam(""); + setEnvironment(""); + onFilterChange({}); + }; + + const planTypes = [ + { value: "", label: "All Types" }, + { value: "playbook", label: "Playbooks" }, + { value: "runbook", label: "Runbooks" }, + { value: "release-checklist", label: "Release Checklists" }, + ]; + + return ( +
+
+

Search Plans

+ +
+ +
+ {/* Basic Search */} +
+
+ + setQuery(e.target.value)} + placeholder="Search plan title or description..." + className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-[#55cfd0] focus:outline-none focus:ring-1 focus:ring-[#55cfd0]" + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> +
+ +
+ + +
+ +
+ + +
+
+ + {/* Advanced Filters */} + {showAdvanced && ( +
+

Scope Filters

+
+
+ + setService(e.target.value)} + placeholder="Filter by service..." + className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-[#55cfd0] focus:outline-none focus:ring-1 focus:ring-[#55cfd0]" + /> +
+ +
+ + setTeam(e.target.value)} + placeholder="Filter by team..." + className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-[#55cfd0] focus:outline-none focus:ring-1 focus:ring-[#55cfd0]" + /> +
+ +
+ + setEnvironment(e.target.value)} + placeholder="Filter by environment..." + className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-[#55cfd0] focus:outline-none focus:ring-1 focus:ring-[#55cfd0]" + /> +
+
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/components/PlanGrid.tsx b/app/components/PlanGrid.tsx new file mode 100644 index 0000000..d6e1934 --- /dev/null +++ b/app/components/PlanGrid.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { OrchestrationPlan } from "@/app/lib/types"; +import { PlanCard } from "@/app/components/PlanCard"; + +interface PlanGridProps { + plans: OrchestrationPlan[]; + loading?: boolean; + error?: string; +} + +export function PlanGrid({ plans, loading, error }: PlanGridProps) { + if (loading) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+ ); + } + + if (error) { + return ( +
+
+ + + +
+

Error Loading Plans

+

{error}

+ +
+ ); + } + + if (plans.length === 0) { + return ( +
+
+ + + +
+

No Plans Found

+

+ No orchestration plans match your current filters. +

+

+ Try adjusting your search criteria or check back later for new plans. +

+
+ ); + } + + return ( +
+ {plans.map((plan) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/app/components/QuickActions.tsx b/app/components/QuickActions.tsx new file mode 100644 index 0000000..449249c --- /dev/null +++ b/app/components/QuickActions.tsx @@ -0,0 +1,39 @@ +"use client"; + +import Link from "next/link"; + +export function QuickActions() { + return ( +
+ +
+
+ + + +
+

Browse Plans

+
+

Discover available operational workflows

+ + + +
+
+ + + +
+

Active Runs

+
+

Monitor running workflows and complete manual steps

+ +
+ ); +} \ No newline at end of file diff --git a/app/components/RecentPlans.tsx b/app/components/RecentPlans.tsx new file mode 100644 index 0000000..9223f68 --- /dev/null +++ b/app/components/RecentPlans.tsx @@ -0,0 +1,119 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { useAsyncState } from "@/app/lib/hooks"; +import { queryPlans } from "@/app/lib/orchestration"; +import { OrchestrationPlan } from "@/app/lib/types"; + +export function RecentPlans() { + const [plans, setPlans] = useState([]); + const asyncState = useAsyncState(); + const { start, succeed, fail } = asyncState; + + useEffect(() => { + const loadPlans = async () => { + start(); + try { + const result = await queryPlans({ limit: 5 }); // Get recent plans + setPlans(result || []); + succeed(); + } catch (err) { + fail(err); + } + }; + + loadPlans(); + }, [start, succeed, fail]); + + if (asyncState.loading) { + return ( +
+

Recent Plans

+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+
+ ); + } + + if (asyncState.error) { + return ( +
+

Recent Plans

+

Failed to load recent plans

+
+ ); + } + + if (plans.length === 0) { + return ( +
+

Recent Plans

+
+
+ + + +
+

No plans available

+ + Browse Plans → + +
+
+ ); + } + + return ( +
+
+

Recent Plans

+ + View All → + +
+
+ {plans.map((plan) => ( + +
+
+

+ {plan.title} +

+

+ {plan.description} +

+
+ + {plan.steps.length} steps + + {plan.tags.type && ( + + {plan.tags.type} + + )} +
+
+
+ + ))} +
+
+ ); +} \ No newline at end of file diff --git a/app/components/RunBrowser.tsx b/app/components/RunBrowser.tsx new file mode 100644 index 0000000..ed4d6e2 --- /dev/null +++ b/app/components/RunBrowser.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import { useAsyncState } from '@/app/lib/hooks'; +import { queryRuns } from '@/app/lib/orchestration'; +import { OrchestrationRun, RunQuery } from '@/app/lib/types'; +import { RunFilters } from './RunFilters'; +import { RunList } from './RunList'; +import { Pagination } from './Pagination'; +import { serializeScope } from '@/app/lib/scope'; + +interface RunBrowserProps { + initialQuery?: Partial; +} + +export function RunBrowser({ initialQuery }: RunBrowserProps) { + const [runs, setRuns] = useState([]); + const [query, setQuery] = useState>(initialQuery || {}); + const [currentPage, setCurrentPage] = useState(0); + const runState = useAsyncState(); + const { start, succeed, fail } = runState; + const router = useRouter(); + const pathname = usePathname(); + const pageSize = 10; + + // Load runs when query changes + useEffect(() => { + const loadRuns = async () => { + start(); + try { + const runsData = await queryRuns(query); + setRuns(runsData); + succeed(); + } catch (error) { + fail(error); + setRuns([]); + } + }; + + loadRuns(); + }, [query, start, succeed, fail]); + + const handleQueryChange = (newQuery: Partial) => { + setQuery(newQuery); + setCurrentPage(0); + + const params = new URLSearchParams(); + if (newQuery.statuses && newQuery.statuses.length > 0) { + params.set('status', newQuery.statuses.join(',')); + } + if (newQuery.planIds && newQuery.planIds.length > 0) { + params.set('planId', newQuery.planIds.join(',')); + } + if (newQuery.limit) { + params.set('limit', String(newQuery.limit)); + } + const serializedScope = serializeScope(newQuery.scope); + if (serializedScope) { + params.set('scope', serializedScope); + } + + const search = params.toString(); + router.replace(search ? `${pathname}?${search}` : pathname); + }; + + const handleRunClick = (runId: string) => { + window.location.href = `/orchestration/runs/${runId}`; + }; + + const pagedRuns = runs.slice(currentPage * pageSize, (currentPage + 1) * pageSize); + + return ( +
+ {/* Filters */} + + + {/* Results Header */} +
+
+

+ Workflow Runs +

+

+ {runState.loading ? 'Loading runs...' : `${runs.length} run${runs.length !== 1 ? 's' : ''} found`} +

+
+
+ + {/* Results */} +
+ + +
+
+ ); +} diff --git a/app/components/RunDetail.tsx b/app/components/RunDetail.tsx new file mode 100644 index 0000000..ce8f859 --- /dev/null +++ b/app/components/RunDetail.tsx @@ -0,0 +1,357 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useAsyncState } from '@/app/lib/hooks'; +import { getRun, getPlan } from '@/app/lib/orchestration'; +import { WorkflowVisualizer } from './WorkflowVisualizer'; +import { MarkdownText } from './MarkdownText'; +import { StepCompletionModal } from './StepCompletionModal'; +import { OrchestrationRun, OrchestrationPlan, OrchestrationStepState } from '@/app/lib/types'; +import { formatDate } from '@/app/lib/utils'; + +interface RunDetailProps { + runId: string; +} + +export function RunDetail({ runId }: RunDetailProps) { + const [run, setRun] = useState(null); + const [plan, setPlan] = useState(null); + const [selectedStepId, setSelectedStepId] = useState(null); + const [isCompletionModalOpen, setIsCompletionModalOpen] = useState(false); + + const runState = useAsyncState(); + const planState = useAsyncState(); + const { start: startRun, succeed: succeedRun, fail: failRun } = runState; + const { start: startPlan, succeed: succeedPlan, fail: failPlan } = planState; + + // Load run data + useEffect(() => { + const loadRun = async () => { + startRun(); + try { + const runData = await getRun(runId); + setRun(runData); + succeedRun(); + + // Load the associated plan + if (runData.planId) { + startPlan(); + try { + const planData = await getPlan(runData.planId); + setPlan(planData); + succeedPlan(); + } catch (error) { + failPlan(error); + } + } + } catch (error) { + failRun(error); + } + }; + + loadRun(); + }, [runId, startRun, succeedRun, failRun, startPlan, succeedPlan, failPlan]); + + const getRunStepStates = (currentRun: OrchestrationRun): OrchestrationStepState[] => { + const directStates = currentRun.stepStates; + const fallbackStates = (currentRun as unknown as { steps?: OrchestrationStepState[] }).steps; + return directStates && directStates.length > 0 ? directStates : fallbackStates || []; + }; + + const handleStepClick = (stepId: string) => { + if (!run || !plan) return; + + const stepState = getRunStepStates(run).find(s => s.stepId === stepId); + const step = plan.steps.find(s => s.id === stepId); + const status = normalizeStepStatus(stepState?.status); + + // Allow manual steps to be completed when they are running or ready. + const completableTypes = new Set(['manual']); + if (step && completableTypes.has(step.type) && (status === 'running' || status === 'ready')) { + setSelectedStepId(stepId); + setIsCompletionModalOpen(true); + } + }; + + const handleStepCompleted = () => { + // Reload the run data to get updated step states + const loadRun = async () => { + try { + const runData = await getRun(runId); + setRun(runData); + } catch (error) { + console.error('Failed to reload run:', error); + } + }; + + loadRun(); + setIsCompletionModalOpen(false); + setSelectedStepId(null); + }; + + const getRunStatusColor = (status: string): string => { + switch (status) { + case 'completed': + return 'text-green-600 bg-green-100'; + case 'failed': + return 'text-red-600 bg-red-100'; + case 'running': + return 'text-blue-600 bg-blue-100'; + case 'blocked': + return 'text-amber-600 bg-amber-100'; + case 'created': + return 'text-gray-600 bg-gray-100'; + case 'cancelled': + return 'text-gray-600 bg-gray-100'; + default: + return 'text-gray-600 bg-gray-100'; + } + }; + + const normalizeStepStatus = (status?: string): string => { + if (!status) return 'pending'; + const normalized = status.toLowerCase(); + switch (normalized) { + case 'completed': + case 'complete': + return 'succeeded'; + case 'in_progress': + case 'in-progress': + case 'inprogress': + return 'running'; + default: + return normalized; + } + }; + + const calculateProgress = (): { completed: number; total: number; percentage: number } => { + if (!run) return { completed: 0, total: 0, percentage: 0 }; + const runStepStates = getRunStepStates(run); + if (runStepStates.length === 0) return { completed: 0, total: 0, percentage: 0 }; + + const total = runStepStates.length; + const completed = runStepStates.filter(step => step.status === 'succeeded').length; + const percentage = total > 0 ? Math.round((completed / total) * 100) : 0; + return { completed, total, percentage }; + }; + + if (runState.loading) { + return ( +
+
Loading run...
+
+ ); + } + + if (runState.error) { + return ( +
+
Error loading run
+
{runState.error}
+
+ ); + } + + if (!run) { + return ( +
+
Run not found
+
+ The requested run could not be found. +
+
+ ); + } + + const progress = calculateProgress(); + const runStepStates = run ? getRunStepStates(run) : []; + + return ( +
+ {/* Run Header */} +
+
+
+
+

+ Run {run.id} +

+ + {run.status.charAt(0).toUpperCase() + run.status.slice(1)} + +
+ +
+
+ Plan: +
{run.planId}
+
+
+ Progress: +
+ {progress.completed}/{progress.total} steps ({progress.percentage}%) +
+
+
+ Created: +
{formatDate(run.createdAt)}
+
+
+ Updated: +
{formatDate(run.updatedAt)}
+
+ {plan?.url && ( + + )} +
+ + {/* Progress Bar */} +
+
+
+
+
+ + {run.url && ( + + )} +
+
+
+ + {/* Workflow Visualizer */} + {plan && ( +
+

+ Workflow Progress +

+ + +
+ )} + + {/* Step Timeline */} +
+

+ Step Timeline +

+ +
+ {runStepStates.map((stepState, index) => { + const step = plan?.steps.find(s => s.id === stepState.stepId); + if (!step) return null; + const normalizedStatus = normalizeStepStatus(stepState.status); + + return ( +
+
+ {index + 1} +
+ +
+
+

{step.title}

+ + {normalizedStatus} + + + {step.type} + +
+ + {step.description && ( +

+ +

+ )} + +
+ {stepState.startedAt && ( +
Started: {formatDate(stepState.startedAt)}
+ )} + {stepState.finishedAt && ( +
Finished: {formatDate(stepState.finishedAt)}
+ )} + {stepState.actor && ( +
Actor: {stepState.actor}
+ )} +
+ + {stepState.note && ( +
+ Note: {stepState.note} +
+ )} +
+
+ ); + })} +
+
+ + {/* Step Completion Modal */} + {isCompletionModalOpen && selectedStepId && plan && ( + s.id === selectedStepId)!} + onClose={() => setIsCompletionModalOpen(false)} + onCompleted={handleStepCompleted} + /> + )} +
+ ); +} diff --git a/app/components/RunFilters.tsx b/app/components/RunFilters.tsx new file mode 100644 index 0000000..21f1dfd --- /dev/null +++ b/app/components/RunFilters.tsx @@ -0,0 +1,198 @@ +'use client'; + +import React, { useState } from 'react'; +import { queryPlans } from '@/app/lib/orchestration'; +import { RunQuery, RunStatus, QueryScope } from '@/app/lib/types'; + +interface RunFiltersProps { + query: Partial; + onQueryChange: (query: Partial) => void; +} + +const RUN_STATUSES: RunStatus[] = ['created', 'running', 'blocked', 'completed', 'failed', 'cancelled']; + +export function RunFilters({ query, onQueryChange }: RunFiltersProps) { + const [selectedStatuses, setSelectedStatuses] = useState(query.statuses || []); + const [planIds, setPlanIds] = useState(query.planIds || []); + const [planSearch, setPlanSearch] = useState(''); + const [scope, setScope] = useState(query.scope || {}); + const [showAdvanced, setShowAdvanced] = useState(Boolean(query.scope)); + + const handleSearch = async () => { + let resolvedPlanIds = planIds; + if (planSearch.trim()) { + try { + const matches = await queryPlans({ query: planSearch.trim(), limit: 10 }); + resolvedPlanIds = matches.map(plan => plan.id); + } catch { + resolvedPlanIds = []; + } + } + + if (resolvedPlanIds !== planIds) { + setPlanIds(resolvedPlanIds); + } + + const newQuery: Partial = { + ...query, + statuses: selectedStatuses.length > 0 ? selectedStatuses : undefined, + planIds: resolvedPlanIds.length > 0 ? resolvedPlanIds : undefined, + scope: Object.keys(scope).length > 0 ? scope : undefined, + }; + onQueryChange(newQuery); + }; + + const handlePlanIdRemove = (planId: string) => { + setPlanIds(planIds.filter(id => id !== planId)); + }; + + const handleScopeChange = (field: keyof QueryScope, value: string) => { + setScope({ + ...scope, + [field]: value || undefined, + }); + }; + + const handleClear = () => { + setSelectedStatuses([]); + setPlanIds([]); + setPlanSearch(''); + setScope({}); + onQueryChange({}); + }; + + return ( +
+
+

Search Runs

+ +
+ +
+
+ + +
+ +
+ +
+ setPlanSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + placeholder="Search by plan name..." + className="flex-1 rounded-lg border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-[#55cfd0] focus:outline-none focus:ring-1 focus:ring-[#55cfd0]" + /> +
+
+ +
+ + +
+
+ + {planIds.length > 0 && ( +
+ Filtered plans: + {planIds.map(planId => ( + + {planId} + + + ))} +
+ )} + + {showAdvanced && ( +
+

Scope Filters

+
+
+ + handleScopeChange('service', e.target.value)} + placeholder="Filter by service..." + className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-[#55cfd0] focus:outline-none focus:ring-1 focus:ring-[#55cfd0]" + /> +
+ +
+ + handleScopeChange('team', e.target.value)} + placeholder="Filter by team..." + className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-[#55cfd0] focus:outline-none focus:ring-1 focus:ring-[#55cfd0]" + /> +
+ +
+ + handleScopeChange('environment', e.target.value)} + placeholder="Filter by environment..." + className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm placeholder-slate-400 focus:border-[#55cfd0] focus:outline-none focus:ring-1 focus:ring-[#55cfd0]" + /> +
+
+
+ )} +
+ ); +} diff --git a/app/components/RunItem.tsx b/app/components/RunItem.tsx new file mode 100644 index 0000000..5a32298 --- /dev/null +++ b/app/components/RunItem.tsx @@ -0,0 +1,174 @@ +'use client'; + +import React from 'react'; +import { OrchestrationRun, OrchestrationStepState, RunStatus } from '@/app/lib/types'; +import { formatDate } from '@/app/lib/utils'; + +interface RunItemProps { + run: OrchestrationRun; + onClick: () => void; +} + +export function RunItem({ run, onClick }: RunItemProps) { + const getStatusColor = (status: RunStatus): string => { + switch (status) { + case 'completed': + return 'bg-green-100 text-green-800 border-green-200'; + case 'failed': + return 'bg-red-100 text-red-800 border-red-200'; + case 'running': + return 'bg-blue-100 text-blue-800 border-blue-200'; + case 'blocked': + return 'bg-amber-100 text-amber-800 border-amber-200'; + case 'created': + return 'bg-gray-100 text-gray-800 border-gray-200'; + case 'cancelled': + return 'bg-gray-100 text-gray-800 border-gray-200'; + default: + return 'bg-gray-100 text-gray-800 border-gray-200'; + } + }; + + const getStatusIcon = (status: RunStatus): string => { + switch (status) { + case 'completed': + return '✓'; + case 'failed': + return '✗'; + case 'running': + return '⟳'; + case 'blocked': + return '⚠'; + case 'created': + return '○'; + case 'cancelled': + return '⏹'; + default: + return '○'; + } + }; + + const getRunStepStates = (currentRun: OrchestrationRun): OrchestrationStepState[] => { + const directStates = currentRun.stepStates; + const fallbackStates = (currentRun as unknown as { steps?: OrchestrationStepState[] }).steps; + return directStates && directStates.length > 0 ? directStates : fallbackStates || []; + }; + + const normalizeStepStatus = (status?: string): string => { + if (!status) return 'pending'; + const normalized = status.toLowerCase(); + switch (normalized) { + case 'completed': + case 'complete': + return 'succeeded'; + case 'in_progress': + case 'in-progress': + case 'inprogress': + return 'running'; + default: + return normalized; + } + }; + + const calculateProgress = (): { completed: number; total: number; percentage: number } => { + const stepStates = getRunStepStates(run); + const total = stepStates.length; + const completed = stepStates.filter(step => normalizeStepStatus(step.status) === 'succeeded').length; + const percentage = total > 0 ? Math.round((completed / total) * 100) : 0; + return { completed, total, percentage }; + }; + + const progress = calculateProgress(); + const isBlocked = run.status === 'blocked'; + + return ( +
+
+
+ {/* Run Header */} +
+
+ + {run.id} + + + {getStatusIcon(run.status)} + {run.status.charAt(0).toUpperCase() + run.status.slice(1)} + +
+ + {isBlocked && ( + + + + + Needs Attention + + )} +
+ + {/* Plan Information */} + + + {/* Progress Bar */} +
+
+ Progress + {progress.completed}/{progress.total} steps ({progress.percentage}%) +
+
+
+
+
+ + {/* Timestamps */} +
+
+ Created: {formatDate(run.createdAt)} +
+
+ Updated: {formatDate(run.updatedAt)} +
+
+ + {/* Upstream Link */} +
+ + {/* Action Indicator */} +
+ + + +
+
+
+ ); +} diff --git a/app/components/RunList.tsx b/app/components/RunList.tsx new file mode 100644 index 0000000..da9325e --- /dev/null +++ b/app/components/RunList.tsx @@ -0,0 +1,60 @@ +'use client'; + +import React from 'react'; +import { OrchestrationRun } from '@/app/lib/types'; +import { RunItem } from './RunItem'; + +interface RunListProps { + runs: OrchestrationRun[]; + loading: boolean; + error: string; + onRunClick: (runId: string) => void; +} + +export function RunList({ runs, loading, error, onRunClick }: RunListProps) { + if (loading) { + return ( +
+
+
Loading runs...
+
+ ); + } + + if (error) { + return ( +
+
+
Error loading runs
+
{error}
+
+
+ ); + } + + if (runs.length === 0) { + return ( +
+
+ + + +
No runs found
+
Try adjusting your filters or check back later for new runs.
+
+
+ ); + } + + return ( +
+ {runs.map(run => ( + onRunClick(run.id)} + /> + ))} +
+ ); +} \ No newline at end of file diff --git a/app/components/RunStatusSummary.tsx b/app/components/RunStatusSummary.tsx new file mode 100644 index 0000000..45cd149 --- /dev/null +++ b/app/components/RunStatusSummary.tsx @@ -0,0 +1,179 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { useAsyncState } from "@/app/lib/hooks"; +import { queryRuns } from "@/app/lib/orchestration"; +import { OrchestrationRun, RunStatus } from "@/app/lib/types"; + +interface StatusCount { + status: RunStatus; + count: number; + label: string; + color: string; +} + +export function RunStatusSummary() { + const [runs, setRuns] = useState([]); + const asyncState = useAsyncState(); + const { start, succeed, fail } = asyncState; + + useEffect(() => { + const loadRuns = async () => { + start(); + try { + const result = await queryRuns({ limit: 1000 }); // Get all runs for summary + setRuns(result || []); + succeed(); + } catch (err) { + fail(err); + } + }; + + loadRuns(); + }, [start, succeed, fail]); + + if (asyncState.loading) { + return ( +
+

Run Status Summary

+
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ ))} +
+
+ ); + } + + if (asyncState.error) { + return ( +
+

Run Status Summary

+

Failed to load run status summary

+
+ ); + } + + // Count runs by status + const statusCounts: StatusCount[] = [ + { + status: 'running', + count: runs.filter(run => run.status === 'running').length, + label: 'Running', + color: 'text-green-600' + }, + { + status: 'blocked', + count: runs.filter(run => run.status === 'blocked').length, + label: 'Blocked', + color: 'text-amber-600' + }, + { + status: 'completed', + count: runs.filter(run => run.status === 'completed').length, + label: 'Completed', + color: 'text-blue-600' + }, + { + status: 'failed', + count: runs.filter(run => run.status === 'failed').length, + label: 'Failed', + color: 'text-red-600' + } + ]; + + return ( +
+

Run Status Summary

+
+ {statusCounts.map((statusCount) => { + const isBlocked = statusCount.status === 'blocked'; + const isFailed = statusCount.status === 'failed'; + const hasIssues = isBlocked || isFailed; + + return ( +
0 + ? isBlocked + ? 'bg-amber-50 border border-amber-200 ring-2 ring-amber-100' + : 'bg-red-50 border border-red-200 ring-2 ring-red-100' + : 'bg-slate-50' + }`} + > +
0 ? 'animate-pulse' : '' + }`}> + {statusCount.count} +
+
{statusCount.label}
+ {isBlocked && statusCount.count > 0 && ( +
+ Needs Attention +
+ )} + {isFailed && statusCount.count > 0 && ( +
+ Action Required +
+ )} +
+ ); + })} +
+ + {(() => { + const blockedCount = statusCounts?.find(s => s.status === 'blocked')?.count || 0; + const runningCount = statusCounts?.find(s => s.status === 'running')?.count || 0; + if (blockedCount === 0 && runningCount === 0) return null; + + return ( +
+ {runningCount > 0 && ( +
+
+ + + + + {runningCount} workflow{runningCount !== 1 ? 's' : ''} running right now + +
+ + View running runs → + +
+ )} + + {blockedCount > 0 && ( +
+
+ + + + + {blockedCount} workflow{blockedCount !== 1 ? 's' : ''} blocked and waiting for manual action + +
+ + View blocked runs → + +
+ )} +
+ ); + })()} +
+ ); +} diff --git a/app/components/StepCompletionModal.tsx b/app/components/StepCompletionModal.tsx new file mode 100644 index 0000000..1636d46 --- /dev/null +++ b/app/components/StepCompletionModal.tsx @@ -0,0 +1,150 @@ +'use client'; + +import React, { useState } from 'react'; +import { completeStep } from '@/app/lib/orchestration'; +import { MarkdownText } from '@/app/components/MarkdownText'; +import { OrchestrationStep } from '@/app/lib/types'; + +interface StepCompletionModalProps { + runId: string; + stepId: string; + step: OrchestrationStep; + onClose: () => void; + onCompleted: () => void; +} + +export function StepCompletionModal({ + runId, + stepId, + step, + onClose, + onCompleted +}: StepCompletionModalProps) { + const [note, setNote] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const noteChars = note.trim().length; + const stepTypeLabel = step.type.charAt(0).toUpperCase() + step.type.slice(1); + const stepTypeBadge = step.type.charAt(0).toUpperCase(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + setIsSubmitting(true); + setError(null); + + try { + await completeStep(runId, stepId, note.trim() || undefined); + onCompleted(); + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to complete step'); + } finally { + setIsSubmitting(false); + } + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+
+
+
+

Complete Step

+

+ Add a completion note for audit history. +

+
+ +
+ +
+
+
+
+ {stepTypeBadge} +
+ + {stepTypeLabel} Step + +
+

{step.title}

+ {step.description && ( +

+ +

+ )} +
+ +
+
+
+ + {noteChars} chars +
+