diff --git a/README.md b/README.md index f21c071..48d3cb4 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ OpsOrch Console is the operator-focused web UI for OpsOrch. It provides a unifie OpsOrch Console is available in two editions built from a single codebase: -- **OSS Edition** - Open source features including incidents, logs, metrics, tickets, services, and settings +- **OSS Edition** - Open source features including incidents, logs, metrics, tickets, services, orchestration, and settings - **Enterprise Edition** - All OSS features plus AI-powered Copilot assistance and chat history The edition is controlled at build time via the `OPSORCH_EDITION` environment variable. @@ -23,6 +23,7 @@ The edition is controlled at build time via the `OPSORCH_EDITION` environment va - **Metrics**: Visualize and query metrics data with customizable expressions and aggregations - **Services**: Explore service catalog and dependencies - **Tickets**: View and manage tickets from integrated ticketing systems +- **Orchestration**: Browse workflow plans, launch runs, monitor run status, and complete manual steps with progress tracking - **Chat**: AI-powered assistance via OpsOrch Copilot for incident investigation, log analysis, and operational queries. Copilot can generate smart references to filtered views with query parameters - **Settings**: Configure OpsOrch Core and Copilot endpoints @@ -35,6 +36,14 @@ All primary data views (Incidents, Alerts, Logs, Metrics) support: - **Scope filtering**: Filter by service, environment, and team - **Copilot integration**: AI can generate filtered views and include them as clickable references +### Copilot Answer Actions + +Copilot responses can include recommended actions to trigger orchestration workflows: + +- `actions` entries use the `orchestration` type and may include `id`, `name`, and `reason` +- When an `id` is present, the UI links directly to `/orchestration/plans/{id}` +- When no `id` is provided, the UI routes operators to the orchestration plan browser + ## Architecture @@ -202,7 +211,7 @@ The Docker images run as a non-root user and expose port 3000. Both editions sup ## Project Structure - `app/` - Next.js app router pages and layouts - - `(oss)/` - OSS Edition routes (incidents, logs, metrics, tickets, services, settings) + - `(oss)/` - OSS Edition routes (incidents, logs, metrics, tickets, services, orchestration, settings) - `(enterprise)/` - Enterprise Edition routes (Copilot home, chat history) - `components/` - Reusable React components - `(enterprise)/` - Enterprise-only components (CopilotPanel, etc.) @@ -312,4 +321,3 @@ Download release artifacts from the [Releases page](https://github.com/OpsOrch/o - [OpsOrch Core Documentation](../opsorch-core/README.md) - [OpsOrch Copilot Documentation](../opsorch-copilot/README.md) - [Next.js Documentation](https://nextjs.org/docs) - diff --git a/app/(oss)/deployments/[id]/page.tsx b/app/(oss)/deployments/[id]/page.tsx index 8b30489..e75df84 100644 --- a/app/(oss)/deployments/[id]/page.tsx +++ b/app/(oss)/deployments/[id]/page.tsx @@ -58,7 +58,7 @@ export default function DeploymentDetailPage() { .catch((err) => { deploymentState.fail(err); }); - }, [deploymentId]); // eslint-disable-line react-hooks/exhaustive-deps + }, [deploymentId, deploymentState]); diff --git a/app/components/(enterprise)/CopilotPanel.tsx b/app/components/(enterprise)/CopilotPanel.tsx index dce5281..26e3df7 100644 --- a/app/components/(enterprise)/CopilotPanel.tsx +++ b/app/components/(enterprise)/CopilotPanel.tsx @@ -48,14 +48,19 @@ function normalizeAnswer(payload: CopilotApiResponse): CopilotAnswer { // Extract executionTrace from the answer const derivedExecutionTrace = answer.executionTrace; + // Extract actions from the answer + const derivedActions = answer.actions; + console.log('[normalizeAnswer] answer.references:', answer.references); console.log('[normalizeAnswer] derivedReferences:', derivedReferences); console.log('[normalizeAnswer] executionTrace:', derivedExecutionTrace); + console.log('[normalizeAnswer] actions:', derivedActions); return { conclusion: derivedConclusion, missing: answer.missing, references: derivedReferences, + actions: derivedActions, confidence: answer.confidence, chatId: derivedChatId, executionTrace: derivedExecutionTrace, diff --git a/app/components/(enterprise)/copilot/ActionLinks.tsx b/app/components/(enterprise)/copilot/ActionLinks.tsx new file mode 100644 index 0000000..b8be910 --- /dev/null +++ b/app/components/(enterprise)/copilot/ActionLinks.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { CopilotAction } from "@/app/lib/types"; + +export function ActionLinks({ + actions, +}: { + actions?: CopilotAction[]; +}) { + if (!actions?.length) { + return null; + } + + return ( +
+

+ Recommended Actions ({actions.length}) +

+
+ {actions.map((action, idx) => { + const href = action.id + ? `/orchestration/plans/${action.id}` + : `/orchestration/plans`; + const label = action.name || action.id || "Run Orchestration"; + + return ( + +
+ + + +
+
+
+ {label} + + + +
+ {action.reason && ( +

+ {action.reason} +

+ )} +
+
+ ); + })} +
+
+ ); +} diff --git a/app/components/(enterprise)/copilot/ReferenceLinks.tsx b/app/components/(enterprise)/copilot/ReferenceLinks.tsx index baf6c77..821e811 100644 --- a/app/components/(enterprise)/copilot/ReferenceLinks.tsx +++ b/app/components/(enterprise)/copilot/ReferenceLinks.tsx @@ -13,9 +13,9 @@ export function ReferenceLinks({ console.log('[ReferenceLinks] No references provided'); return null; } - const { incidents, alerts, services, metrics, logs, tickets, deployments, teams } = references; - console.log('[ReferenceLinks] Extracted:', { incidents, alerts, services, metrics, logs, tickets, deployments, teams }); - if (!incidents?.length && !alerts?.length && !services?.length && !metrics?.length && !logs?.length && !tickets?.length && !deployments?.length && !teams?.length) { + const { incidents, alerts, services, metrics, logs, tickets, deployments, teams, orchestrationPlans } = references; + console.log('[ReferenceLinks] Extracted:', { incidents, alerts, services, metrics, logs, tickets, deployments, teams, orchestrationPlans }); + if (!incidents?.length && !alerts?.length && !services?.length && !metrics?.length && !logs?.length && !tickets?.length && !deployments?.length && !teams?.length && !orchestrationPlans?.length) { console.log('[ReferenceLinks] All reference arrays are empty'); return null; } @@ -36,7 +36,8 @@ export function ReferenceLinks({ (logs?.length || 0) + (tickets?.length || 0) + (deployments?.length || 0) + - (teams?.length || 0); + (teams?.length || 0) + + (orchestrationPlans?.length || 0); return (
@@ -70,11 +71,11 @@ export function ReferenceLinks({ const href = isString ? `/incidents/${inc}` : buildIncidentHref(inc); const label = isString ? `Incident ${inc}` : inc.query || 'Incident Query'; return ( - + @@ -92,11 +93,11 @@ export function ReferenceLinks({ const href = isString ? `/alerts/${alert}` : buildAlertHref(alert); const label = isString ? alert : alert.query || 'Alert Query'; return ( - + @@ -133,11 +134,11 @@ export function ReferenceLinks({ } return ( - + @@ -151,11 +152,11 @@ export function ReferenceLinks({

Services

{renderList(services.map((svc) => ( -
+ @@ -168,11 +169,11 @@ export function ReferenceLinks({

Teams

{renderList(teams.map((team: string) => ( -
+ @@ -189,12 +190,12 @@ export function ReferenceLinks({ const label = `${t}`; const href = t ? `/tickets?ticketId=${encodeURIComponent(t)}` : "/tickets"; return ( - + @@ -218,12 +219,12 @@ export function ReferenceLinks({ : "(unnamed)"; const tooltip = `${m.start || "?"} → ${m.end || "?"}${m.scope ? ` • Scope: ${JSON.stringify(m.scope)}` : ""}`; return ( - + @@ -259,6 +260,23 @@ export function ReferenceLinks({ )}
) : null} + {isExpanded && orchestrationPlans?.length ? ( +
+

Orchestration Plans

+ {renderList(orchestrationPlans.map((planId: string) => ( +
+ + + + {planId} + + )))} +
+ ) : null}
); } diff --git a/app/components/(enterprise)/copilot/ResponseDetails.tsx b/app/components/(enterprise)/copilot/ResponseDetails.tsx index 8375e4f..87fc6cd 100644 --- a/app/components/(enterprise)/copilot/ResponseDetails.tsx +++ b/app/components/(enterprise)/copilot/ResponseDetails.tsx @@ -1,5 +1,6 @@ import { Accordion, Badge } from "@/app/lib/ui"; import { ReferenceLinks } from "./ReferenceLinks"; +import { ActionLinks } from "./ActionLinks"; import { ToolExecutionsView } from "./ToolExecutionsView"; import { CopilotAnswer } from "@/app/lib/types"; import { stringifyData } from "@/app/lib/utils"; @@ -10,6 +11,9 @@ export function ResponseDetailsContent({ answer }: { answer: CopilotAnswer }) { return (
+ {/* Recommended Actions */} + + {/* References */} @@ -47,6 +51,7 @@ export function ResponseDetails({ answer }: { answer: CopilotAnswer }) { const hasDetails = answer.missing?.length || answer.references || + answer.actions?.length || answer.executionTrace; if (!hasDetails) return null; diff --git a/app/components/AlertsPanel.tsx b/app/components/AlertsPanel.tsx index b35e890..7c3ec26 100644 --- a/app/components/AlertsPanel.tsx +++ b/app/components/AlertsPanel.tsx @@ -83,8 +83,7 @@ export function AlertsPanel({ initialQuery, readOnly = false }: AlertsPanelProps executeQuery(alertQuery); }, 0); return () => clearTimeout(timer); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [alertQuery, executeQuery]); return (
([]); const [currentFilters, setCurrentFilters] = useState>({}); + const [initialFilters, setInitialFilters] = useState | null>(null); const asyncState = useAsyncState(); const { start, succeed, fail } = asyncState; + // Parse URL params on mount + useEffect(() => { + const scopeParam = searchParams.get("scope"); + const queryParam = searchParams.get("query"); + + const filters: Partial = {}; + + if (scopeParam) { + try { + filters.scope = JSON.parse(scopeParam) as QueryScope; + } catch { + // Invalid JSON, ignore + } + } + + if (queryParam) { + filters.query = queryParam; + } + + // Use an async function and microtask to set state to avoid direct setState in effect + Promise.resolve().then(() => { + setInitialFilters(filters); + setCurrentFilters(filters); + }); + }, [searchParams]); + const loadPlans = useCallback( async (filters: Partial = {}) => { start(); @@ -35,20 +64,24 @@ export function PlanBrowser() { loadPlans(filters); }; - // Load initial plans + // Load plans once initial filters are parsed useEffect(() => { - const timeoutId = setTimeout(() => { - void loadPlans(); - }, 0); - return () => clearTimeout(timeoutId); - }, [loadPlans]); + if (initialFilters !== null) { + const timeoutId = setTimeout(() => { + void loadPlans(initialFilters); + }, 0); + return () => clearTimeout(timeoutId); + } + }, [initialFilters, loadPlans]); return (
{/* Filters */} - {/* Results Header */} @@ -78,7 +111,7 @@ export function PlanBrowser() {
{/* Plan Grid */} - ) => void; loading?: boolean; + initialFilters?: Partial; } -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(""); +export function PlanFilters({ onFilterChange, loading, initialFilters }: PlanFiltersProps) { + const [query, setQuery] = useState(initialFilters?.query || ""); + const [selectedType, setSelectedType] = useState(initialFilters?.tags?.type || ""); + const [showAdvanced, setShowAdvanced] = useState(Boolean(initialFilters?.scope)); + const [service, setService] = useState(initialFilters?.scope?.service || ""); + const [team, setTeam] = useState(initialFilters?.scope?.team || ""); + const [environment, setEnvironment] = useState(initialFilters?.scope?.environment || ""); + const handleSearch = () => { const filters: Partial = { diff --git a/app/lib/referenceBuilder.ts b/app/lib/referenceBuilder.ts index 15679be..d241518 100644 --- a/app/lib/referenceBuilder.ts +++ b/app/lib/referenceBuilder.ts @@ -40,7 +40,7 @@ export function buildToolExecutionHref( // Handle expression as object with search field const logExpression = args.expression as Record | undefined; return buildLogHref({ - expression: { + expression: { search: logExpression?.search as string || args.query as string, filters: logExpression?.filters as LogReference["expression"]["filters"], severityIn: logExpression?.severityIn as string[], @@ -122,6 +122,56 @@ export function buildToolExecutionHref( } return "/incidents"; + case "query-orchestration-plans": + // Link to orchestration plans list, optionally with query params + const planParams = new URLSearchParams(); + if (args.query) planParams.set("query", String(args.query)); + if (args.scope) planParams.set("scope", JSON.stringify(args.scope)); + const planQueryStr = planParams.toString(); + return planQueryStr ? `/orchestration/plans?${planQueryStr}` : "/orchestration/plans"; + + case "get-orchestration-plan": + if (args.id) { + return `/orchestration/plans/${args.id}`; + } + if (args.planId) { + return `/orchestration/plans/${args.planId}`; + } + return "/orchestration/plans"; + + case "query-orchestration-runs": + // Link to orchestration runs list + const runParams = new URLSearchParams(); + if (args.planIds && Array.isArray(args.planIds)) { + runParams.set("planIds", args.planIds.join(",")); + } + if (args.statuses && Array.isArray(args.statuses)) { + runParams.set("statuses", args.statuses.join(",")); + } + const runQueryStr = runParams.toString(); + return runQueryStr ? `/orchestration/runs?${runQueryStr}` : "/orchestration/runs"; + + case "get-orchestration-run": + if (args.id) { + return `/orchestration/runs/${args.id}`; + } + if (args.runId) { + return `/orchestration/runs/${args.runId}`; + } + return "/orchestration/runs"; + + case "start-orchestration-run": + if (args.planId) { + return `/orchestration/plans/${args.planId}`; + } + return "/orchestration/plans"; + + case "complete-orchestration-step": + if (args.runId) { + return `/orchestration/runs/${args.runId}`; + } + return "/orchestration/runs"; + default: return null; // Unknown tool - no Console link } diff --git a/app/lib/types.ts b/app/lib/types.ts index 1b69f9e..cd0a8ec 100644 --- a/app/lib/types.ts +++ b/app/lib/types.ts @@ -189,12 +189,21 @@ export type CopilotReferences = { tickets?: string[]; deployments?: (string | DeploymentReference | Partial)[]; teams?: string[]; + orchestrationPlans?: string[]; +}; + +export type CopilotAction = { + type: "orchestration"; + id?: string; + name?: string; + reason?: string; }; export type CopilotAnswer = { conclusion: string; missing?: string[]; references?: CopilotReferences; + actions?: CopilotAction[]; confidence?: number; chatId?: string; executionTrace?: TurnExecutionTrace; diff --git a/tests/referenceBuilder.test.ts b/tests/referenceBuilder.test.ts index f2106b4..59d3542 100644 --- a/tests/referenceBuilder.test.ts +++ b/tests/referenceBuilder.test.ts @@ -375,6 +375,46 @@ describe('referenceBuilder', () => { assert.strictEqual(buildToolExecutionHref('get-incident-timeline', { id: 'inc-1' }), '/incidents/inc-1?tab=timeline'); }); + it('should handle query-orchestration-plans', () => { + const href = buildToolExecutionHref('query-orchestration-plans', { + query: 'restart', + scope: { service: 'api' } + }); + assert.ok(href?.includes('/orchestration/plans?')); + assert.ok(href?.includes('query=restart')); + assert.ok(decodeURIComponent(href || '').includes('"service":"api"')); + }); + + it('should handle get-orchestration-plan with id', () => { + assert.strictEqual(buildToolExecutionHref('get-orchestration-plan', { id: 'plan-1' }), '/orchestration/plans/plan-1'); + }); + + it('should handle get-orchestration-plan with planId', () => { + assert.strictEqual(buildToolExecutionHref('get-orchestration-plan', { planId: 'plan-2' }), '/orchestration/plans/plan-2'); + }); + + it('should handle query-orchestration-runs', () => { + const href = buildToolExecutionHref('query-orchestration-runs', { + planIds: ['p1', 'p2'], + statuses: ['running'] + }); + assert.ok(href?.includes('/orchestration/runs?')); + assert.ok(href?.includes('planIds=p1%2Cp2')); + assert.ok(href?.includes('statuses=running')); + }); + + it('should handle get-orchestration-run with id', () => { + assert.strictEqual(buildToolExecutionHref('get-orchestration-run', { id: 'run-1' }), '/orchestration/runs/run-1'); + }); + + it('should handle start-orchestration-run', () => { + assert.strictEqual(buildToolExecutionHref('start-orchestration-run', { planId: 'plan-1' }), '/orchestration/plans/plan-1'); + }); + + it('should handle complete-orchestration-step', () => { + assert.strictEqual(buildToolExecutionHref('complete-orchestration-step', { runId: 'run-1' }), '/orchestration/runs/run-1'); + }); + it('should return null for unknown tool', () => { assert.strictEqual(buildToolExecutionHref('unknown-tool', {}), null); }); diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index cca476f..8ee9049 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -102,7 +102,6 @@ test("computeEdgePaths builds trunk + branches for fan-out", () => { const edgeCounts = getEdgeCounts(steps); const outgoingTargets = getOutgoingTargets(steps); const paths = computeEdgePaths({ - steps, stepById, nodeRects, statusById, @@ -135,7 +134,6 @@ test("computeEdgePaths flags blocked edges", () => { const edgeCounts = getEdgeCounts(steps); const outgoingTargets = getOutgoingTargets(steps); const paths = computeEdgePaths({ - steps, stepById, nodeRects, statusById,