diff --git a/src/app/[[...panel]]/page.tsx b/src/app/[[...panel]]/page.tsx index 9930df4b8c..8a754d0eb6 100644 --- a/src/app/[[...panel]]/page.tsx +++ b/src/app/[[...panel]]/page.tsx @@ -37,6 +37,7 @@ import { SecurityAuditPanel } from '@/components/panels/security-audit-panel' import { NodesPanel } from '@/components/panels/nodes-panel' import { ExecApprovalPanel } from '@/components/panels/exec-approval-panel' import { SystemMonitorPanel } from '@/components/panels/system-monitor-panel' +import { AiTeamOsPanel } from '@/components/panels/ai-team-os-panel' import { ChatPagePanel } from '@/components/panels/chat-page-panel' import { ChatPanel } from '@/components/chat/chat-panel' import { STORAGE_GATEWAY_URL } from '@/lib/device-identity' @@ -591,6 +592,8 @@ function ContentRouter({ tab }: { tab: string }) { return case 'monitor': return + case 'ai-team-os': + return case 'skills': return case 'channels': diff --git a/src/app/api/ai-team-os/route.ts b/src/app/api/ai-team-os/route.ts new file mode 100644 index 0000000000..933c764372 --- /dev/null +++ b/src/app/api/ai-team-os/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from 'next/server' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { requireRole } from '@/lib/auth' +import { logger } from '@/lib/logger' + +const VOXSIGN_ROOT = process.env.VOXSIGN_ROOT || '/home/ubuntu/projects/voxsign' + +async function readJson(relativePath: string) { + try { + const content = await readFile(path.join(VOXSIGN_ROOT, relativePath), 'utf8') + return JSON.parse(content) + } catch { + return null + } +} + +async function readText(relativePath: string) { + try { + return await readFile(path.join(VOXSIGN_ROOT, relativePath), 'utf8') + } catch { + return null + } +} + +export async function GET(request: NextRequest) { + const auth = requireRole(request, 'viewer') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + try { + const [ + health, + excellence, + budgetControl, + ciBacklog, + evalCoverage, + latestHealthReport, + latestScorecardReport, + latestEvalCoverageReport, + ] = await Promise.all([ + readJson('.team/state/ai_team_os_health.json'), + readJson('.team/state/ai_team_excellence_scorecard.json'), + readJson('.team/state/ai_team_budget_control.json'), + readJson('.team/state/ci_pr_backlog.json'), + readJson('.team/state/ai_team_eval_coverage.json'), + readText('reports/ops/latest/ai-team-os-health-latest.md'), + readText('reports/ops/latest/ai-team-excellence-scorecard-latest.md'), + readText('reports/ops/latest/ai-team-eval-coverage-latest.md'), + ]) + + const missing = [ + ['health', health], + ['excellence', excellence], + ['budgetControl', budgetControl], + ['ciBacklog', ciBacklog], + ['evalCoverage', evalCoverage], + ].filter(([, value]) => !value).map(([name]) => name) + + return NextResponse.json({ + root: VOXSIGN_ROOT, + generatedAt: new Date().toISOString(), + missing, + health, + excellence, + budgetControl, + ciBacklog, + evalCoverage, + reports: { + health: latestHealthReport, + excellence: latestScorecardReport, + evalCoverage: latestEvalCoverageReport, + }, + }) + } catch (error) { + logger.error({ err: error }, 'AI Team OS API error') + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/src/components/layout/nav-rail.tsx b/src/components/layout/nav-rail.tsx index f5c6d09dbe..aa24b3d36c 100644 --- a/src/components/layout/nav-rail.tsx +++ b/src/components/layout/nav-rail.tsx @@ -49,6 +49,7 @@ const navGroups: NavGroup[] = [ { id: 'exec-approvals', label: 'Approvals', icon: , priority: false }, { id: 'office', label: 'Office', icon: , priority: false }, { id: 'monitor', label: 'Monitor', icon: , priority: false }, + { id: 'ai-team-os', label: 'AI Team OS', icon: , priority: false }, ], }, { diff --git a/src/components/panels/ai-team-os-panel.tsx b/src/components/panels/ai-team-os-panel.tsx new file mode 100644 index 0000000000..51f58a7267 --- /dev/null +++ b/src/components/panels/ai-team-os-panel.tsx @@ -0,0 +1,343 @@ +'use client' + +import { useCallback, useMemo, useState } from 'react' +import { useSmartPoll } from '@/lib/use-smart-poll' +import { Button } from '@/components/ui/button' + +type Check = { + name: string + status: string + points: number + max_points: number + detail: string +} + +type Dimension = { + name: string + weight: number + points: number + status: string + signals: string[] + gaps: string[] +} + +type AiTeamOsPayload = { + generatedAt: string + missing: string[] + health: { + generated_at: string + score: number + status: string + checks: Check[] + } | null + excellence: { + generated_at: string + score: number + status: string + north_star: string + dimensions: Dimension[] + red_flags: string[] + next_actions: string[] + } | null + budgetControl: { + generated_at: string + status: string + controls_passed: number + controls_total: number + controls: Record + next_actions: string[] + } | null + ciBacklog: { + generated_at: string + summary: { + total: number + delivery_control_blockers: number + by_classification: Record + } + prs: Array<{ + number: number + title: string + url: string + classification: string + ci_state: string + next_action: string + }> + } | null + evalCoverage: { + generated_at: string + score: number + status: string + weak_areas: string[] + areas: Array<{ + name: string + status: string + points: number + max_points: number + signals: string[] + gaps: string[] + }> + } | null +} + +function statusClass(status: string) { + const value = status.toLowerCase() + if (value.includes('excellent') || value.includes('healthy') || value === 'pass' || value === 'controlled') { + return 'bg-emerald-500/10 text-emerald-600 border-emerald-500/30' + } + if (value.includes('competitive') || value.includes('managed') || value.includes('warn') || value.includes('degraded')) { + return 'bg-amber-500/10 text-amber-600 border-amber-500/30' + } + return 'bg-red-500/10 text-red-600 border-red-500/30' +} + +function formatTime(value?: string) { + if (!value) return 'unknown' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString() +} + +function Pill({ children, status }: { children: React.ReactNode; status: string }) { + return ( + + {children} + + ) +} + +function MetricCard({ label, value, status, detail }: { label: string; value: string; status: string; detail?: string }) { + return ( +
+
+
+
{label}
+
{value}
+
+ {status} +
+ {detail &&

{detail}

} +
+ ) +} + +export function AiTeamOsPanel() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + + const fetchData = useCallback(async () => { + try { + const res = await fetch('/api/ai-team-os') + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const payload = await res.json() + setData(payload) + setError(null) + } catch (err: any) { + setError(err.message || 'Failed to load AI Team OS') + } + }, []) + + useSmartPoll(fetchData, 15000) + + const checks = useMemo(() => { + return data?.health?.checks || [] + }, [data]) + + const failedOrWarnChecks = checks.filter(check => check.status !== 'PASS') + const dimensions = data?.excellence?.dimensions || [] + const ciClasses = data?.ciBacklog?.summary?.by_classification || {} + const controls = data?.budgetControl?.controls || {} + const evalAreas = data?.evalCoverage?.areas || [] + + if (!data) { + return ( +
+ {error ? `Error: ${error}` : 'Loading AI Team OS...'} +
+ ) + } + + return ( +
+
+
+

AI Team OS

+

+ Customer-oriented operating scorecard for the VoxSign AI team. +

+
+
+ Updated {formatTime(data.excellence?.generated_at || data.health?.generated_at)} + +
+
+ + {data.missing.length > 0 && ( +
+ Missing AI Team OS inputs: {data.missing.join(', ')} +
+ )} + +
+ + + + 0 ? 'WARN' : 'PASS'} + detail={`${data.ciBacklog?.summary?.total ?? 0} open PRs`} + /> + +
+ +
+
+
+

Excellence Dimensions

+ {data.excellence?.status || 'unknown'} +
+
+ {dimensions.map(dimension => ( +
+
+ {dimension.name} + {dimension.points}/{dimension.weight} +
+
+
+
+
+ {dimension.status} +

+ {dimension.gaps?.[0] || dimension.signals?.[0] || 'No gap recorded'} +

+
+
+ ))} +
+
+ +
+

Red Flags

+ {data.excellence?.red_flags?.length ? ( +
+ {data.excellence.red_flags.map(flag => ( +
+ {flag} +
+ ))} +
+ ) : ( +

No red flags.

+ )} +
+
+ +
+
+

CI/PR Delivery Control

+
+ {Object.entries(ciClasses).map(([name, count]) => ( +
+
{name}
+
{count}
+
+ ))} +
+
+ {(data.ciBacklog?.prs || []).slice(0, 8).map(pr => ( + +
+ #{pr.number} {pr.title} + {pr.classification} +
+

{pr.next_action}

+
+ ))} +
+
+ +
+

Budget Controls

+
+ {Object.entries(controls).map(([name, passed]) => ( +
+ {name.replace(/_/g, ' ')} + {passed ? 'PASS' : 'FAIL'} +
+ ))} +
+
+
+ +
+
+

AI Behavior Eval Coverage

+ {data.evalCoverage?.status || 'unknown'} +
+
+ {evalAreas.map(area => ( +
+
+ {area.name} + {area.points}/{area.max_points} +
+
+
+
+
+ {area.status} +

+ {area.gaps?.[0] || area.signals?.[0] || 'No gap recorded'} +

+
+
+ ))} +
+
+ +
+

Next Actions

+
+ {(data.excellence?.next_actions || []).map(action => ( +
+ {action} +
+ ))} +
+
+
+ ) +}