From 87d80996bf857b237b7ce3f2ecf35f4730aa58e8 Mon Sep 17 00:00:00 2001 From: JustAGhosT Date: Wed, 11 Mar 2026 08:06:56 +0200 Subject: [PATCH 1/5] Phase 15 Batch A: Settings, Notification Preferences, User Profile FE-008: Enhanced settings page with language selector (en-US/fr-FR/de-DE), Data & Privacy consent toggles (analytics, telemetry, personalized content, third-party sharing), descriptions on all toggles, save confirmation. FE-009: New /settings/notifications page with channel toggles (email, push, SMS, in-app), 5 notification categories with per-category enable/disable, quiet hours with start/end time and timezone. FE-010: New /profile page with account info, role badges (Admin/Analyst/ Viewer), GDPR & EU AI Act consent management (4 consent types), privacy summary with status dots, data export request (GDPR Article 20), session info. Added Profile nav item with User icon to sidebar. Store: Extended usePreferencesStore with language, privacyConsent, and notificationPreferences state + actions (setLanguage, setPrivacyConsent, setNotificationChannel, setQuietHours). Build: 14 pages generated (was 12), 0 TypeScript errors. Co-Authored-By: Claude Opus 4.6 --- .../web/src/app/(app)/profile/page.tsx | 257 +++++++++++++++++ .../app/(app)/settings/notifications/page.tsx | 264 ++++++++++++++++++ .../web/src/app/(app)/settings/page.tsx | 222 ++++++++++++--- .../web/src/components/Navigation/Sidebar.tsx | 2 + .../web/src/components/Navigation/navItems.ts | 1 + .../web/src/stores/usePreferencesStore.ts | 99 ++++++- 6 files changed, 802 insertions(+), 43 deletions(-) create mode 100644 src/UILayer/web/src/app/(app)/profile/page.tsx create mode 100644 src/UILayer/web/src/app/(app)/settings/notifications/page.tsx diff --git a/src/UILayer/web/src/app/(app)/profile/page.tsx b/src/UILayer/web/src/app/(app)/profile/page.tsx new file mode 100644 index 00000000..75dfc3e9 --- /dev/null +++ b/src/UILayer/web/src/app/(app)/profile/page.tsx @@ -0,0 +1,257 @@ +"use client" + +import { useId, useState } from "react" +import { useAuth } from "@/contexts/AuthContext" +import { usePreferencesStore } from "@/stores" + +interface ConsentRecord { + type: string + label: string + description: string + granted: boolean +} + +const GDPR_CONSENTS: ConsentRecord[] = [ + { + type: "GDPRDataProcessing", + label: "Data processing", + description: "Allow processing of personal data for core platform functionality", + granted: true, + }, + { + type: "GDPRAutomatedDecisionMaking", + label: "Automated decision-making", + description: "Allow AI agents to make decisions using your data", + granted: false, + }, + { + type: "GDPRDataTransferOutsideEU", + label: "Cross-border data transfer", + description: "Allow data to be processed outside the EU/EEA", + granted: false, + }, + { + type: "EUAIActHighRiskSystem", + label: "High-risk AI system consent", + description: "Acknowledge interaction with systems classified as high-risk under the EU AI Act", + granted: false, + }, +] + +export default function ProfilePage() { + const { user } = useAuth() + const { privacyConsent } = usePreferencesStore() + const [consents, setConsents] = useState(GDPR_CONSENTS) + const [exportRequested, setExportRequested] = useState(false) + + function toggleConsent(type: string) { + setConsents((prev) => + prev.map((c) => (c.type === type ? { ...c, granted: !c.granted } : c)) + ) + } + + function handleDataExport() { + // In production this calls POST /api/v1/compliance/gdpr/data-export + setExportRequested(true) + } + + if (!user) { + return ( +
+
+
+ ) + } + + return ( +
+

Profile

+ + {/* Account info */} +
+

Account

+
+ + + + +
+
+ + {/* Roles */} +
+

Roles

+ {user.roles.length > 0 ? ( +
+ {user.roles.map((role) => ( + + ))} +
+ ) : ( +

No roles assigned

+ )} +
+ + {/* GDPR Consent */} +
+

GDPR & AI Act Consent

+

+ Manage your consent preferences. All changes are logged for compliance auditing. +

+
+ {consents.map((consent) => ( + toggleConsent(consent.type)} + /> + ))} +
+
+ + {/* Data & privacy summary */} +
+

Data Privacy

+
+
+ Analytics consent + +
+
+ Telemetry consent + +
+
+ Personalized content + +
+

+ Manage these in{" "} + + Settings > Data & Privacy + +

+
+
+ + {/* Data export */} +
+

Data Export

+

+ Request a copy of all personal data stored in the system (GDPR Article 20). +

+ {exportRequested ? ( +
+ Export request submitted. You will receive a download link via email. +
+ ) : ( + + )} +
+ + {/* Session info */} +
+

Session

+
+ + +
+
+
+ ) +} + +function InfoRow({ + label, + value, + mono, +}: { + label: string + value: string + mono?: boolean +}) { + return ( +
+ {label} + + {value} + +
+ ) +} + +function RoleBadge({ role }: { role: string }) { + const colors: Record = { + Admin: "border-red-700 bg-red-950/50 text-red-300", + Analyst: "border-blue-700 bg-blue-950/50 text-blue-300", + Viewer: "border-gray-700 bg-gray-950/50 text-gray-300", + } + const cls = colors[role] ?? "border-gray-700 bg-gray-950/50 text-gray-300" + + return ( + + {role} + + ) +} + +function ConsentRow({ + consent, + onToggle, +}: { + consent: ConsentRecord + onToggle: () => void +}) { + const id = useId() + + return ( +
+
+ + {consent.label} + +

{consent.description}

+
+ +
+ ) +} + +function StatusDot({ active }: { active: boolean }) { + return ( + + + {active ? "Granted" : "Not granted"} + + ) +} diff --git a/src/UILayer/web/src/app/(app)/settings/notifications/page.tsx b/src/UILayer/web/src/app/(app)/settings/notifications/page.tsx new file mode 100644 index 00000000..dcf70986 --- /dev/null +++ b/src/UILayer/web/src/app/(app)/settings/notifications/page.tsx @@ -0,0 +1,264 @@ +"use client" + +import { useId } from "react" +import { usePreferencesStore } from "@/stores" +import type { NotificationPreferences } from "@/stores/usePreferencesStore" + +const NOTIFICATION_CATEGORIES = [ + { id: "approvals", label: "Approvals", description: "Approval requests and decisions" }, + { id: "security", label: "Security", description: "Security alerts and access changes" }, + { id: "system", label: "System", description: "System status and maintenance" }, + { id: "agents", label: "Agent activity", description: "Agent status changes and completions" }, + { id: "compliance", label: "Compliance", description: "Compliance deadlines and audit results" }, +] as const + +export default function NotificationPreferencesPage() { + const { + notificationsEnabled, + setNotificationsEnabled, + notificationPreferences, + setNotificationChannel, + setQuietHours, + } = usePreferencesStore() + + const { channels, quietHours } = notificationPreferences + + return ( +
+
+

Notification Preferences

+

+ Choose how and when you receive notifications. +

+
+ + {/* Master toggle */} +
+ +
+ + {/* Channels */} +
+

Channels

+
+ setNotificationChannel("inApp", v)} + disabled={!notificationsEnabled} + /> + setNotificationChannel("email", v)} + disabled={!notificationsEnabled} + /> + setNotificationChannel("push", v)} + disabled={!notificationsEnabled} + /> + setNotificationChannel("sms", v)} + disabled={!notificationsEnabled} + /> +
+
+ + {/* Categories */} +
+

Categories

+

+ Enable or disable notifications by category. Channel overrides can be configured per category. +

+
+ {NOTIFICATION_CATEGORIES.map((cat) => ( + + ))} +
+
+ + {/* Quiet hours */} +
+

Quiet Hours

+
+ setQuietHours({ enabled: v })} + disabled={!notificationsEnabled} + /> + {quietHours.enabled && notificationsEnabled && ( +
+ setQuietHours({ startTime: v })} + /> + setQuietHours({ endTime: v })} + /> +
+ + setQuietHours({ timezone: e.target.value })} + className="w-full rounded bg-white/10 px-3 py-1.5 text-sm text-white outline-none focus-visible:ring-2 focus-visible:ring-cyan-500" + placeholder="America/New_York" + /> +
+
+ )} +
+
+ + + Back to settings + +
+ ) +} + +function CategoryRow({ + category, + preferences, + disabled, +}: { + category: { id: string; label: string; description: string } + preferences: NotificationPreferences + disabled: boolean +}) { + const existing = preferences.categories.find((c) => c.category === category.id) + const enabled = existing ? existing.enabled : true + const store = usePreferencesStore() + + function toggleCategory(value: boolean) { + const updated = preferences.categories.filter((c) => c.category !== category.id) + updated.push({ + category: category.id, + enabled: value, + channels: existing?.channels ?? { ...preferences.channels }, + }) + store.setNotificationPreferences({ categories: updated }) + } + + return ( +
+
+ {category.label} +

{category.description}

+
+ +
+ ) +} + +function TimeInput({ + label, + value, + onChange, +}: { + label: string + value: string + onChange: (v: string) => void +}) { + const id = useId() + return ( +
+ + onChange(e.target.value)} + className="w-full rounded bg-white/10 px-3 py-1.5 text-sm text-white outline-none focus-visible:ring-2 focus-visible:ring-cyan-500" + /> +
+ ) +} + +function ToggleRow({ + label, + description, + checked, + onChange, + disabled, +}: { + label: string + description?: string + checked: boolean + onChange: (v: boolean) => void + disabled?: boolean +}) { + const id = useId() + + return ( +
+
+ + {label} + + {description &&

{description}

} +
+ +
+ ) +} + +function ToggleButton({ + checked, + onChange, + disabled, + label, +}: { + checked: boolean + onChange: (v: boolean) => void + disabled?: boolean + label: string +}) { + return ( + + ) +} diff --git a/src/UILayer/web/src/app/(app)/settings/page.tsx b/src/UILayer/web/src/app/(app)/settings/page.tsx index 482afaa1..f26bb19b 100644 --- a/src/UILayer/web/src/app/(app)/settings/page.tsx +++ b/src/UILayer/web/src/app/(app)/settings/page.tsx @@ -1,7 +1,12 @@ "use client" -import { useState } from "react" +import { useId, useState } from "react" import { usePreferencesStore } from "@/stores" +import { + SUPPORTED_LANGUAGES, + LANGUAGE_LABELS, + type SupportedLanguage, +} from "@/lib/i18n/i18nConfig" export default function SettingsPage() { const { @@ -15,99 +20,232 @@ export default function SettingsPage() { setFontSize, soundEnabled, setSoundEnabled, + language, + setLanguage, + privacyConsent, + setPrivacyConsent, resetDefaults, } = usePreferencesStore() + const [saved, setSaved] = useState(false) + + function handleLanguageChange(lang: SupportedLanguage) { + setLanguage(lang) + // Also persist to localStorage key used by i18nConfig + try { + localStorage.setItem("cognitivemesh_language", lang) + } catch { + // localStorage may be unavailable + } + } + + function handleSave() { + // Preferences are already persisted to localStorage via Zustand persist. + // In a future iteration this will also sync to the backend API. + setSaved(true) + setTimeout(() => setSaved(false), 2000) + } + return (
-

Settings

+
+

Settings

+ +
+ {/* Appearance */}

Appearance

-
- - -
- -
- - -
+ setTheme(v as "dark" | "light" | "system")} + options={[ + { value: "dark", label: "Dark" }, + { value: "light", label: "Light" }, + { value: "system", label: "System" }, + ]} + /> + setFontSize(v as "small" | "medium" | "large")} + options={[ + { value: "small", label: "Small" }, + { value: "medium", label: "Medium" }, + { value: "large", label: "Large" }, + ]} + />
+ {/* Language */} +
+

Language

+ handleLanguageChange(v as SupportedLanguage)} + options={SUPPORTED_LANGUAGES.map((lang) => ({ + value: lang, + label: `${LANGUAGE_LABELS[lang].flag} ${LANGUAGE_LABELS[lang].nativeLabel}`, + }))} + /> +
+ + {/* Accessibility */}

Accessibility

- + {/* Data & Privacy */} +
+

Data & Privacy

+

+ Control how your data is collected and used. Changes are recorded for GDPR compliance. +

+
+ setPrivacyConsent({ analytics: v })} + /> + setPrivacyConsent({ telemetry: v })} + /> + setPrivacyConsent({ personalizedContent: v })} + /> + setPrivacyConsent({ thirdPartySharing: v })} + /> +
+ {privacyConsent.updatedAt > 0 && ( +

+ Last updated: {new Date(privacyConsent.updatedAt).toLocaleString()} +

+ )} +
+ +
+ + + Notification preferences + +
) } -let toggleId = 0 +function SelectRow({ + label, + value, + onChange, + options, +}: { + label: string + value: string + onChange: (value: string) => void + options: { value: string; label: string }[] +}) { + const id = useId() + return ( +
+ + +
+ ) +} function ToggleRow({ label, + description, checked, onChange, }: { label: string + description?: string checked: boolean onChange: (v: boolean) => void }) { - const [labelId] = useState(() => `toggle-label-${++toggleId}`) + const id = useId() return ( -
- {label} +
+
+ + {label} + + {description && ( +

{description}

+ )} +
)}
- Back to settings - + ) } @@ -167,7 +203,7 @@ function CategoryRow({ } return ( -
+
{category.label}

{category.description}

@@ -202,63 +238,3 @@ function TimeInput({
) } - -function ToggleRow({ - label, - description, - checked, - onChange, - disabled, -}: { - label: string - description?: string - checked: boolean - onChange: (v: boolean) => void - disabled?: boolean -}) { - const id = useId() - - return ( -
-
- - {label} - - {description &&

{description}

} -
- -
- ) -} - -function ToggleButton({ - checked, - onChange, - disabled, - label, -}: { - checked: boolean - onChange: (v: boolean) => void - disabled?: boolean - label: string -}) { - return ( - - ) -} diff --git a/src/UILayer/web/src/app/(app)/settings/page.tsx b/src/UILayer/web/src/app/(app)/settings/page.tsx index f26bb19b..a0730d4b 100644 --- a/src/UILayer/web/src/app/(app)/settings/page.tsx +++ b/src/UILayer/web/src/app/(app)/settings/page.tsx @@ -1,12 +1,15 @@ "use client" import { useId, useState } from "react" +import Link from "next/link" import { usePreferencesStore } from "@/stores" +import { ToggleRow } from "@/components/ui/toggle-switch" import { SUPPORTED_LANGUAGES, LANGUAGE_LABELS, type SupportedLanguage, } from "@/lib/i18n/i18nConfig" +import i18n from "@/lib/i18n/i18nConfig" export default function SettingsPage() { const { @@ -31,6 +34,8 @@ export default function SettingsPage() { function handleLanguageChange(lang: SupportedLanguage) { setLanguage(lang) + // Update i18next runtime language immediately + i18n.changeLanguage(lang) // Also persist to localStorage key used by i18nConfig try { localStorage.setItem("cognitivemesh_language", lang) @@ -41,7 +46,7 @@ export default function SettingsPage() { function handleSave() { // Preferences are already persisted to localStorage via Zustand persist. - // In a future iteration this will also sync to the backend API. + // TODO: Sync to backend user preferences API. setSaved(true) setTimeout(() => setSaved(false), 2000) } @@ -172,12 +177,12 @@ export default function SettingsPage() { > Reset to defaults - Notification preferences - +
) @@ -215,46 +220,3 @@ function SelectRow({ ) } - -function ToggleRow({ - label, - description, - checked, - onChange, -}: { - label: string - description?: string - checked: boolean - onChange: (v: boolean) => void -}) { - const id = useId() - - return ( -
-
- - {label} - - {description && ( -

{description}

- )} -
- -
- ) -} diff --git a/src/UILayer/web/src/components/ui/toggle-switch.tsx b/src/UILayer/web/src/components/ui/toggle-switch.tsx new file mode 100644 index 00000000..b4c6bcfb --- /dev/null +++ b/src/UILayer/web/src/components/ui/toggle-switch.tsx @@ -0,0 +1,63 @@ +"use client" + +import { useId } from "react" + +export function ToggleRow({ + label, + description, + checked, + onChange, + disabled, +}: { + label: string + description?: string + checked: boolean + onChange: (v: boolean) => void + disabled?: boolean +}) { + const id = useId() + + return ( +
+
+ + {label} + + {description &&

{description}

} +
+ +
+ ) +} + +export function ToggleButton({ + checked, + onChange, + disabled, + label, +}: { + checked: boolean + onChange: (v: boolean) => void + disabled?: boolean + label: string +}) { + return ( + + ) +} diff --git a/src/UILayer/web/src/stores/usePreferencesStore.ts b/src/UILayer/web/src/stores/usePreferencesStore.ts index 26dc1d5e..5c5c433d 100644 --- a/src/UILayer/web/src/stores/usePreferencesStore.ts +++ b/src/UILayer/web/src/stores/usePreferencesStore.ts @@ -2,7 +2,7 @@ * Preferences store — user settings persisted to localStorage. * * Uses Zustand's persist middleware to survive page reloads. - * Also syncs to backend when the user is authenticated. + * TODO: Sync to backend user preferences API when authenticated. */ import { create } from "zustand" import { persist } from "zustand/middleware" @@ -41,6 +41,12 @@ export interface NotificationCategoryPreference { channels: { email: boolean; push: boolean; sms: boolean; inApp: boolean } } +export interface GdprConsentRecord { + type: string + granted: boolean + updatedAt: number +} + interface PreferencesState { theme: Theme sidebarCollapsed: boolean @@ -52,6 +58,7 @@ interface PreferencesState { language: SupportedLanguage privacyConsent: PrivacyConsent notificationPreferences: NotificationPreferences + gdprConsents: GdprConsentRecord[] } interface PreferencesActions { @@ -68,6 +75,7 @@ interface PreferencesActions { setNotificationPreferences: (prefs: Partial) => void setNotificationChannel: (channel: keyof NotificationPreferences["channels"], enabled: boolean) => void setQuietHours: (quietHours: Partial) => void + setGdprConsent: (type: string, granted: boolean) => void resetDefaults: () => void } @@ -101,6 +109,7 @@ const defaults: PreferencesState = { language: "en-US", privacyConsent: defaultPrivacyConsent, notificationPreferences: defaultNotificationPreferences, + gdprConsents: [], } export const usePreferencesStore = create()( @@ -154,6 +163,16 @@ export const usePreferencesStore = create }, }, })), + setGdprConsent: (type, granted) => + set((state) => { + const filtered = state.gdprConsents.filter((c) => c.type !== type) + return { + gdprConsents: [ + ...filtered, + { type, granted, updatedAt: Date.now() }, + ], + } + }), resetDefaults: () => set(defaults), }), { From 83ace0a0cbea46f26adc65b5fa83e797bd0ca0c8 Mon Sep 17 00:00:00 2001 From: JustAGhosT Date: Wed, 11 Mar 2026 10:13:47 +0200 Subject: [PATCH 3/5] Fix ~40 code quality issues across backend and frontend Backend: CancellationToken propagation, atomic ConcurrentDictionary updates, Cypher injection prevention via regex validation, authority override revocation. UI components: forwardRef type corrections, aria-hidden/aria-label a11y fixes, event listener cleanup, CSS sanitization for dangerouslySetInnerHTML, unique keys with index fallback, variant priority fix, displayName casing. Pages/hooks/stores: open redirect prevention, SSR hydration fix, timer cleanup, SignalR mounted guard, auth token expiry check, Array.isArray guard, crypto randomUUID replacing module counter, Zustand persist with versioned migration, devDependencies cleanup, dark-themed select options. Co-Authored-By: Claude Opus 4.6 --- .../Services/AgentRegistryService.cs | 6 +-- .../Services/AuthorityService.cs | 18 +++++-- .../CustomerIntelligenceManager.cs | 10 ++-- .../Services/NISTComplianceService.cs | 18 ++++--- src/UILayer/web/MIGRATION.md | 4 +- src/UILayer/web/package.json | 4 +- .../web/src/app/(app)/profile/page.tsx | 47 ++++++++++++++++--- .../app/(app)/settings/notifications/page.tsx | 2 +- .../web/src/app/(app)/settings/page.tsx | 14 ++++-- src/UILayer/web/src/app/login/page.tsx | 7 ++- .../web/src/components/Navigation/TopBar.tsx | 2 +- .../web/src/components/Nexus/index.tsx | 3 +- .../web/src/components/ParticleField.tsx | 4 +- .../agency/AgentActionAuditTrail.tsx | 3 +- src/UILayer/web/src/components/ui/alert.tsx | 6 +-- .../web/src/components/ui/breadcrumb.tsx | 7 ++- .../web/src/components/ui/carousel.tsx | 10 ++-- src/UILayer/web/src/components/ui/chart.tsx | 12 +++-- .../web/src/components/ui/collapsible.tsx | 4 +- src/UILayer/web/src/components/ui/form.tsx | 10 ++-- src/UILayer/web/src/components/ui/menubar.tsx | 2 +- .../web/src/components/ui/pagination.tsx | 3 +- .../web/src/components/ui/progress.tsx | 1 + .../web/src/components/ui/resizable.tsx | 1 + src/UILayer/web/src/components/ui/sidebar.tsx | 4 +- .../web/src/components/ui/toggle-group.tsx | 4 +- src/UILayer/web/src/contexts/AuthContext.tsx | 1 + src/UILayer/web/src/hooks/use-toast.ts | 2 +- src/UILayer/web/src/hooks/useSignalR.ts | 18 ++++--- src/UILayer/web/src/stores/useAgentStore.ts | 2 +- .../web/src/stores/useNotificationStore.ts | 4 +- .../web/src/stores/usePreferencesStore.ts | 7 +++ 32 files changed, 163 insertions(+), 77 deletions(-) diff --git a/src/BusinessApplications/AgentRegistry/Services/AgentRegistryService.cs b/src/BusinessApplications/AgentRegistry/Services/AgentRegistryService.cs index 566f6016..c9db295d 100644 --- a/src/BusinessApplications/AgentRegistry/Services/AgentRegistryService.cs +++ b/src/BusinessApplications/AgentRegistry/Services/AgentRegistryService.cs @@ -102,7 +102,7 @@ public async Task RegisterAgentAsync(AgentDefinition definition } /// - public async Task GetAgentByIdAsync(Guid agentId) + public async Task GetAgentByIdAsync(Guid agentId, CancellationToken cancellationToken = default) { try { @@ -110,7 +110,7 @@ public async Task GetAgentByIdAsync(Guid agentId) { var agent = await _dbContext.AgentDefinitions .AsNoTracking() - .FirstOrDefaultAsync(a => a.AgentId == agentId); + .FirstOrDefaultAsync(a => a.AgentId == agentId, cancellationToken); if (agent == null) { @@ -550,7 +550,7 @@ private string IncrementVersion(AgentDefinition agent) /// async Task IAgentRegistryPort.GetAgentByIdAsync(Guid agentId, string tenantId, CancellationToken cancellationToken) { - var definition = await GetAgentByIdAsync(agentId); + var definition = await GetAgentByIdAsync(agentId, cancellationToken); return MapToPortAgent(definition, tenantId); } diff --git a/src/BusinessApplications/AgentRegistry/Services/AuthorityService.cs b/src/BusinessApplications/AgentRegistry/Services/AuthorityService.cs index fc7b1b82..5c43e998 100644 --- a/src/BusinessApplications/AgentRegistry/Services/AuthorityService.cs +++ b/src/BusinessApplications/AgentRegistry/Services/AuthorityService.cs @@ -742,10 +742,22 @@ await OverrideAgentAuthorityAsync( } /// - Task IAuthorityPort.RevokeAuthorityOverrideAsync(Guid agentId, string action, string revokedBy, string tenantId) + async Task IAuthorityPort.RevokeAuthorityOverrideAsync(Guid agentId, string action, string revokedBy, string tenantId) { - _logger.LogWarning("RevokeAuthorityOverrideAsync is not yet implemented — override for agent {AgentId}, action {Action} was not revoked", agentId, action); - return Task.FromResult(false); + return await _circuitBreaker.ExecuteAsync(async () => + { + var activeOverride = await _dbContext.AuthorityOverrides + .Where(o => o.AgentId == agentId && o.TenantId == tenantId && o.IsActive) + .FirstOrDefaultAsync(); + + if (activeOverride == null) + { + _logger.LogWarning("No active authority override found for agent {AgentId}, action {Action} in tenant {TenantId}", agentId, action, tenantId); + return false; + } + + return await RevokeAuthorityOverrideAsync(activeOverride.OverrideToken, revokedBy); + }); } /// diff --git a/src/BusinessApplications/CustomerIntelligence/CustomerIntelligenceManager.cs b/src/BusinessApplications/CustomerIntelligence/CustomerIntelligenceManager.cs index 6c9b19be..0cf16468 100644 --- a/src/BusinessApplications/CustomerIntelligence/CustomerIntelligenceManager.cs +++ b/src/BusinessApplications/CustomerIntelligence/CustomerIntelligenceManager.cs @@ -68,10 +68,14 @@ public async Task GetCustomerProfileAsync( } // Enrich profile with knowledge graph relationships - // Escape single quotes to prevent Cypher injection - var safeCustomerId = customerId.Replace("'", "\\'", StringComparison.Ordinal); + // Validate customerId contains only safe characters (alphanumeric, hyphens, underscores) + if (!System.Text.RegularExpressions.Regex.IsMatch(customerId, @"^[\w\-]+$")) + { + throw new ArgumentException("Customer ID contains invalid characters", nameof(customerId)); + } + var relationships = await _knowledgeGraphManager.QueryAsync( - $"MATCH (c:Customer {{id: '{safeCustomerId}'}})-[r]->(s:Segment) RETURN s", + $"MATCH (c:Customer {{id: '{customerId}'}})-[r]->(s:Segment) RETURN s", cancellationToken).ConfigureAwait(false); foreach (var relation in relationships) diff --git a/src/BusinessApplications/NISTCompliance/Services/NISTComplianceService.cs b/src/BusinessApplications/NISTCompliance/Services/NISTComplianceService.cs index 76535310..a3d9f24c 100644 --- a/src/BusinessApplications/NISTCompliance/Services/NISTComplianceService.cs +++ b/src/BusinessApplications/NISTCompliance/Services/NISTComplianceService.cs @@ -220,13 +220,17 @@ public Task SubmitReviewAsync(NISTReviewRequest request, Can } var now = DateTimeOffset.UtcNow; - lock (record) - { - record.ReviewStatus = request.Decision; - record.ReviewedBy = request.ReviewerId; - record.ReviewedAt = now; - record.ReviewNotes = request.Notes; - } + _evidence.AddOrUpdate( + request.EvidenceId, + _ => throw new InvalidOperationException("Evidence record disappeared during review"), + (_, existing) => + { + existing.ReviewStatus = request.Decision; + existing.ReviewedBy = request.ReviewerId; + existing.ReviewedAt = now; + existing.ReviewNotes = request.Notes; + return existing; + }); AddAuditEntry("default-org", new NISTAuditEntry { diff --git a/src/UILayer/web/MIGRATION.md b/src/UILayer/web/MIGRATION.md index 1f8b6cd5..4be7f515 100644 --- a/src/UILayer/web/MIGRATION.md +++ b/src/UILayer/web/MIGRATION.md @@ -223,7 +223,7 @@ All moved from root `components/ui/` → `src/components/ui/`. Radix-UI deps ins | lib/service-worker/register | done | Fixed TS error | | lib/service-worker/offlineManager | done | | | lib/service-worker/index | done | | -| lib/visualizations/useD3 | done | D3 hook exists here (AgentNetworkGraph imports wrong path) | +| lib/visualizations/useD3 | done | D3 hook — all visualizations import from `@/lib/visualizations/useD3` | --- @@ -304,7 +304,7 @@ All moved from root `components/ui/` → `src/components/ui/`. Radix-UI deps ins | Tests | 1 | 1 | 0 | 0 | 0 | 0 | | **Totals** | **151** | **145** | **0** | **1** | **5** | **0** | -**Migration progress: 100% complete (0 TypeScript errors, Next.js 16 build passing)** +**Migration progress: 96% complete (145/151 items done, 0 TypeScript errors, Next.js 16 build passing)** ### Remaining legacy items (1) diff --git a/src/UILayer/web/package.json b/src/UILayer/web/package.json index c45f9ff7..15512d08 100644 --- a/src/UILayer/web/package.json +++ b/src/UILayer/web/package.json @@ -45,8 +45,6 @@ "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/postcss": "^4.2.1", - "@testing-library/dom": "^10.4.1", - "@types/uuid": "^11.0.0", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "^1.1.1", @@ -81,6 +79,7 @@ "@storybook/addon-links": "^10.2.17", "@storybook/react": "^10.2.17", "@storybook/react-webpack5": "^10.2.17", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", "@types/d3": "7.4.3", @@ -88,6 +87,7 @@ "@types/node": "24.12.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", + "@types/uuid": "^11.0.0", "axe-core": "4.11.1", "babel-jest": "29.7.0", "babel-loader": "10.1.1", diff --git a/src/UILayer/web/src/app/(app)/profile/page.tsx b/src/UILayer/web/src/app/(app)/profile/page.tsx index 59fe8697..b0012d8a 100644 --- a/src/UILayer/web/src/app/(app)/profile/page.tsx +++ b/src/UILayer/web/src/app/(app)/profile/page.tsx @@ -33,8 +33,21 @@ export default function ProfilePage() { const { user } = useAuth() const { privacyConsent, gdprConsents, setGdprConsent } = usePreferencesStore() - // Capture auth time once on mount so it doesn't change on re-renders - const authenticatedSince = useMemo(() => new Date().toLocaleString(), []) + // Show the user's auth time from the token, fallback to mount time + const authenticatedSince = useMemo(() => { + if (user?.id) { + const token = typeof localStorage !== "undefined" ? localStorage.getItem("cm_access_token") : null + if (token) { + try { + const payload = JSON.parse(atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))) + if (typeof payload.iat === "number") { + return new Date(payload.iat * 1000).toLocaleString() + } + } catch { /* fallthrough */ } + } + } + return new Date().toLocaleString() + }, [user?.id]) function isConsentGranted(type: string): boolean { const record = gdprConsents.find((c) => c.type === type) @@ -140,10 +153,24 @@ export default function ProfilePage() { function DataExportSection() { const [exportRequested, setExportRequested] = useState(false) + const [exporting, setExporting] = useState(false) + const [exportError, setExportError] = useState(null) - function handleDataExport() { - // TODO: Call POST /api/v1/compliance/gdpr/data-export - setExportRequested(true) + async function handleDataExport() { + setExporting(true) + setExportError(null) + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:5000"}/api/v1/compliance/gdpr/data-export`, + { method: "POST" } + ) + if (!res.ok) throw new Error(`Export request failed (${res.status})`) + setExportRequested(true) + } catch (err) { + setExportError(err instanceof Error ? err.message : "Export request failed") + } finally { + setExporting(false) + } } return ( @@ -152,6 +179,11 @@ function DataExportSection() {

Request a copy of all personal data stored in the system (GDPR Article 20).

+ {exportError && ( +
+ {exportError} +
+ )} {exportRequested ? (
Export request submitted. You will receive a download link via email. @@ -160,9 +192,10 @@ function DataExportSection() { )} diff --git a/src/UILayer/web/src/app/(app)/settings/notifications/page.tsx b/src/UILayer/web/src/app/(app)/settings/notifications/page.tsx index 2eda0799..95e4a171 100644 --- a/src/UILayer/web/src/app/(app)/settings/notifications/page.tsx +++ b/src/UILayer/web/src/app/(app)/settings/notifications/page.tsx @@ -158,7 +158,7 @@ export default function NotificationPreferencesPage() { className="w-full rounded bg-white/10 px-3 py-1.5 text-sm text-white outline-none focus-visible:ring-2 focus-visible:ring-cyan-500" > {TIMEZONES.map((tz) => ( - ))} diff --git a/src/UILayer/web/src/app/(app)/settings/page.tsx b/src/UILayer/web/src/app/(app)/settings/page.tsx index a0730d4b..381d02f7 100644 --- a/src/UILayer/web/src/app/(app)/settings/page.tsx +++ b/src/UILayer/web/src/app/(app)/settings/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useId, useState } from "react" +import { useEffect, useId, useRef, useState } from "react" import Link from "next/link" import { usePreferencesStore } from "@/stores" import { ToggleRow } from "@/components/ui/toggle-switch" @@ -31,6 +31,13 @@ export default function SettingsPage() { } = usePreferencesStore() const [saved, setSaved] = useState(false) + const saveTimerRef = useRef>(undefined) + + useEffect(() => { + return () => { + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + } + }, []) function handleLanguageChange(lang: SupportedLanguage) { setLanguage(lang) @@ -48,7 +55,8 @@ export default function SettingsPage() { // Preferences are already persisted to localStorage via Zustand persist. // TODO: Sync to backend user preferences API. setSaved(true) - setTimeout(() => setSaved(false), 2000) + if (saveTimerRef.current) clearTimeout(saveTimerRef.current) + saveTimerRef.current = setTimeout(() => setSaved(false), 2000) } return ( @@ -212,7 +220,7 @@ function SelectRow({ className="rounded bg-white/10 px-3 py-1.5 text-sm text-white outline-none focus-visible:ring-2 focus-visible:ring-cyan-500" > {options.map((opt) => ( - ))} diff --git a/src/UILayer/web/src/app/login/page.tsx b/src/UILayer/web/src/app/login/page.tsx index 9496dcfd..daedd09b 100644 --- a/src/UILayer/web/src/app/login/page.tsx +++ b/src/UILayer/web/src/app/login/page.tsx @@ -6,10 +6,13 @@ import { useRouter, useSearchParams } from "next/navigation" import { FormEvent, useEffect, useState } from "react" function sanitizeReturnTo(value: string | null): string { - if (!value || !value.startsWith("/") || value.startsWith("//") || value.includes("://")) { + if (!value) return "/" + // Normalize backslashes to forward slashes to prevent open redirects (e.g. /\evil.com) + const normalized = value.replace(/\\/g, "/") + if (!normalized.startsWith("/") || normalized.startsWith("//") || normalized.includes("://")) { return "/" } - return value + return normalized } function LoginForm() { diff --git a/src/UILayer/web/src/components/Navigation/TopBar.tsx b/src/UILayer/web/src/components/Navigation/TopBar.tsx index 779eb1a7..a0a15f9b 100644 --- a/src/UILayer/web/src/components/Navigation/TopBar.tsx +++ b/src/UILayer/web/src/components/Navigation/TopBar.tsx @@ -22,7 +22,7 @@ export function TopBar() { {/* TODO: implement notification panel onClick handler */} +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Adaptive Balance

+ {balance && ( +

+ Confidence: {(balance.overallConfidence * 100).toFixed(0)}% ·{' '} + Generated {new Date(balance.generatedAt).toLocaleDateString()} +

+ )} +
+ +
+ + {/* Spectrum sliders */} + {balance && ( +
+

Spectrum Positions

+ +
+ )} + + {/* Dimension selector + history */} + {balance && balance.dimensions.length > 0 && ( +
+
+

History

+ +
+ {dimHistory ? ( + + ) : ( +

Select a dimension to view history.

+ )} +
+ )} + + {/* Reflexion status */} + {reflexion && ( +
+

Reflexion System Status

+
+
+

Hallucination Rate

+

+ {(reflexion.hallucinationRate * 100).toFixed(1)}% +

+
+
+

Average Confidence

+

+ {(reflexion.averageConfidence * 100).toFixed(1)}% +

+
+
+ {reflexion.recentResults.length > 0 && ( +
+

Recent Evaluations

+
+ {reflexion.recentResults.slice(0, 5).map((r) => ( +
+ {r.result} + + {(r.confidence * 100).toFixed(0)}% ·{' '} + {new Date(r.timestamp).toLocaleDateString()} + +
+ ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/UILayer/web/src/components/widgets/AdaptiveBalance/BalanceHistory.tsx b/src/UILayer/web/src/components/widgets/AdaptiveBalance/BalanceHistory.tsx new file mode 100644 index 00000000..9f162ce4 --- /dev/null +++ b/src/UILayer/web/src/components/widgets/AdaptiveBalance/BalanceHistory.tsx @@ -0,0 +1,106 @@ +'use client'; + +import React, { useEffect, useCallback } from 'react'; +import * as d3 from 'd3'; +import { useD3 } from '@/lib/visualizations/useD3'; +import type { SpectrumHistoryEntry } from '../types'; + +interface BalanceHistoryProps { + dimension: string; + history: SpectrumHistoryEntry[]; +} + +/** + * D3 line chart showing historical balance adjustments for a given dimension. + */ +export default function BalanceHistory({ dimension, history }: BalanceHistoryProps) { + const render = useCallback( + (width: number, height: number) => { + const svg = d3.select(svgRef.current); + if (!svg.node()) return; + svg.selectAll('*').remove(); + + if (history.length === 0) { + svg + .append('text') + .attr('x', width / 2) + .attr('y', height / 2) + .attr('text-anchor', 'middle') + .attr('fill', 'rgb(107,114,128)') + .attr('font-size', 12) + .text(`No history for ${dimension}`); + return; + } + + const margin = { top: 16, right: 16, bottom: 32, left: 40 }; + const innerW = width - margin.left - margin.right; + const innerH = height - margin.top - margin.bottom; + + const dates = history.map((h) => new Date(h.timestamp)); + const x = d3 + .scaleTime() + .domain(d3.extent(dates) as [Date, Date]) + .range([0, innerW]); + const y = d3.scaleLinear().domain([0, 1]).range([innerH, 0]); + + const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`); + + // Axes + g.append('g') + .attr('transform', `translate(0,${innerH})`) + .call(d3.axisBottom(x).ticks(5).tickFormat(d3.timeFormat('%b %d') as (d: Date | d3.NumberValue) => string)) + .selectAll('text') + .attr('fill', 'rgb(156,163,175)') + .attr('font-size', 10); + g.append('g') + .call(d3.axisLeft(y).ticks(5)) + .selectAll('text') + .attr('fill', 'rgb(156,163,175)') + .attr('font-size', 10); + g.selectAll('.domain, line').attr('stroke', 'rgba(255,255,255,0.15)'); + + // Line + const line = d3 + .line() + .x((d) => x(new Date(d.timestamp))) + .y((d) => y(d.value)) + .curve(d3.curveMonotoneX); + + g.append('path') + .datum(history) + .attr('fill', 'none') + .attr('stroke', '#3b82f6') + .attr('stroke-width', 2) + .attr('d', line); + + // Dots + g.selectAll('circle') + .data(history) + .join('circle') + .attr('cx', (d) => x(new Date(d.timestamp))) + .attr('cy', (d) => y(d.value)) + .attr('r', 3) + .attr('fill', '#3b82f6'); + }, + [dimension, history], + ); + + const { svgRef } = useD3(render); + + useEffect(() => { + const el = svgRef.current; + if (el) { + const { width, height } = el.getBoundingClientRect(); + if (width > 0 && height > 0) render(width, height); + } + }, [render, svgRef]); + + return ( + + ); +} diff --git a/src/UILayer/web/src/components/widgets/AdaptiveBalance/SpectrumSlider.tsx b/src/UILayer/web/src/components/widgets/AdaptiveBalance/SpectrumSlider.tsx new file mode 100644 index 00000000..59378da5 --- /dev/null +++ b/src/UILayer/web/src/components/widgets/AdaptiveBalance/SpectrumSlider.tsx @@ -0,0 +1,59 @@ +'use client'; + +import React from 'react'; +import type { SpectrumDimensionResult } from '../types'; + +interface SpectrumSliderProps { + dimensions: SpectrumDimensionResult[]; +} + +function confidenceColor(value: number): string { + if (value >= 0.7) return 'bg-emerald-500'; + if (value >= 0.4) return 'bg-yellow-500'; + return 'bg-red-500'; +} + +/** + * Visual slider showing balance between autonomy and control for each + * spectrum dimension. Each dimension is rendered as a horizontal slider + * with confidence bounds. + */ +export default function SpectrumSlider({ dimensions }: SpectrumSliderProps) { + if (dimensions.length === 0) { + return

No spectrum dimensions available.

; + } + + return ( +
+ {dimensions.map((dim) => { + const pct = dim.value * 100; + const lowerPct = dim.lowerBound * 100; + const upperPct = dim.upperBound * 100; + return ( +
+
+ {dim.dimension} + {(dim.value * 100).toFixed(0)}% +
+
+ {/* Confidence band */} +
+ {/* Current value marker */} +
+
+ {dim.rationale && ( +

{dim.rationale}

+ )} +
+ ); + })} +
+ ); +} diff --git a/src/UILayer/web/src/components/widgets/AdaptiveBalance/index.ts b/src/UILayer/web/src/components/widgets/AdaptiveBalance/index.ts new file mode 100644 index 00000000..f9ba8ee7 --- /dev/null +++ b/src/UILayer/web/src/components/widgets/AdaptiveBalance/index.ts @@ -0,0 +1,3 @@ +export { default as AdaptiveBalanceDashboard } from './AdaptiveBalanceDashboard'; +export { default as SpectrumSlider } from './SpectrumSlider'; +export { default as BalanceHistory } from './BalanceHistory'; diff --git a/src/UILayer/web/src/components/widgets/CognitiveSandwich/BurndownChart.tsx b/src/UILayer/web/src/components/widgets/CognitiveSandwich/BurndownChart.tsx new file mode 100644 index 00000000..8a9057c2 --- /dev/null +++ b/src/UILayer/web/src/components/widgets/CognitiveSandwich/BurndownChart.tsx @@ -0,0 +1,88 @@ +'use client'; + +import React, { useEffect, useCallback } from 'react'; +import * as d3 from 'd3'; +import { useD3 } from '@/lib/visualizations/useD3'; +import type { PhaseAuditEntry } from '../types'; + +interface BurndownChartProps { + totalPhases: number; + auditEntries: PhaseAuditEntry[]; +} + +/** + * D3 burndown chart showing Cognitive Sandwich process progress over time. + * Plots completed phases against the timeline derived from audit entries. + */ +export default function BurndownChart({ totalPhases, auditEntries }: BurndownChartProps) { + const render = useCallback( + (width: number, height: number) => { + const svg = d3.select(svgRef.current); + if (!svg.node()) return; + svg.selectAll('*').remove(); + + if (auditEntries.length === 0 || totalPhases === 0) { + svg.append('text').attr('x', width / 2).attr('y', height / 2).attr('text-anchor', 'middle').attr('fill', 'rgb(107,114,128)').attr('font-size', 12).text('No burndown data available.'); + return; + } + + const margin = { top: 16, right: 16, bottom: 32, left: 40 }; + const innerW = width - margin.left - margin.right; + const innerH = height - margin.top - margin.bottom; + + // Build burndown data: remaining phases over time + const sorted = [...auditEntries].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + let remaining = totalPhases; + const points = sorted.map((e) => { + if (e.eventType.includes('Completed') || e.eventType.includes('Transition')) { + remaining = Math.max(0, remaining - 1); + } + return { date: new Date(e.timestamp), remaining }; + }); + + // Add start point + points.unshift({ date: new Date(sorted[0].timestamp), remaining: totalPhases }); + + const dates = points.map((p) => p.date); + const x = d3.scaleTime().domain(d3.extent(dates) as [Date, Date]).range([0, innerW]); + const y = d3.scaleLinear().domain([0, totalPhases]).range([innerH, 0]); + + const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`); + + // Axes + g.append('g').attr('transform', `translate(0,${innerH})`).call(d3.axisBottom(x).ticks(5).tickFormat(d3.timeFormat('%b %d') as (d: Date | d3.NumberValue) => string)).selectAll('text').attr('fill', 'rgb(156,163,175)').attr('font-size', 10); + g.append('g').call(d3.axisLeft(y).ticks(totalPhases)).selectAll('text').attr('fill', 'rgb(156,163,175)').attr('font-size', 10); + g.selectAll('.domain, line').attr('stroke', 'rgba(255,255,255,0.15)'); + + // Ideal line + if (points.length >= 2) { + g.append('line') + .attr('x1', x(dates[0])) + .attr('y1', y(totalPhases)) + .attr('x2', x(dates[dates.length - 1])) + .attr('y2', y(0)) + .attr('stroke', 'rgba(255,255,255,0.15)') + .attr('stroke-dasharray', '5,5'); + } + + // Actual burndown line + const line = d3.line<{ date: Date; remaining: number }>().x((d) => x(d.date)).y((d) => y(d.remaining)).curve(d3.curveStepAfter); + g.append('path').datum(points).attr('fill', 'none').attr('stroke', '#f59e0b').attr('stroke-width', 2).attr('d', line); + + g.selectAll('circle').data(points).join('circle').attr('cx', (d) => x(d.date)).attr('cy', (d) => y(d.remaining)).attr('r', 3).attr('fill', '#f59e0b'); + }, + [totalPhases, auditEntries], + ); + + const { svgRef } = useD3(render); + + useEffect(() => { + const el = svgRef.current; + if (el) { + const { width, height } = el.getBoundingClientRect(); + if (width > 0 && height > 0) render(width, height); + } + }, [render, svgRef]); + + return ; +} diff --git a/src/UILayer/web/src/components/widgets/CognitiveSandwich/CognitiveSandwichDashboard.tsx b/src/UILayer/web/src/components/widgets/CognitiveSandwich/CognitiveSandwichDashboard.tsx new file mode 100644 index 00000000..356c11ef --- /dev/null +++ b/src/UILayer/web/src/components/widgets/CognitiveSandwich/CognitiveSandwichDashboard.tsx @@ -0,0 +1,131 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import PhaseStepper from './PhaseStepper'; +import BurndownChart from './BurndownChart'; +import { getSandwichProcess, getSandwichAuditTrail, getSandwichDebt } from '../api'; +import type { SandwichProcess, PhaseAuditEntry, CognitiveDebtAssessment } from '../types'; + +interface CognitiveSandwichDashboardProps { + processId?: string; +} + +export default function CognitiveSandwichDashboard({ processId = 'default-process' }: CognitiveSandwichDashboardProps) { + const [process, setProcess] = useState(null); + const [audit, setAudit] = useState([]); + const [debt, setDebt] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [proc, trail, debtData] = await Promise.all([ + getSandwichProcess(processId), + getSandwichAuditTrail(processId), + getSandwichDebt(processId), + ]); + setProcess(proc); + setAudit(trail); + setDebt(debtData); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load Cognitive Sandwich data.'); + } finally { + setLoading(false); + } + }, [processId]); + + useEffect(() => { void fetchData(); }, [fetchData]); + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (error) { + return ( +
+

Error loading sandwich data

+

{error}

+ +
+ ); + } + + return ( +
+
+
+

Cognitive Sandwich

+ {process &&

{process.name} · State: {process.state}

} +
+ +
+ + {process && ( + <> +
+

Phase Progression

+ +
+ +
+
+

Phases

+

{process.currentPhaseIndex + 1} / {process.phases.length}

+
+
+

Step-backs

+

{process.stepBackCount} / {process.maxStepBacks}

+
+
+

Debt Threshold

+

{process.cognitiveDebtThreshold}

+
+
+

Current Debt

+

+ {debt ? debt.debtScore.toFixed(1) : '-'} +

+
+
+ + )} + + {debt && debt.recommendations.length > 0 && ( +
+

Debt Reduction Recommendations

+
    + {debt.recommendations.map((r, i) =>
  • {r}
  • )} +
+
+ )} + +
+

Burndown

+ +
+ + {audit.length > 0 && ( +
+

Audit Trail

+
+ {audit.map((e) => ( +
+ {new Date(e.timestamp).toLocaleString()} + {e.eventType} + {e.details} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/UILayer/web/src/components/widgets/CognitiveSandwich/PhaseStepper.tsx b/src/UILayer/web/src/components/widgets/CognitiveSandwich/PhaseStepper.tsx new file mode 100644 index 00000000..49bf6b87 --- /dev/null +++ b/src/UILayer/web/src/components/widgets/CognitiveSandwich/PhaseStepper.tsx @@ -0,0 +1,57 @@ +'use client'; + +import React from 'react'; +import type { Phase } from '../types'; + +interface PhaseStepperProps { + phases: Phase[]; + currentPhaseIndex: number; +} + +function phaseTypeIcon(phaseType: string): string { + const lower = phaseType.toLowerCase(); + if (lower.includes('human')) return 'H'; + if (lower.includes('ai') || lower.includes('machine')) return 'AI'; + return '?'; +} + +function statusColor(status: string, isCurrent: boolean): string { + if (isCurrent) return 'border-blue-500 bg-blue-500/20 text-blue-400'; + if (status === 'Completed') return 'border-green-500 bg-green-500/20 text-green-400'; + if (status === 'Skipped') return 'border-gray-600 bg-gray-600/20 text-gray-500'; + return 'border-white/20 bg-white/5 text-gray-500'; +} + +/** + * Visual phase progression stepper for the Cognitive Sandwich pattern + * (Human -> AI -> Human). + */ +export default function PhaseStepper({ phases, currentPhaseIndex }: PhaseStepperProps) { + if (phases.length === 0) { + return

No phases defined.

; + } + + return ( +
+ {phases.map((phase, i) => { + const isCurrent = i === currentPhaseIndex; + return ( + + {i > 0 && ( +
+ )} +
+ {phaseTypeIcon(phase.phaseType)} + {phase.phaseName} + {phase.status} +
+ + ); + })} +
+ ); +} diff --git a/src/UILayer/web/src/components/widgets/CognitiveSandwich/index.ts b/src/UILayer/web/src/components/widgets/CognitiveSandwich/index.ts new file mode 100644 index 00000000..5d73a9f9 --- /dev/null +++ b/src/UILayer/web/src/components/widgets/CognitiveSandwich/index.ts @@ -0,0 +1,3 @@ +export { default as CognitiveSandwichDashboard } from './CognitiveSandwichDashboard'; +export { default as PhaseStepper } from './PhaseStepper'; +export { default as BurndownChart } from './BurndownChart'; diff --git a/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactMetricsDashboard.tsx b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactMetricsDashboard.tsx new file mode 100644 index 00000000..aa11e896 --- /dev/null +++ b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactMetricsDashboard.tsx @@ -0,0 +1,120 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import SafetyGauge from './SafetyGauge'; +import ImpactRadar from './ImpactRadar'; +import ImpactTimeline from './ImpactTimeline'; +import { getImpactReport, getResistancePatterns } from '../api'; +import type { ImpactReport, ResistanceIndicator } from '../types'; + +interface ImpactMetricsDashboardProps { + tenantId?: string; +} + +export default function ImpactMetricsDashboard({ tenantId = 'default-tenant' }: ImpactMetricsDashboardProps) { + const [report, setReport] = useState(null); + const [resistance, setResistance] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [rep, res] = await Promise.all([ + getImpactReport(tenantId), + getResistancePatterns(tenantId), + ]); + setReport(rep); + setResistance(res); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load impact metrics.'); + } finally { + setLoading(false); + } + }, [tenantId]); + + useEffect(() => { void fetchData(); }, [fetchData]); + + if (loading) { + return ( +
+
+
+ {[1, 2, 3].map((k) =>
)} +
+
+
+ ); + } + + if (error) { + return ( +
+

Error loading impact metrics

+

{error}

+ +
+ ); + } + + const radarLabels: string[] = []; + const radarValues: number[] = []; + if (report) { + radarLabels.push('Safety', 'Alignment', 'Adoption', 'Overall'); + radarValues.push(report.safetyScore, report.alignmentScore * 100, report.adoptionRate * 100, report.overallImpactScore); + } + + return ( +
+
+
+

Impact Metrics

+ {report &&

Report generated {new Date(report.generatedAt).toLocaleDateString()}

} +
+ +
+ + {report && ( + <> +
+
+ +
+
+

Alignment

+

{(report.alignmentScore * 100).toFixed(0)}%

+
+
+

Adoption Rate

+

{(report.adoptionRate * 100).toFixed(0)}%

+
+
+

Overall Impact

+

{report.overallImpactScore.toFixed(0)}

+
+
+ +
+

Impact Dimensions

+ +
+ + {report.recommendations.length > 0 && ( +
+

Recommendations

+
    + {report.recommendations.map((r, i) =>
  • {r}
  • )} +
+
+ )} + + )} + +
+

Resistance Patterns

+ +
+
+ ); +} diff --git a/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactRadar.tsx b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactRadar.tsx new file mode 100644 index 00000000..28c9afe7 --- /dev/null +++ b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactRadar.tsx @@ -0,0 +1,119 @@ +'use client'; + +import React, { useEffect, useCallback } from 'react'; +import * as d3 from 'd3'; +import { useD3 } from '@/lib/visualizations/useD3'; + +interface ImpactRadarProps { + /** Dimension labels. */ + labels: string[]; + /** Corresponding values (0-100 scale). */ + values: number[]; +} + +/** + * D3 radar chart for impact metric dimensions (safety, alignment, adoption, etc.). + */ +export default function ImpactRadar({ labels, values }: ImpactRadarProps) { + const render = useCallback( + (width: number, height: number) => { + const svg = d3.select(svgRef.current); + if (!svg.node()) return; + svg.selectAll('*').remove(); + + if (labels.length === 0) { + svg + .append('text') + .attr('x', width / 2) + .attr('y', height / 2) + .attr('text-anchor', 'middle') + .attr('fill', 'rgb(107,114,128)') + .attr('font-size', 12) + .text('No impact data available.'); + return; + } + + const cx = width / 2; + const cy = height / 2; + const maxRadius = Math.min(cx, cy) - 36; + const numAxes = labels.length; + const angleSlice = (Math.PI * 2) / numAxes; + const rScale = d3.scaleLinear().domain([0, 100]).range([0, maxRadius]); + + const g = svg.append('g').attr('transform', `translate(${cx},${cy})`); + + // Grid + const levels = 4; + for (let lvl = 1; lvl <= levels; lvl++) { + const r = (maxRadius / levels) * lvl; + g.append('circle') + .attr('r', r) + .attr('fill', 'none') + .attr('stroke', 'rgba(255,255,255,0.08)') + .attr('stroke-dasharray', '2,3'); + } + + // Axes + labels + labels.forEach((label, i) => { + const angle = angleSlice * i - Math.PI / 2; + g.append('line') + .attr('x2', maxRadius * Math.cos(angle)) + .attr('y2', maxRadius * Math.sin(angle)) + .attr('stroke', 'rgba(255,255,255,0.08)'); + + const labelR = maxRadius + 18; + g.append('text') + .attr('x', labelR * Math.cos(angle)) + .attr('y', labelR * Math.sin(angle)) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .attr('fill', 'rgb(156,163,175)') + .attr('font-size', 9) + .text(label); + }); + + // Polygon + const lineGen = d3 + .lineRadial() + .angle((_, i) => angleSlice * i) + .radius((d) => rScale(d)) + .curve(d3.curveLinearClosed); + + g.append('path') + .datum(values) + .attr('d', lineGen) + .attr('fill', 'rgba(34,197,94,0.2)') + .attr('stroke', '#22c55e') + .attr('stroke-width', 2); + + values.forEach((val, i) => { + const angle = angleSlice * i - Math.PI / 2; + g.append('circle') + .attr('cx', rScale(val) * Math.cos(angle)) + .attr('cy', rScale(val) * Math.sin(angle)) + .attr('r', 3.5) + .attr('fill', '#22c55e'); + }); + }, + [labels, values], + ); + + const { svgRef } = useD3(render); + + useEffect(() => { + const el = svgRef.current; + if (el) { + const { width, height } = el.getBoundingClientRect(); + if (width > 0 && height > 0) render(width, height); + } + }, [render, svgRef]); + + return ( + + ); +} diff --git a/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactTimeline.tsx b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactTimeline.tsx new file mode 100644 index 00000000..8eb91fda --- /dev/null +++ b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactTimeline.tsx @@ -0,0 +1,36 @@ +'use client'; + +import React from 'react'; +import type { ResistanceIndicator } from '../types'; + +interface ImpactTimelineProps { + indicators: ResistanceIndicator[]; +} + +function severityBadgeClass(severity: string): string { + if (severity === 'High') return 'bg-red-500/20 text-red-400'; + if (severity === 'Medium') return 'bg-yellow-500/20 text-yellow-400'; + return 'bg-gray-500/20 text-gray-400'; +} + +export default function ImpactTimeline({ indicators }: ImpactTimelineProps) { + if (indicators.length === 0) { + return

No resistance patterns detected.

; + } + return ( +
+ {indicators.map((ind) => ( +
+
+
+
+ {ind.pattern} + {ind.severity} +
+

{ind.affectedUsers} affected users - {new Date(ind.detectedAt).toLocaleDateString()}

+
+
+ ))} +
+ ); +} diff --git a/src/UILayer/web/src/components/widgets/ImpactMetrics/SafetyGauge.tsx b/src/UILayer/web/src/components/widgets/ImpactMetrics/SafetyGauge.tsx new file mode 100644 index 00000000..bde5535c --- /dev/null +++ b/src/UILayer/web/src/components/widgets/ImpactMetrics/SafetyGauge.tsx @@ -0,0 +1,103 @@ +'use client'; + +import React, { useEffect, useCallback } from 'react'; +import * as d3 from 'd3'; +import { useD3 } from '@/lib/visualizations/useD3'; + +interface SafetyGaugeProps { + /** Safety score on a 0-100 scale. */ + score: number; + /** Label displayed below the gauge. */ + label?: string; +} + +function gaugeColor(score: number): string { + if (score >= 75) return '#22c55e'; + if (score >= 50) return '#3b82f6'; + if (score >= 25) return '#f59e0b'; + return '#ef4444'; +} + +/** + * D3 half-circle gauge for a psychological safety score (0-100). + */ +export default function SafetyGauge({ score, label }: SafetyGaugeProps) { + const render = useCallback( + (width: number, height: number) => { + const svg = d3.select(svgRef.current); + if (!svg.node()) return; + svg.selectAll('*').remove(); + + const size = Math.min(width, height); + const cx = width / 2; + const cy = height * 0.6; + const outerRadius = size * 0.42; + const innerRadius = outerRadius * 0.72; + + const startAngle = -Math.PI / 2; + const endAngle = Math.PI / 2; + const range = endAngle - startAngle; + const clampedScore = Math.max(0, Math.min(100, score)); + const valueAngle = startAngle + (clampedScore / 100) * range; + + const arc = d3.arc().innerRadius(innerRadius).outerRadius(outerRadius).cornerRadius(4); + + // Background arc + svg + .append('path') + .attr('transform', `translate(${cx},${cy})`) + .attr('d', arc({ startAngle, endAngle } as never) as string) + .attr('fill', 'rgba(255,255,255,0.1)'); + + // Value arc + svg + .append('path') + .attr('transform', `translate(${cx},${cy})`) + .attr('d', arc({ startAngle, endAngle: valueAngle } as never) as string) + .attr('fill', gaugeColor(clampedScore)); + + // Center text + svg + .append('text') + .attr('x', cx) + .attr('y', cy - 2) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'auto') + .attr('fill', 'white') + .attr('font-size', size * 0.15) + .attr('font-weight', '700') + .text(Math.round(clampedScore)); + + if (label) { + svg + .append('text') + .attr('x', cx) + .attr('y', cy + size * 0.12) + .attr('text-anchor', 'middle') + .attr('fill', 'rgb(156,163,175)') + .attr('font-size', size * 0.06) + .text(label); + } + }, + [score, label], + ); + + const { svgRef } = useD3(render); + + useEffect(() => { + const el = svgRef.current; + if (el) { + const { width, height } = el.getBoundingClientRect(); + if (width > 0 && height > 0) render(width, height); + } + }, [render, svgRef]); + + return ( + + ); +} diff --git a/src/UILayer/web/src/components/widgets/ImpactMetrics/index.ts b/src/UILayer/web/src/components/widgets/ImpactMetrics/index.ts new file mode 100644 index 00000000..a0f955ee --- /dev/null +++ b/src/UILayer/web/src/components/widgets/ImpactMetrics/index.ts @@ -0,0 +1,4 @@ +export { default as ImpactMetricsDashboard } from './ImpactMetricsDashboard'; +export { default as SafetyGauge } from './SafetyGauge'; +export { default as ImpactRadar } from './ImpactRadar'; +export { default as ImpactTimeline } from './ImpactTimeline'; diff --git a/src/UILayer/web/src/components/widgets/NistCompliance/ComplianceTimeline.tsx b/src/UILayer/web/src/components/widgets/NistCompliance/ComplianceTimeline.tsx new file mode 100644 index 00000000..08c1d51d --- /dev/null +++ b/src/UILayer/web/src/components/widgets/NistCompliance/ComplianceTimeline.tsx @@ -0,0 +1,95 @@ +'use client'; + +import React, { useEffect, useCallback } from 'react'; +import * as d3 from 'd3'; +import { useD3 } from '@/lib/visualizations/useD3'; +import type { NISTAuditEntry } from '../types'; + +interface ComplianceTimelineProps { + entries: NISTAuditEntry[]; +} + +/** + * D3 timeline of NIST compliance audit events. + */ +export default function ComplianceTimeline({ entries }: ComplianceTimelineProps) { + const render = useCallback( + (width: number, height: number) => { + const svg = d3.select(svgRef.current); + if (!svg.node()) return; + svg.selectAll('*').remove(); + + if (entries.length === 0) { + svg + .append('text') + .attr('x', width / 2) + .attr('y', height / 2) + .attr('text-anchor', 'middle') + .attr('fill', 'rgb(107,114,128)') + .attr('font-size', 12) + .text('No audit events to display.'); + return; + } + + const margin = { top: 20, right: 20, bottom: 40, left: 60 }; + const innerW = width - margin.left - margin.right; + const innerH = height - margin.top - margin.bottom; + + const dates = entries.map((e) => new Date(e.performedAt)); + const xExtent = d3.extent(dates) as [Date, Date]; + const x = d3.scaleTime().domain(xExtent).range([0, innerW]).nice(); + + const actions = [...new Set(entries.map((e) => e.action))]; + const y = d3.scaleBand().domain(actions).range([0, innerH]).padding(0.4); + + const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`); + + // X axis + g.append('g') + .attr('transform', `translate(0,${innerH})`) + .call(d3.axisBottom(x).ticks(5).tickFormat(d3.timeFormat('%b %d') as (d: Date | d3.NumberValue) => string)) + .selectAll('text') + .attr('fill', 'rgb(156,163,175)') + .attr('font-size', 10); + g.selectAll('.domain, line').attr('stroke', 'rgba(255,255,255,0.15)'); + + // Y axis + g.append('g') + .call(d3.axisLeft(y)) + .selectAll('text') + .attr('fill', 'rgb(156,163,175)') + .attr('font-size', 10); + + // Dots + const color = d3.scaleOrdinal(d3.schemeTableau10).domain(actions); + g.selectAll('circle') + .data(entries) + .join('circle') + .attr('cx', (d) => x(new Date(d.performedAt))) + .attr('cy', (d) => (y(d.action) ?? 0) + y.bandwidth() / 2) + .attr('r', 5) + .attr('fill', (d) => color(d.action)) + .attr('opacity', 0.85); + }, + [entries], + ); + + const { svgRef } = useD3(render); + + useEffect(() => { + const el = svgRef.current; + if (el) { + const { width, height } = el.getBoundingClientRect(); + if (width > 0 && height > 0) render(width, height); + } + }, [render, svgRef]); + + return ( + + ); +} diff --git a/src/UILayer/web/src/components/widgets/NistCompliance/GapAnalysisTable.tsx b/src/UILayer/web/src/components/widgets/NistCompliance/GapAnalysisTable.tsx new file mode 100644 index 00000000..d00a57ab --- /dev/null +++ b/src/UILayer/web/src/components/widgets/NistCompliance/GapAnalysisTable.tsx @@ -0,0 +1,125 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import type { NISTGapItem } from '../types'; + +interface GapAnalysisTableProps { + gaps: NISTGapItem[]; +} + +type SortField = 'statementId' | 'currentScore' | 'targetScore' | 'priority'; +type SortDir = 'asc' | 'desc'; + +const PRIORITY_ORDER: Record = { + Critical: 0, + High: 1, + Medium: 2, + Low: 3, +}; + +function priorityColor(p: string): string { + switch (p) { + case 'Critical': + return 'text-red-400'; + case 'High': + return 'text-orange-400'; + case 'Medium': + return 'text-yellow-400'; + default: + return 'text-gray-400'; + } +} + +/** + * Sortable table displaying NIST compliance gap items. + */ +export default function GapAnalysisTable({ gaps }: GapAnalysisTableProps) { + const [sortField, setSortField] = useState('priority'); + const [sortDir, setSortDir] = useState('asc'); + + const sorted = useMemo(() => { + const copy = [...gaps]; + copy.sort((a, b) => { + let cmp = 0; + switch (sortField) { + case 'statementId': + cmp = a.statementId.localeCompare(b.statementId); + break; + case 'currentScore': + cmp = a.currentScore - b.currentScore; + break; + case 'targetScore': + cmp = a.targetScore - b.targetScore; + break; + case 'priority': + cmp = (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99); + break; + } + return sortDir === 'asc' ? cmp : -cmp; + }); + return copy; + }, [gaps, sortField, sortDir]); + + function handleSort(field: SortField) { + if (field === sortField) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortField(field); + setSortDir('asc'); + } + } + + function renderHeader(label: string, field: SortField) { + const active = sortField === field; + const arrow = active ? (sortDir === 'asc' ? ' \u25B2' : ' \u25BC') : ''; + return ( + handleSort(field)} + > + {label} + {arrow} + + ); + } + + if (gaps.length === 0) { + return

No compliance gaps identified.

; + } + + return ( +
+ + + + {renderHeader('Statement', 'statementId')} + {renderHeader('Current', 'currentScore')} + {renderHeader('Target', 'targetScore')} + {renderHeader('Priority', 'priority')} + + + + + {sorted.map((gap) => ( + + + + + + + + ))} + +
+ Actions +
{gap.statementId}{gap.currentScore}{gap.targetScore}{gap.priority} +
    + {gap.recommendedActions.map((action, i) => ( +
  • {action}
  • + ))} +
+
+
+ ); +} diff --git a/src/UILayer/web/src/components/widgets/NistCompliance/MaturityGauge.tsx b/src/UILayer/web/src/components/widgets/NistCompliance/MaturityGauge.tsx new file mode 100644 index 00000000..c7d8179d --- /dev/null +++ b/src/UILayer/web/src/components/widgets/NistCompliance/MaturityGauge.tsx @@ -0,0 +1,124 @@ +'use client'; + +import React, { useEffect, useCallback } from 'react'; +import * as d3 from 'd3'; +import { useD3 } from '@/lib/visualizations/useD3'; + +interface MaturityGaugeProps { + /** Overall maturity score (0-5 scale). */ + score: number; + /** Label displayed below the gauge. */ + label?: string; +} + +const MATURITY_LEVELS = ['Partial', 'Risk Informed', 'Repeatable', 'Adaptive', 'Optimal'] as const; + +function getMaturityLabel(score: number): string { + if (score < 1) return MATURITY_LEVELS[0]; + if (score < 2) return MATURITY_LEVELS[1]; + if (score < 3) return MATURITY_LEVELS[2]; + if (score < 4) return MATURITY_LEVELS[3]; + return MATURITY_LEVELS[4]; +} + +function getGaugeColor(score: number): string { + if (score < 1.5) return '#ef4444'; + if (score < 2.5) return '#f59e0b'; + if (score < 3.5) return '#3b82f6'; + return '#22c55e'; +} + +/** + * D3 radial gauge visualizing a NIST maturity score on a 0-5 scale. + */ +export default function MaturityGauge({ score, label }: MaturityGaugeProps) { + const render = useCallback( + (width: number, height: number) => { + const svg = d3.select(svgRef.current); + if (!svg.node()) return; + svg.selectAll('*').remove(); + + const size = Math.min(width, height); + const cx = width / 2; + const cy = height / 2; + const outerRadius = size * 0.42; + const innerRadius = outerRadius * 0.75; + + const startAngle = -Math.PI * 0.75; + const endAngle = Math.PI * 0.75; + const range = endAngle - startAngle; + const valueAngle = startAngle + (score / 5) * range; + + const arc = d3.arc().innerRadius(innerRadius).outerRadius(outerRadius).cornerRadius(4); + + // Background arc + svg + .append('path') + .attr('transform', `translate(${cx},${cy})`) + .attr('d', arc({ startAngle, endAngle } as never) as string) + .attr('fill', 'rgba(255,255,255,0.1)'); + + // Value arc + svg + .append('path') + .attr('transform', `translate(${cx},${cy})`) + .attr('d', arc({ startAngle, endAngle: valueAngle } as never) as string) + .attr('fill', getGaugeColor(score)); + + // Center text — score + svg + .append('text') + .attr('x', cx) + .attr('y', cy - 4) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'auto') + .attr('fill', 'white') + .attr('font-size', size * 0.16) + .attr('font-weight', '700') + .text(score.toFixed(1)); + + // Center text — maturity level + svg + .append('text') + .attr('x', cx) + .attr('y', cy + size * 0.1) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'auto') + .attr('fill', 'rgb(156,163,175)') + .attr('font-size', size * 0.07) + .text(getMaturityLabel(score)); + + // Label below gauge + if (label) { + svg + .append('text') + .attr('x', cx) + .attr('y', cy + size * 0.35) + .attr('text-anchor', 'middle') + .attr('fill', 'rgb(156,163,175)') + .attr('font-size', size * 0.06) + .text(label); + } + }, + [score, label], + ); + + const { svgRef } = useD3(render); + + useEffect(() => { + const el = svgRef.current; + if (el) { + const { width, height } = el.getBoundingClientRect(); + if (width > 0 && height > 0) render(width, height); + } + }, [render, svgRef]); + + return ( + + ); +} diff --git a/src/UILayer/web/src/components/widgets/NistCompliance/NistComplianceDashboard.tsx b/src/UILayer/web/src/components/widgets/NistCompliance/NistComplianceDashboard.tsx new file mode 100644 index 00000000..2536c8d8 --- /dev/null +++ b/src/UILayer/web/src/components/widgets/NistCompliance/NistComplianceDashboard.tsx @@ -0,0 +1,164 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import MaturityGauge from './MaturityGauge'; +import GapAnalysisTable from './GapAnalysisTable'; +import ComplianceTimeline from './ComplianceTimeline'; +import { getNistScore, getNistRoadmap, getNistAuditLog } from '../api'; +import type { NISTScoreResponse, NISTRoadmapResponse, NISTAuditEntry } from '../types'; + +interface NistComplianceDashboardProps { + organizationId?: string; +} + +/** + * FE-011: Main NIST Compliance Dashboard widget. + * + * Displays maturity scores, pillar breakdown, gap analysis, and an audit + * event timeline sourced from the NISTComplianceController API. + */ +export default function NistComplianceDashboard({ + organizationId = 'default-org', +}: NistComplianceDashboardProps) { + const [scoreData, setScoreData] = useState(null); + const [roadmapData, setRoadmapData] = useState(null); + const [auditEntries, setAuditEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [score, roadmap, audit] = await Promise.all([ + getNistScore(organizationId), + getNistRoadmap(organizationId), + getNistAuditLog(organizationId, 50), + ]); + setScoreData(score); + setRoadmapData(roadmap); + setAuditEntries(audit.entries ?? []); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to load NIST compliance data.'; + setError(msg); + } finally { + setLoading(false); + } + }, [organizationId]); + + useEffect(() => { + void fetchData(); + }, [fetchData]); + + // Loading skeleton + if (loading) { + return ( +
+
+
+ {[1, 2, 3].map((k) => ( +
+ ))} +
+
+
+ ); + } + + // Error state + if (error) { + return ( +
+

Error loading compliance data

+

{error}

+ +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

NIST AI RMF Compliance

+ {scoreData && ( +

+ Assessed {new Date(scoreData.assessedAt).toLocaleDateString()} +

+ )} +
+ +
+ + {/* Gauges row */} + {scoreData && ( +
+ {/* Overall maturity */} +
+ +
+ + {/* Top 3 pillar scores */} + {scoreData.pillarScores.slice(0, 3).map((ps) => ( +
+ +
+ ))} +
+ )} + + {/* Pillar breakdown table */} + {scoreData && scoreData.pillarScores.length > 0 && ( +
+

Pillar Breakdown

+
+ {scoreData.pillarScores.map((ps) => { + const pct = (ps.averageScore / 5) * 100; + return ( +
+ {ps.pillarName} +
+
+
+
+
+ + {ps.averageScore.toFixed(1)} + +
+ ); + })} +
+
+ )} + + {/* Gap analysis */} + {roadmapData && ( +
+

Gap Analysis

+ +
+ )} + + {/* Audit timeline */} +
+

Compliance Timeline

+ +
+
+ ); +} diff --git a/src/UILayer/web/src/components/widgets/NistCompliance/index.ts b/src/UILayer/web/src/components/widgets/NistCompliance/index.ts new file mode 100644 index 00000000..0656ee76 --- /dev/null +++ b/src/UILayer/web/src/components/widgets/NistCompliance/index.ts @@ -0,0 +1,4 @@ +export { default as NistComplianceDashboard } from './NistComplianceDashboard'; +export { default as MaturityGauge } from './MaturityGauge'; +export { default as GapAnalysisTable } from './GapAnalysisTable'; +export { default as ComplianceTimeline } from './ComplianceTimeline'; diff --git a/src/UILayer/web/src/components/widgets/ValueGeneration/BlindnessHeatmap.tsx b/src/UILayer/web/src/components/widgets/ValueGeneration/BlindnessHeatmap.tsx new file mode 100644 index 00000000..ff3df4aa --- /dev/null +++ b/src/UILayer/web/src/components/widgets/ValueGeneration/BlindnessHeatmap.tsx @@ -0,0 +1,60 @@ +'use client'; + +import React from 'react'; + +interface BlindnessHeatmapProps { + /** Risk score 0-1. */ + riskScore: number; + /** Identified blind spots. */ + blindSpots: string[]; +} + +function severityClass(index: number, total: number): string { + const pct = total > 0 ? index / total : 0; + if (pct < 0.33) return 'bg-red-500/80'; + if (pct < 0.66) return 'bg-orange-500/70'; + return 'bg-yellow-500/60'; +} + +/** + * Heatmap-style visualization of organizational blind spots. + * Each blind spot is rendered as a tile whose color intensity maps to + * its relative severity (position in the ordered list from the backend). + */ +export default function BlindnessHeatmap({ riskScore, blindSpots }: BlindnessHeatmapProps) { + if (blindSpots.length === 0) { + return

No blind spots detected.

; + } + + return ( +
+ {/* Risk badge */} +
+ Blindness Risk Score + 0.6 + ? 'bg-red-500/20 text-red-400' + : riskScore > 0.3 + ? 'bg-yellow-500/20 text-yellow-400' + : 'bg-green-500/20 text-green-400' + }`} + > + {(riskScore * 100).toFixed(0)}% + +
+ + {/* Blind spot tiles */} +
+ {blindSpots.map((spot, i) => ( +
+ {spot} +
+ ))} +
+
+ ); +} diff --git a/src/UILayer/web/src/components/widgets/ValueGeneration/ValueGenerationDashboard.tsx b/src/UILayer/web/src/components/widgets/ValueGeneration/ValueGenerationDashboard.tsx new file mode 100644 index 00000000..1b588242 --- /dev/null +++ b/src/UILayer/web/src/components/widgets/ValueGeneration/ValueGenerationDashboard.tsx @@ -0,0 +1,183 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import ValueRadarChart from './ValueRadarChart'; +import BlindnessHeatmap from './BlindnessHeatmap'; +import { runValueDiagnostic, detectOrgBlindness } from '../api'; +import type { ValueDiagnosticResponse, OrgBlindnessDetectionResponse } from '../types'; + +interface ValueGenerationDashboardProps { + targetId?: string; + targetType?: string; + organizationId?: string; + tenantId?: string; +} + +/** + * FE-013: Value Generation Dashboard widget. + * + * Displays value diagnostic results with a radar chart of value dimensions, + * and an organizational blindness heatmap sourced from the + * ValueGenerationController API. + */ +export default function ValueGenerationDashboard({ + targetId = 'current-user', + targetType = 'User', + organizationId = 'default-org', + tenantId = 'default-tenant', +}: ValueGenerationDashboardProps) { + const [diagnostic, setDiagnostic] = useState(null); + const [blindness, setBlindness] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [diag, blind] = await Promise.all([ + runValueDiagnostic(targetId, targetType, tenantId), + detectOrgBlindness(organizationId, tenantId), + ]); + setDiagnostic(diag); + setBlindness(blind); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to load value generation data.'; + setError(msg); + } finally { + setLoading(false); + } + }, [targetId, targetType, organizationId, tenantId]); + + useEffect(() => { + void fetchData(); + }, [fetchData]); + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (error) { + return ( +
+

Error loading value data

+

{error}

+ +
+ ); + } + + // Build radar data from diagnostic + const radarAxes: string[] = []; + const radarValues: number[] = []; + if (diagnostic) { + radarAxes.push('Score'); + radarValues.push(Math.min(diagnostic.valueScore, 100)); + diagnostic.strengths.forEach((s) => { + radarAxes.push(s); + radarValues.push(80); // strengths are inherently high + }); + diagnostic.developmentOpportunities.forEach((d) => { + radarAxes.push(d); + radarValues.push(35); // opportunities are inherently low + }); + } + + return ( +
+ {/* Header */} +
+

Value Generation

+ +
+ + {/* Value diagnostic summary */} + {diagnostic && ( +
+

Diagnostic Summary

+
+
+

Value Score

+

{diagnostic.valueScore}

+
+
+

Profile

+

{diagnostic.valueProfile}

+
+
+

Strengths

+

{diagnostic.strengths.length}

+
+
+

Opportunities

+

+ {diagnostic.developmentOpportunities.length} +

+
+
+
+ )} + + {/* Radar chart */} + {radarAxes.length > 0 && ( +
+

Value Dimensions

+ +
+ )} + + {/* Strengths & opportunities */} + {diagnostic && ( +
+
+

Strengths

+
    + {diagnostic.strengths.map((s, i) => ( +
  • + {s} +
  • + ))} +
+
+
+

Development Opportunities

+
    + {diagnostic.developmentOpportunities.map((d, i) => ( +
  • + {d} +
  • + ))} +
+
+
+ )} + + {/* Organizational blindness */} + {blindness && ( +
+

Organizational Blindness

+ +
+ )} +
+ ); +} diff --git a/src/UILayer/web/src/components/widgets/ValueGeneration/ValueRadarChart.tsx b/src/UILayer/web/src/components/widgets/ValueGeneration/ValueRadarChart.tsx new file mode 100644 index 00000000..a7b81872 --- /dev/null +++ b/src/UILayer/web/src/components/widgets/ValueGeneration/ValueRadarChart.tsx @@ -0,0 +1,125 @@ +'use client'; + +import React, { useEffect, useCallback } from 'react'; +import * as d3 from 'd3'; +import { useD3 } from '@/lib/visualizations/useD3'; + +interface ValueRadarChartProps { + /** Labels for each axis of the radar. */ + axes: string[]; + /** Values for each axis (0-100 scale). */ + values: number[]; +} + +/** + * D3 radar / spider chart rendering value dimensions. + */ +export default function ValueRadarChart({ axes, values }: ValueRadarChartProps) { + const render = useCallback( + (width: number, height: number) => { + const svg = d3.select(svgRef.current); + if (!svg.node()) return; + svg.selectAll('*').remove(); + + if (axes.length === 0) { + svg + .append('text') + .attr('x', width / 2) + .attr('y', height / 2) + .attr('text-anchor', 'middle') + .attr('fill', 'rgb(107,114,128)') + .attr('font-size', 12) + .text('No data for radar chart.'); + return; + } + + const cx = width / 2; + const cy = height / 2; + const maxRadius = Math.min(cx, cy) - 40; + const numAxes = axes.length; + const angleSlice = (Math.PI * 2) / numAxes; + + const rScale = d3.scaleLinear().domain([0, 100]).range([0, maxRadius]); + + const g = svg.append('g').attr('transform', `translate(${cx},${cy})`); + + // Grid circles + const levels = 4; + for (let lvl = 1; lvl <= levels; lvl++) { + const r = (maxRadius / levels) * lvl; + g.append('circle') + .attr('r', r) + .attr('fill', 'none') + .attr('stroke', 'rgba(255,255,255,0.1)') + .attr('stroke-dasharray', '3,3'); + } + + // Axis lines + labels + axes.forEach((label, i) => { + const angle = angleSlice * i - Math.PI / 2; + const xEnd = maxRadius * Math.cos(angle); + const yEnd = maxRadius * Math.sin(angle); + g.append('line') + .attr('x1', 0) + .attr('y1', 0) + .attr('x2', xEnd) + .attr('y2', yEnd) + .attr('stroke', 'rgba(255,255,255,0.1)'); + + const labelDist = maxRadius + 16; + g.append('text') + .attr('x', labelDist * Math.cos(angle)) + .attr('y', labelDist * Math.sin(angle)) + .attr('text-anchor', 'middle') + .attr('dominant-baseline', 'central') + .attr('fill', 'rgb(156,163,175)') + .attr('font-size', 10) + .text(label); + }); + + // Data polygon + const lineGen = d3 + .lineRadial() + .angle((_, i) => angleSlice * i) + .radius((d) => rScale(d)) + .curve(d3.curveLinearClosed); + + g.append('path') + .datum(values) + .attr('d', lineGen) + .attr('fill', 'rgba(59,130,246,0.25)') + .attr('stroke', '#3b82f6') + .attr('stroke-width', 2); + + // Data dots + values.forEach((val, i) => { + const angle = angleSlice * i - Math.PI / 2; + g.append('circle') + .attr('cx', rScale(val) * Math.cos(angle)) + .attr('cy', rScale(val) * Math.sin(angle)) + .attr('r', 4) + .attr('fill', '#3b82f6'); + }); + }, + [axes, values], + ); + + const { svgRef } = useD3(render); + + useEffect(() => { + const el = svgRef.current; + if (el) { + const { width, height } = el.getBoundingClientRect(); + if (width > 0 && height > 0) render(width, height); + } + }, [render, svgRef]); + + return ( + + ); +} diff --git a/src/UILayer/web/src/components/widgets/ValueGeneration/index.ts b/src/UILayer/web/src/components/widgets/ValueGeneration/index.ts new file mode 100644 index 00000000..6d182b08 --- /dev/null +++ b/src/UILayer/web/src/components/widgets/ValueGeneration/index.ts @@ -0,0 +1,3 @@ +export { default as ValueGenerationDashboard } from './ValueGenerationDashboard'; +export { default as ValueRadarChart } from './ValueRadarChart'; +export { default as BlindnessHeatmap } from './BlindnessHeatmap'; diff --git a/src/UILayer/web/src/components/widgets/api.ts b/src/UILayer/web/src/components/widgets/api.ts new file mode 100644 index 00000000..0dd6fdff --- /dev/null +++ b/src/UILayer/web/src/components/widgets/api.ts @@ -0,0 +1,145 @@ +/** + * API helper for Phase 15b widget dashboards. + * + * These endpoints are not yet in the auto-generated OpenAPI types, so we use + * a typed fetch wrapper that reads the same NEXT_PUBLIC_API_BASE_URL env var + * as the openapi-fetch client in `@/lib/api/client`. + * + * Once the OpenAPI spec is regenerated, migrate callers to `servicesApi.GET(...)`. + */ + +function getApiBaseUrl(): string { + const url = process.env.NEXT_PUBLIC_API_BASE_URL; + if (url) return url; + return 'http://localhost:5000'; +} + +const BASE = getApiBaseUrl(); + +async function fetchJson(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json', ...init?.headers }, + ...init, + }); + if (!res.ok) { + const text = await res.text().catch(() => res.statusText); + throw new Error(`API ${res.status}: ${text}`); + } + return res.json() as Promise; +} + +// ───────────────────────────── NIST Compliance ───────────────────────────── + +import type { + NISTScoreResponse, + NISTRoadmapResponse, + NISTChecklistResponse, + NISTAuditLogResponse, + BalanceResponse, + SpectrumHistoryResponse, + ReflexionStatusResponse, + ValueDiagnosticResponse, + OrgBlindnessDetectionResponse, + PsychologicalSafetyScore, + ImpactReport, + ResistanceIndicator, + SandwichProcess, + PhaseAuditEntry, + CognitiveDebtAssessment, +} from './types'; + +export async function getNistScore(organizationId: string): Promise { + return fetchJson(`/api/v1/nist-compliance/organizations/${encodeURIComponent(organizationId)}/score`); +} + +export async function getNistRoadmap(organizationId: string): Promise { + return fetchJson(`/api/v1/nist-compliance/organizations/${encodeURIComponent(organizationId)}/roadmap`); +} + +export async function getNistChecklist(organizationId: string): Promise { + return fetchJson(`/api/v1/nist-compliance/organizations/${encodeURIComponent(organizationId)}/checklist`); +} + +export async function getNistAuditLog(organizationId: string, maxResults = 50): Promise { + return fetchJson(`/api/v1/nist-compliance/organizations/${encodeURIComponent(organizationId)}/audit-log?maxResults=${maxResults}`); +} + +// ──────────────────────────── Adaptive Balance ────────────────────────────── + +export async function getAdaptiveBalance(context: Record = {}): Promise { + return fetchJson('/api/v1/adaptive-balance/balance', { + method: 'POST', + body: JSON.stringify({ context }), + }); +} + +export async function getSpectrumHistory(dimension: string): Promise { + return fetchJson(`/api/v1/adaptive-balance/history/${encodeURIComponent(dimension)}`); +} + +export async function getReflexionStatus(): Promise { + return fetchJson('/api/v1/adaptive-balance/reflexion-status'); +} + +// ─────────────────────────── Value Generation ─────────────────────────────── + +export async function runValueDiagnostic( + targetId: string, + targetType: string, + tenantId: string, +): Promise { + return fetchJson('/api/v1/ValueGeneration/value-diagnostic', { + method: 'POST', + body: JSON.stringify({ targetId, targetType, tenantId }), + }); +} + +export async function detectOrgBlindness( + organizationId: string, + tenantId: string, + departmentFilters: string[] = [], +): Promise { + return fetchJson('/api/v1/ValueGeneration/org-blindness/detect', { + method: 'POST', + body: JSON.stringify({ organizationId, tenantId, departmentFilters }), + }); +} + +// ──────────────────────────── Impact Metrics ──────────────────────────────── + +export async function getSafetyScoreHistory( + teamId: string, + tenantId: string, +): Promise { + return fetchJson(`/api/v1/impact-metrics/safety-score/${encodeURIComponent(teamId)}/history?tenantId=${encodeURIComponent(tenantId)}`); +} + +export async function getImpactReport( + tenantId: string, + periodStart?: string, + periodEnd?: string, +): Promise { + const params = new URLSearchParams(); + if (periodStart) params.set('periodStart', periodStart); + if (periodEnd) params.set('periodEnd', periodEnd); + const qs = params.toString(); + return fetchJson(`/api/v1/impact-metrics/report/${encodeURIComponent(tenantId)}${qs ? `?${qs}` : ''}`); +} + +export async function getResistancePatterns(tenantId: string): Promise { + return fetchJson(`/api/v1/impact-metrics/telemetry/${encodeURIComponent(tenantId)}/resistance`); +} + +// ────────────────────────── Cognitive Sandwich ────────────────────────────── + +export async function getSandwichProcess(processId: string): Promise { + return fetchJson(`/api/v1/cognitive-sandwich/${encodeURIComponent(processId)}`); +} + +export async function getSandwichAuditTrail(processId: string): Promise { + return fetchJson(`/api/v1/cognitive-sandwich/${encodeURIComponent(processId)}/audit`); +} + +export async function getSandwichDebt(processId: string): Promise { + return fetchJson(`/api/v1/cognitive-sandwich/${encodeURIComponent(processId)}/debt`); +} diff --git a/src/UILayer/web/src/components/widgets/types.ts b/src/UILayer/web/src/components/widgets/types.ts new file mode 100644 index 00000000..a02e5433 --- /dev/null +++ b/src/UILayer/web/src/components/widgets/types.ts @@ -0,0 +1,224 @@ +/** + * Shared TypeScript types for Phase 15b widget dashboards. + * + * These types mirror the C# backend models from the corresponding controllers + * (NISTComplianceController, AdaptiveBalanceController, ValueGenerationController, + * ImpactMetricsController, CognitiveSandwichController). + * + * Once the OpenAPI spec is regenerated to include these endpoints, these types + * can be replaced by the auto-generated ones from `services.d.ts`. + */ + +// ───────────────────────────── NIST Compliance ───────────────────────────── + +export interface NISTChecklistPillarScore { + pillarId: string; + pillarName: string; + averageScore: number; + statementCount: number; +} + +export interface NISTScoreResponse { + organizationId: string; + overallScore: number; + pillarScores: NISTChecklistPillarScore[]; + assessedAt: string; +} + +export interface NISTGapItem { + statementId: string; + currentScore: number; + targetScore: number; + priority: string; + recommendedActions: string[]; +} + +export interface NISTRoadmapResponse { + organizationId: string; + gaps: NISTGapItem[]; + generatedAt: string; +} + +export interface NISTChecklistStatement { + statementId: string; + text: string; + status: string; + evidenceCount: number; +} + +export interface NISTChecklistPillar { + pillarId: string; + pillarName: string; + statements: NISTChecklistStatement[]; +} + +export interface NISTChecklistResponse { + organizationId: string; + pillars: NISTChecklistPillar[]; + totalStatements: number; + completedStatements: number; +} + +export interface NISTAuditEntry { + entryId: string; + action: string; + performedBy: string; + performedAt: string; + details: string; +} + +export interface NISTAuditLogResponse { + entries: NISTAuditEntry[]; +} + +// ──────────────────────────── Adaptive Balance ────────────────────────────── + +export interface SpectrumDimensionResult { + dimension: string; + value: number; + lowerBound: number; + upperBound: number; + rationale: string; +} + +export interface BalanceResponse { + dimensions: SpectrumDimensionResult[]; + overallConfidence: number; + generatedAt: string; +} + +export interface SpectrumHistoryEntry { + value: number; + timestamp: string; + source: string; +} + +export interface SpectrumHistoryResponse { + dimension: string; + history: SpectrumHistoryEntry[]; +} + +export interface ReflexionStatusEntry { + evaluationId: string; + result: string; + confidence: number; + timestamp: string; +} + +export interface ReflexionStatusResponse { + recentResults: ReflexionStatusEntry[]; + hallucinationRate: number; + averageConfidence: number; +} + +// ─────────────────────────── Value Generation ─────────────────────────────── + +export interface ValueDiagnosticResponse { + valueScore: number; + valueProfile: string; + strengths: string[]; + developmentOpportunities: string[]; +} + +export interface OrgBlindnessDetectionResponse { + blindnessRiskScore: number; + identifiedBlindSpots: string[]; +} + +// ──────────────────────────── Impact Metrics ──────────────────────────────── + +export type SafetyDimension = + | 'TrustInAI' + | 'FearOfReplacement' + | 'ComfortWithAutomation' + | 'WillingnessToExperiment' + | 'TransparencyPerception' + | 'ErrorTolerance'; + +export interface PsychologicalSafetyScore { + scoreId: string; + teamId: string; + tenantId: string; + overallScore: number; + dimensions: Record; + surveyResponseCount: number; + behavioralSignalCount: number; + calculatedAt: string; + confidenceLevel: string; +} + +export interface ImpactReport { + reportId: string; + tenantId: string; + periodStart: string; + periodEnd: string; + safetyScore: number; + alignmentScore: number; + adoptionRate: number; + overallImpactScore: number; + recommendations: string[]; + generatedAt: string; +} + +export interface ResistanceIndicator { + indicatorId: string; + pattern: string; + severity: string; + affectedUsers: number; + detectedAt: string; +} + +export interface ImpactAssessment { + assessmentId: string; + tenantId: string; + periodStart: string; + periodEnd: string; + productivityDelta: number; + qualityDelta: number; + timeToDecisionDelta: number; + userSatisfactionScore: number; + adoptionRate: number; + resistanceIndicators: ResistanceIndicator[]; +} + +// ────────────────────────── Cognitive Sandwich ────────────────────────────── + +export interface Phase { + phaseId: string; + phaseName: string; + phaseType: string; + status: string; + order: number; +} + +export interface SandwichProcess { + processId: string; + tenantId: string; + name: string; + createdAt: string; + currentPhaseIndex: number; + phases: Phase[]; + state: string; + maxStepBacks: number; + stepBackCount: number; + cognitiveDebtThreshold: number; +} + +export interface PhaseAuditEntry { + entryId: string; + processId: string; + phaseId: string; + eventType: string; + timestamp: string; + userId: string; + details: string; +} + +export interface CognitiveDebtAssessment { + processId: string; + phaseId: string; + debtScore: number; + isBreached: boolean; + recommendations: string[]; + assessedAt: string; +} diff --git a/src/UILayer/web/src/lib/code-splitting/registry/lazyWidgets.ts b/src/UILayer/web/src/lib/code-splitting/registry/lazyWidgets.ts index ac43d709..db9638cd 100644 --- a/src/UILayer/web/src/lib/code-splitting/registry/lazyWidgets.ts +++ b/src/UILayer/web/src/lib/code-splitting/registry/lazyWidgets.ts @@ -58,3 +58,25 @@ export const LazyMetricsChart = createLazyWidget( export const LazyAgentNetworkGraph = createLazyWidget( () => import('@/components/visualizations/AgentNetworkGraph') as AnyImport ); + +// Phase 15b — PRD Widget Dashboards + +export const LazyNistComplianceDashboard = createLazyWidget( + () => import('@/components/widgets/NistCompliance/NistComplianceDashboard') as AnyImport +); + +export const LazyAdaptiveBalanceDashboard = createLazyWidget( + () => import('@/components/widgets/AdaptiveBalance/AdaptiveBalanceDashboard') as AnyImport +); + +export const LazyValueGenerationDashboard = createLazyWidget( + () => import('@/components/widgets/ValueGeneration/ValueGenerationDashboard') as AnyImport +); + +export const LazyImpactMetricsDashboard = createLazyWidget( + () => import('@/components/widgets/ImpactMetrics/ImpactMetricsDashboard') as AnyImport +); + +export const LazyCognitiveSandwichDashboard = createLazyWidget( + () => import('@/components/widgets/CognitiveSandwich/CognitiveSandwichDashboard') as AnyImport +); From 7c6a1282c64c4a59c866434c70f012ba2a55d02e Mon Sep 17 00:00:00 2001 From: JustAGhosT Date: Wed, 11 Mar 2026 13:21:48 +0200 Subject: [PATCH 5/5] =?UTF-8?q?Orchestrator:=20Phase=2015=20complete=20?= =?UTF-8?q?=E2=80=94=2095/109=20items=20done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend grade C→B. 5 widget PRDs built (NIST, Adaptive Balance, Value Gen, Impact Metrics, Cognitive Sandwich). Frontend Docker, K8s manifests, Terraform module, deploy pipeline, Dependabot npm, CodeQL TypeScript all added. 14 items remaining for Phases 16-17. Co-Authored-By: Claude Opus 4.6 --- .claude/state/orchestrator.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.claude/state/orchestrator.json b/.claude/state/orchestrator.json index 403ae3a1..2e845ec6 100644 --- a/.claude/state/orchestrator.json +++ b/.claude/state/orchestrator.json @@ -1,8 +1,8 @@ { "_comment": "Persistent orchestrator state — survives across Claude Code sessions. Updated by /discover, /sync-backlog, /healthcheck, and /orchestrate.", - "last_updated": "2026-03-11T12:00:00Z", + "last_updated": "2026-03-11T14:00:00Z", "last_phase_completed": 15, - "last_phase_result": "in-progress", + "last_phase_result": "success", "current_metrics": { "build_errors": 0, "build_warnings": 0, @@ -36,9 +36,9 @@ "integration_test_files": ["EthicalComplianceFramework", "DurableWorkflowCrashRecovery", "DecisionExecutor", "ConclAIvePipeline"], "test_files_missing": [], "total_new_tests": 1000, - "backlog_done": 84, + "backlog_done": 95, "backlog_total": 109, - "backlog_remaining": 25 + "backlog_remaining": 14 }, "phase_history": [ { @@ -167,7 +167,7 @@ "navigation_component": true, "multi_page_routing": false, "role_based_ui": false, - "widget_prds_implemented": 8, + "widget_prds_implemented": 13, "widget_prds_total": 17, "component_test_count": 1, "component_test_coverage_pct": 2, @@ -175,23 +175,23 @@ "visual_regression": false, "lighthouse_ci": false, "frontend_in_ci": true, - "frontend_docker": false, - "frontend_k8s": false, - "frontend_terraform": false, + "frontend_docker": true, + "frontend_k8s": true, + "frontend_terraform": true, "state_management": "zustand", "error_handling": "interceptors+boundaries+toast", - "grade": "C" + "grade": "B" }, "frontend_backlog": { "p0_critical": { "total": 4, "done": 4, "items": ["FE-001 API client gen [DONE]", "FE-002 Replace mocked APIs [PARTIAL]", "FE-003 SignalR client [DONE]", "FE-004 Auth flow [DONE]"] }, "p1_high_infra": { "total": 6, "done": 6, "items": ["FE-005 State mgmt [DONE]", "FE-006 Error handling [DONE]", "FE-007 Loading states [DONE]", "FE-008 Settings [DONE]", "FE-009 Notifications prefs [DONE]", "FE-010 User profile [DONE]"] }, - "p1_high_widgets": { "total": 5, "done": 0, "items": ["FE-011 NIST", "FE-012 Adaptive Balance", "FE-013 Value Gen", "FE-014 Impact Metrics", "FE-015 Cognitive Sandwich"] }, + "p1_high_widgets": { "total": 5, "done": 5, "items": ["FE-011 NIST [DONE]", "FE-012 Adaptive Balance [DONE]", "FE-013 Value Gen [DONE]", "FE-014 Impact Metrics [DONE]", "FE-015 Cognitive Sandwich [DONE]"] }, "p2_medium_widgets": { "total": 5, "done": 4, "items": ["FE-016 Context Eng", "FE-017 Agentic System [DONE]", "FE-018 Convener", "FE-019 Marketplace", "FE-020 Org Mesh"] }, "p2_medium_app": { "total": 3, "done": 1, "items": ["FE-021 Multi-page routing", "FE-022 Navigation [DONE]", "FE-023 Role-based UI"] }, - "p2_medium_cicd": { "total": 6, "done": 1, "items": ["FECICD-001 CI pipeline [DONE]", "FECICD-002 Docker", "FECICD-003 Compose", "FECICD-004 Deploy", "FECICD-005 K8s", "FECICD-006 Terraform"] }, + "p2_medium_cicd": { "total": 6, "done": 6, "items": ["FECICD-001 CI pipeline [DONE]", "FECICD-002 Docker [DONE]", "FECICD-003 Compose [DONE]", "FECICD-004 Deploy [DONE]", "FECICD-005 K8s [DONE]", "FECICD-006 Terraform [DONE]"] }, "p2_medium_testing": { "total": 5, "done": 0, "items": ["FETEST-001 Unit tests 80%", "FETEST-002 API integration", "FETEST-003 E2E real API", "FETEST-004 Visual regression", "FETEST-005 Lighthouse CI"] }, "p3_low_advanced": { "total": 5, "done": 3, "items": ["FE-024 Export", "FE-025 Cmd+K", "FE-026 Collaboration", "FE-027 Locales [DONE]", "FE-028 PWA [DONE]"] } }, "blockers": [], - "next_action": "Phase 15b: Dispatch Team 10 (FRONTEND) for 5 priority widget PRDs (FE-011 to FE-015: NIST, Adaptive Balance, Value Gen, Impact Metrics, Cognitive Sandwich). Dispatch Team 8 (CI/CD) for frontend Docker + deploy pipeline. Dispatch Team 9 (INFRA) for frontend K8s + Terraform." + "next_action": "Phase 16: Dispatch Team 10 (FRONTEND) for remaining widgets (FE-016 to FE-020, FE-021 multi-page routing, FE-023 role-based UI) + Team 7 (TESTING) for frontend unit tests and API integration tests (FETEST-001 to FETEST-005)." }