From 87d80996bf857b237b7ce3f2ecf35f4730aa58e8 Mon Sep 17 00:00:00 2001 From: JustAGhosT Date: Wed, 11 Mar 2026 08:06:56 +0200 Subject: [PATCH 1/8] 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/8] 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/8] =?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)." } From 1b1faceb1320f4ea1b3c4e3682c24ba9bb6f461e Mon Sep 17 00:00:00 2001 From: JustAGhosT Date: Wed, 11 Mar 2026 14:25:12 +0200 Subject: [PATCH 6/8] Phase 16: Remaining widgets, role-based UI, 98 frontend tests Widgets (FE-016, FE-018 to FE-020): - Context Engineering: token usage chart, prompt optimization metrics - Convener: session timeline, orchestration modes - Marketplace: agent browser with search/filter, agent cards - Org Mesh: mesh topology visualization, node type legend App features (FE-021, FE-023): - Multi-page routing: all routes under App Router (app) group - RoleGuard component wrapping compliance page - Sidebar role indicator with user avatar Frontend tests (FETEST-001, FETEST-002): - 12 test suites, 98 tests passing - Components: toggle-switch, ConnectionIndicator, ErrorBoundary, Skeleton - Stores: useAgentStore, useNotificationStore, usePreferencesStore - Hooks: use-toast - Contexts: AuthContext - API: client setup, agent registry integration tests - Jest config: path aliases, file mocks, crypto polyfill Co-Authored-By: Claude Opus 4.6 --- src/UILayer/web/__mocks__/fileMock.js | 1 + src/UILayer/web/jest.config.js | 9 +- src/UILayer/web/jest.setup.js | 21 ++ .../src/__tests__/api-integration/README.md | 40 +++ .../__tests__/api-integration/agents.test.ts | 145 ++++++++++ .../__tests__/api-integration/test-utils.ts | 85 ++++++ .../web/src/app/(app)/compliance/page.tsx | 7 +- .../app/(app)/context-engineering/page.tsx | 7 + .../web/src/app/(app)/convener/page.tsx | 7 + .../web/src/app/(app)/marketplace/page.tsx | 16 +- .../web/src/app/(app)/org-mesh/page.tsx | 7 + .../ErrorBoundary/ErrorBoundary.test.tsx | 97 +++++++ .../Navigation/ConnectionIndicator.test.tsx | 86 ++++++ .../web/src/components/Navigation/Sidebar.tsx | 49 +++- .../web/src/components/Navigation/navItems.ts | 3 + .../src/components/Skeleton/Skeleton.test.tsx | 83 ++++++ .../web/src/components/auth/RoleGuard.tsx | 65 +++++ .../src/components/ui/toggle-switch.test.tsx | 116 ++++++++ .../ContextEngineeringDashboard.tsx | 92 ++++++ .../ContextEngineering/TokenUsageChart.tsx | 57 ++++ .../widgets/ContextEngineering/index.ts | 2 + .../widgets/Convener/ConvenerDashboard.tsx | 93 +++++++ .../widgets/Convener/SessionTimeline.tsx | 87 ++++++ .../src/components/widgets/Convener/index.ts | 2 + .../widgets/Marketplace/AgentCard.tsx | 95 +++++++ .../Marketplace/MarketplaceDashboard.tsx | 157 +++++++++++ .../components/widgets/Marketplace/index.ts | 2 + .../widgets/OrgMesh/MeshTopology.tsx | 100 +++++++ .../widgets/OrgMesh/OrgMeshDashboard.tsx | 81 ++++++ .../src/components/widgets/OrgMesh/index.ts | 2 + .../web/src/contexts/AuthContext.test.tsx | 261 ++++++++++++++++++ src/UILayer/web/src/hooks/use-toast.test.ts | 122 ++++++++ src/UILayer/web/src/lib/api/client.test.ts | 111 ++++++++ .../code-splitting/registry/lazyWidgets.ts | 18 ++ .../web/src/stores/useAgentStore.test.ts | 210 ++++++++++++++ .../src/stores/useNotificationStore.test.ts | 184 ++++++++++++ .../src/stores/usePreferencesStore.test.ts | 119 ++++++++ 37 files changed, 2620 insertions(+), 19 deletions(-) create mode 100644 src/UILayer/web/__mocks__/fileMock.js create mode 100644 src/UILayer/web/src/__tests__/api-integration/README.md create mode 100644 src/UILayer/web/src/__tests__/api-integration/agents.test.ts create mode 100644 src/UILayer/web/src/__tests__/api-integration/test-utils.ts create mode 100644 src/UILayer/web/src/app/(app)/context-engineering/page.tsx create mode 100644 src/UILayer/web/src/app/(app)/convener/page.tsx create mode 100644 src/UILayer/web/src/app/(app)/org-mesh/page.tsx create mode 100644 src/UILayer/web/src/components/ErrorBoundary/ErrorBoundary.test.tsx create mode 100644 src/UILayer/web/src/components/Navigation/ConnectionIndicator.test.tsx create mode 100644 src/UILayer/web/src/components/Skeleton/Skeleton.test.tsx create mode 100644 src/UILayer/web/src/components/auth/RoleGuard.tsx create mode 100644 src/UILayer/web/src/components/ui/toggle-switch.test.tsx create mode 100644 src/UILayer/web/src/components/widgets/ContextEngineering/ContextEngineeringDashboard.tsx create mode 100644 src/UILayer/web/src/components/widgets/ContextEngineering/TokenUsageChart.tsx create mode 100644 src/UILayer/web/src/components/widgets/ContextEngineering/index.ts create mode 100644 src/UILayer/web/src/components/widgets/Convener/ConvenerDashboard.tsx create mode 100644 src/UILayer/web/src/components/widgets/Convener/SessionTimeline.tsx create mode 100644 src/UILayer/web/src/components/widgets/Convener/index.ts create mode 100644 src/UILayer/web/src/components/widgets/Marketplace/AgentCard.tsx create mode 100644 src/UILayer/web/src/components/widgets/Marketplace/MarketplaceDashboard.tsx create mode 100644 src/UILayer/web/src/components/widgets/Marketplace/index.ts create mode 100644 src/UILayer/web/src/components/widgets/OrgMesh/MeshTopology.tsx create mode 100644 src/UILayer/web/src/components/widgets/OrgMesh/OrgMeshDashboard.tsx create mode 100644 src/UILayer/web/src/components/widgets/OrgMesh/index.ts create mode 100644 src/UILayer/web/src/contexts/AuthContext.test.tsx create mode 100644 src/UILayer/web/src/hooks/use-toast.test.ts create mode 100644 src/UILayer/web/src/lib/api/client.test.ts create mode 100644 src/UILayer/web/src/stores/useAgentStore.test.ts create mode 100644 src/UILayer/web/src/stores/useNotificationStore.test.ts create mode 100644 src/UILayer/web/src/stores/usePreferencesStore.test.ts diff --git a/src/UILayer/web/__mocks__/fileMock.js b/src/UILayer/web/__mocks__/fileMock.js new file mode 100644 index 00000000..86059f36 --- /dev/null +++ b/src/UILayer/web/__mocks__/fileMock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub'; diff --git a/src/UILayer/web/jest.config.js b/src/UILayer/web/jest.config.js index 4deac4e8..edf66bc8 100644 --- a/src/UILayer/web/jest.config.js +++ b/src/UILayer/web/jest.config.js @@ -5,10 +5,13 @@ module.exports = { setupFilesAfterEnv: ['/jest.setup.js'], moduleNameMapper: { '\\.(css|less|scss|sass)$': '/__mocks__/styleMock.js', - '^@/components/(.*)$': '/src/components/$1', - '^@/hooks/(.*)$': '/src/hooks/$1', - '^@/lib/(.*)$': '/src/lib/$1', + '\\.(png|jpg|jpeg|gif|webp|svg|ico)$': '/__mocks__/fileMock.js', + '^@/(.*)$': '/src/$1', }, + testPathIgnorePatterns: [ + '/node_modules/', + 'test-utils\\.ts$', + ], transform: { '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest', }, diff --git a/src/UILayer/web/jest.setup.js b/src/UILayer/web/jest.setup.js index 7d1fc402..b8c764b6 100644 --- a/src/UILayer/web/jest.setup.js +++ b/src/UILayer/web/jest.setup.js @@ -1,2 +1,23 @@ // Learn more: https://github.com/testing-library/jest-dom require('@testing-library/jest-dom'); + +// Polyfill crypto.randomUUID for jsdom +if (typeof globalThis.crypto === 'undefined') { + globalThis.crypto = {}; +} +if (typeof globalThis.crypto.randomUUID !== 'function') { + let counter = 0; + globalThis.crypto.randomUUID = () => { + counter++; + return `00000000-0000-4000-8000-${String(counter).padStart(12, '0')}`; + }; +} + +// Polyfill TextEncoder/TextDecoder for jsdom +const { TextEncoder, TextDecoder } = require('util'); +if (typeof globalThis.TextEncoder === 'undefined') { + globalThis.TextEncoder = TextEncoder; +} +if (typeof globalThis.TextDecoder === 'undefined') { + globalThis.TextDecoder = TextDecoder; +} diff --git a/src/UILayer/web/src/__tests__/api-integration/README.md b/src/UILayer/web/src/__tests__/api-integration/README.md new file mode 100644 index 00000000..df46eaca --- /dev/null +++ b/src/UILayer/web/src/__tests__/api-integration/README.md @@ -0,0 +1,40 @@ +# API Integration Tests + +This directory contains integration-level tests that verify the frontend's interaction with backend API endpoints. + +## Approach + +Tests use **lightweight fetch mocks** rather than MSW (Mock Service Worker). The `test-utils.ts` module provides: + +- `mockFetch(urlPattern, response)` — Register a mock response for any fetch URL containing `urlPattern` +- `resetFetchMock()` — Clear all mocks and start fresh (call in `beforeEach`) +- `getFetchCalls()` — Inspect recorded fetch calls for assertions +- `getFetchCallsMatching(urlPattern)` — Filter recorded calls by URL pattern + +For tests that go through Zustand stores (which use `openapi-fetch` clients), mock the `@/lib/api/client` module directly with `jest.mock()` as shown in `agents.test.ts`. + +## Adding a New API Integration Test + +1. Create a file named `{resource}.test.ts` in this directory +2. Mock the relevant API client: + ```ts + const mockGet = jest.fn(); + jest.mock("@/lib/api/client", () => ({ + servicesApi: { GET: mockGet }, + agenticApi: { GET: jest.fn() }, + setAuthToken: jest.fn(), + clearAuthToken: jest.fn(), + })); + ``` +3. Write tests that invoke store actions or hooks and assert on the mapped results +4. Verify both success and error paths + +## Running + +```bash +# Run all tests including integration tests +npm test -- --watchAll=false + +# Run only integration tests +npx jest src/__tests__/api-integration --watchAll=false +``` diff --git a/src/UILayer/web/src/__tests__/api-integration/agents.test.ts b/src/UILayer/web/src/__tests__/api-integration/agents.test.ts new file mode 100644 index 00000000..11e6cf40 --- /dev/null +++ b/src/UILayer/web/src/__tests__/api-integration/agents.test.ts @@ -0,0 +1,145 @@ +/** + * API Integration Tests — Agent Registry + * + * Tests that the useAgentStore.fetchAgents() correctly calls the API + * and maps the response into the store's Agent type. + */ +import { act } from "@testing-library/react"; +import { useAgentStore } from "@/stores/useAgentStore"; + +// Mock the API client to use our controlled mock +const mockGet = jest.fn(); +jest.mock("@/lib/api/client", () => ({ + agenticApi: { GET: (...args: unknown[]) => mockGet(...args) }, + servicesApi: { GET: jest.fn() }, + setAuthToken: jest.fn(), + clearAuthToken: jest.fn(), +})); + +beforeEach(() => { + jest.clearAllMocks(); + act(() => { + useAgentStore.setState({ + agents: [], + selectedAgentId: null, + loading: false, + error: null, + }); + }); +}); + +describe("Agent API integration", () => { + it("should call GET /registry to fetch agents", async () => { + mockGet.mockResolvedValue({ data: [], error: undefined }); + + await act(async () => { + await useAgentStore.getState().fetchAgents(); + }); + + expect(mockGet).toHaveBeenCalledWith("/registry", expect.any(Object)); + }); + + it("should map API response fields to Agent interface", async () => { + mockGet.mockResolvedValue({ + data: [ + { + agentId: "agent-abc", + agentType: "Orchestrator", + name: "MainOrchestrator", + status: "Active", + capabilities: ["planning", "routing", "monitoring"], + currentTasks: 5, + registeredAt: "2025-06-15T10:30:00Z", + }, + ], + error: undefined, + }); + + await act(async () => { + await useAgentStore.getState().fetchAgents(); + }); + + const agents = useAgentStore.getState().agents; + expect(agents).toHaveLength(1); + expect(agents[0]).toEqual({ + agentId: "agent-abc", + agentType: "Orchestrator", + name: "MainOrchestrator", + status: "active", + capabilities: ["planning", "routing", "monitoring"], + currentTasks: 5, + registeredAt: "2025-06-15T10:30:00Z", + }); + }); + + it("should handle empty agent list from API", async () => { + mockGet.mockResolvedValue({ data: [], error: undefined }); + + await act(async () => { + await useAgentStore.getState().fetchAgents(); + }); + + expect(useAgentStore.getState().agents).toEqual([]); + expect(useAgentStore.getState().loading).toBe(false); + expect(useAgentStore.getState().error).toBeNull(); + }); + + it("should handle null data from API", async () => { + mockGet.mockResolvedValue({ data: null, error: undefined }); + + await act(async () => { + await useAgentStore.getState().fetchAgents(); + }); + + expect(useAgentStore.getState().agents).toEqual([]); + expect(useAgentStore.getState().loading).toBe(false); + }); + + it("should set error state when API returns an error", async () => { + mockGet.mockResolvedValue({ + data: undefined, + error: { message: "Forbidden" }, + }); + + await act(async () => { + await useAgentStore.getState().fetchAgents(); + }); + + expect(useAgentStore.getState().error).toBe("Failed to fetch agents"); + expect(useAgentStore.getState().agents).toEqual([]); + }); + + it("should set error state when fetch throws a network error", async () => { + mockGet.mockRejectedValue(new Error("ECONNREFUSED")); + + await act(async () => { + await useAgentStore.getState().fetchAgents(); + }); + + expect(useAgentStore.getState().error).toBe("ECONNREFUSED"); + expect(useAgentStore.getState().loading).toBe(false); + }); + + it("should handle agents with missing optional fields gracefully", async () => { + mockGet.mockResolvedValue({ + data: [ + { + agentId: "minimal-agent", + // Missing: agentType, name, status, capabilities, currentTasks, registeredAt + }, + ], + error: undefined, + }); + + await act(async () => { + await useAgentStore.getState().fetchAgents(); + }); + + const agents = useAgentStore.getState().agents; + expect(agents).toHaveLength(1); + expect(agents[0].agentId).toBe("minimal-agent"); + expect(agents[0].agentType).toBe(""); + expect(agents[0].capabilities).toEqual([]); + expect(agents[0].currentTasks).toBe(0); + }); +}); diff --git a/src/UILayer/web/src/__tests__/api-integration/test-utils.ts b/src/UILayer/web/src/__tests__/api-integration/test-utils.ts new file mode 100644 index 00000000..8c744004 --- /dev/null +++ b/src/UILayer/web/src/__tests__/api-integration/test-utils.ts @@ -0,0 +1,85 @@ +/** + * API Integration Test Setup + * + * Provides a lightweight fetch mock for testing API integration flows + * without requiring MSW (Mock Service Worker). + * + * Usage: + * import { mockFetch, resetFetchMock } from "./setup"; + * + * beforeEach(() => resetFetchMock()); + * + * it("should call API", async () => { + * mockFetch("/api/v1/agent/registry", { + * status: 200, + * body: [{ agentId: "1", name: "Agent" }], + * }); + * // ... invoke code that calls fetch ... + * }); + */ + +interface MockResponse { + status?: number; + body?: unknown; + headers?: Record; +} + +const mockResponses = new Map(); +const fetchCalls: { url: string; init?: RequestInit }[] = []; + +/** + * Register a mock response for a given URL pattern. + * The URL is matched as a substring of the full request URL. + */ +export function mockFetch(urlPattern: string, response: MockResponse): void { + mockResponses.set(urlPattern, response); +} + +/** + * Reset all mock responses and recorded calls. + */ +export function resetFetchMock(): void { + mockResponses.clear(); + fetchCalls.length = 0; + global.fetch = jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + fetchCalls.push({ url, init }); + + for (const [pattern, response] of mockResponses) { + if (url.includes(pattern)) { + return { + ok: (response.status ?? 200) >= 200 && (response.status ?? 200) < 300, + status: response.status ?? 200, + headers: new Headers(response.headers ?? {}), + json: async () => response.body, + text: async () => JSON.stringify(response.body), + } as Response; + } + } + + // Default: 404 for unmocked endpoints + return { + ok: false, + status: 404, + headers: new Headers(), + json: async () => ({ error: "Not Found", url }), + text: async () => `Not Found: ${url}`, + } as Response; + }) as jest.Mock; +} + +/** + * Get all fetch calls recorded during the test. + */ +export function getFetchCalls(): { url: string; init?: RequestInit }[] { + return [...fetchCalls]; +} + +/** + * Get fetch calls that match a URL pattern. + */ +export function getFetchCallsMatching( + urlPattern: string +): { url: string; init?: RequestInit }[] { + return fetchCalls.filter((c) => c.url.includes(urlPattern)); +} diff --git a/src/UILayer/web/src/app/(app)/compliance/page.tsx b/src/UILayer/web/src/app/(app)/compliance/page.tsx index 350575f0..5994a507 100644 --- a/src/UILayer/web/src/app/(app)/compliance/page.tsx +++ b/src/UILayer/web/src/app/(app)/compliance/page.tsx @@ -1,7 +1,12 @@ "use client" import NistComplianceDashboard from '@/components/widgets/NistCompliance/NistComplianceDashboard'; +import { RoleGuard } from '@/components/auth/RoleGuard'; export default function CompliancePage() { - return ; + return ( + + + + ); } diff --git a/src/UILayer/web/src/app/(app)/context-engineering/page.tsx b/src/UILayer/web/src/app/(app)/context-engineering/page.tsx new file mode 100644 index 00000000..52af6022 --- /dev/null +++ b/src/UILayer/web/src/app/(app)/context-engineering/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import ContextEngineeringDashboard from '@/components/widgets/ContextEngineering/ContextEngineeringDashboard'; + +export default function ContextEngineeringPage() { + return ; +} diff --git a/src/UILayer/web/src/app/(app)/convener/page.tsx b/src/UILayer/web/src/app/(app)/convener/page.tsx new file mode 100644 index 00000000..62c27dc7 --- /dev/null +++ b/src/UILayer/web/src/app/(app)/convener/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import ConvenerDashboard from '@/components/widgets/Convener/ConvenerDashboard'; + +export default function ConvenerPage() { + return ; +} diff --git a/src/UILayer/web/src/app/(app)/marketplace/page.tsx b/src/UILayer/web/src/app/(app)/marketplace/page.tsx index 592bed0a..44eced39 100644 --- a/src/UILayer/web/src/app/(app)/marketplace/page.tsx +++ b/src/UILayer/web/src/app/(app)/marketplace/page.tsx @@ -1,17 +1,7 @@ "use client" +import MarketplaceDashboard from '@/components/widgets/Marketplace/MarketplaceDashboard'; + export default function MarketplacePage() { - return ( -
-

Marketplace

-
-

- Agent marketplace coming in Phase 16. -

-

- Browse, install, and manage third-party agents and integrations. -

-
-
- ) + return ; } diff --git a/src/UILayer/web/src/app/(app)/org-mesh/page.tsx b/src/UILayer/web/src/app/(app)/org-mesh/page.tsx new file mode 100644 index 00000000..0862585f --- /dev/null +++ b/src/UILayer/web/src/app/(app)/org-mesh/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import OrgMeshDashboard from '@/components/widgets/OrgMesh/OrgMeshDashboard'; + +export default function OrgMeshPage() { + return ; +} diff --git a/src/UILayer/web/src/components/ErrorBoundary/ErrorBoundary.test.tsx b/src/UILayer/web/src/components/ErrorBoundary/ErrorBoundary.test.tsx new file mode 100644 index 00000000..ae5d3920 --- /dev/null +++ b/src/UILayer/web/src/components/ErrorBoundary/ErrorBoundary.test.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ErrorBoundary } from "./ErrorBoundary"; + +// Suppress expected console.error output from React and ErrorBoundary +const originalConsoleError = console.error; +beforeAll(() => { + console.error = jest.fn(); +}); +afterAll(() => { + console.error = originalConsoleError; +}); + +// Component that always throws to ensure ErrorBoundary catches it +function AlwaysThrows(): React.ReactNode { + throw new Error("Test error message"); +} + +describe("ErrorBoundary", () => { + it("should render children when no error occurs", () => { + render( + +
Safe content
+
+ ); + expect(screen.getByText("Safe content")).toBeInTheDocument(); + }); + + it("should catch errors and show default fallback", () => { + render( + + + + ); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + }); + + it("should show a Try again button in the default fallback", () => { + render( + + + + ); + const button = screen.getByRole("button", { name: /try again/i }); + expect(button).toBeInTheDocument(); + }); + + it("should show error message in development mode", () => { + // NODE_ENV is 'test' by default in jest, which is treated like development + // in the component's ternary. Let's verify the fallback text appears. + render( + + + + ); + // The fallback shows "An unexpected error occurred." in production, + // or the actual error message in development. In test env, NODE_ENV='test' + // so it falls to the else branch. + expect( + screen.getByText("An unexpected error occurred.") + ).toBeInTheDocument(); + }); + + it("should render custom fallback when provided", () => { + render( + Custom error UI
}> + + + ); + expect(screen.getByText("Custom error UI")).toBeInTheDocument(); + expect(screen.queryByText("Something went wrong")).not.toBeInTheDocument(); + }); + + it("should log error information via componentDidCatch", () => { + render( + + + + ); + expect(console.error).toHaveBeenCalled(); + const calls = (console.error as jest.Mock).mock.calls; + const boundaryCall = calls.find( + (c: unknown[]) => c[0] === "[ErrorBoundary]" + ); + expect(boundaryCall).toBeDefined(); + }); + + it("should not render children content when error boundary has caught", () => { + render( + + + + ); + expect(screen.queryByText("Child content")).not.toBeInTheDocument(); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + }); +}); diff --git a/src/UILayer/web/src/components/Navigation/ConnectionIndicator.test.tsx b/src/UILayer/web/src/components/Navigation/ConnectionIndicator.test.tsx new file mode 100644 index 00000000..5e6a5bd6 --- /dev/null +++ b/src/UILayer/web/src/components/Navigation/ConnectionIndicator.test.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { ConnectionIndicator } from "./ConnectionIndicator"; + +// Mock useSignalR hook +const mockUseSignalR = jest.fn(); +jest.mock("@/hooks/useSignalR", () => ({ + useSignalR: (...args: unknown[]) => mockUseSignalR(...args), +})); + +// Mock lucide-react icons to simple elements +jest.mock("lucide-react", () => ({ + Wifi: (props: React.SVGAttributes) => ( + + ), + WifiOff: (props: React.SVGAttributes) => ( + + ), +})); + +beforeEach(() => { + mockUseSignalR.mockReset(); +}); + +describe("ConnectionIndicator", () => { + it("should render a status dot element", () => { + mockUseSignalR.mockReturnValue({ status: "connected" }); + const { container } = render(); + const dot = container.querySelector("span.rounded-full"); + expect(dot).toBeInTheDocument(); + }); + + it("should show 'Connected' in the title when connected", () => { + mockUseSignalR.mockReturnValue({ status: "connected" }); + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.getAttribute("title")).toBe("Connected"); + }); + + it("should show 'Disconnected' in the title when disconnected", () => { + mockUseSignalR.mockReturnValue({ status: "disconnected" }); + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.getAttribute("title")).toBe("Disconnected"); + }); + + it("should show 'Connecting...' in the title when connecting", () => { + mockUseSignalR.mockReturnValue({ status: "connecting" }); + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.getAttribute("title")).toBe("Connecting..."); + }); + + it("should show 'Reconnecting...' in the title when reconnecting", () => { + mockUseSignalR.mockReturnValue({ status: "reconnecting" }); + const { container } = render(); + const wrapper = container.firstChild as HTMLElement; + expect(wrapper.getAttribute("title")).toBe("Reconnecting..."); + }); + + it("should render Wifi icon when connected", () => { + mockUseSignalR.mockReturnValue({ status: "connected" }); + render(); + expect(screen.getByTestId("wifi-icon")).toBeInTheDocument(); + }); + + it("should render WifiOff icon when disconnected", () => { + mockUseSignalR.mockReturnValue({ status: "disconnected" }); + render(); + expect(screen.getByTestId("wifi-off-icon")).toBeInTheDocument(); + }); + + it("should apply green color class when connected", () => { + mockUseSignalR.mockReturnValue({ status: "connected" }); + const { container } = render(); + const dot = container.querySelector("span.rounded-full"); + expect(dot?.className).toContain("bg-green-500"); + }); + + it("should apply red color class when disconnected", () => { + mockUseSignalR.mockReturnValue({ status: "disconnected" }); + const { container } = render(); + const dot = container.querySelector("span.rounded-full"); + expect(dot?.className).toContain("bg-red-500"); + }); +}); diff --git a/src/UILayer/web/src/components/Navigation/Sidebar.tsx b/src/UILayer/web/src/components/Navigation/Sidebar.tsx index 27856bd3..2522b442 100644 --- a/src/UILayer/web/src/components/Navigation/Sidebar.tsx +++ b/src/UILayer/web/src/components/Navigation/Sidebar.tsx @@ -3,15 +3,23 @@ import Link from "next/link" import { usePathname } from "next/navigation" import { usePreferencesStore } from "@/stores" +import { useAuth } from "@/contexts/AuthContext" import { navItems, groupBySections } from "./navItems" import { LayoutDashboard, Bot, BarChart3, + Braces, ShieldCheck, Store, Settings, User, + Users, + Network, + Scale, + TrendingUp, + Activity, + Layers, ChevronLeft, ChevronRight, type LucideIcon, @@ -21,21 +29,30 @@ const iconMap: Record = { LayoutDashboard, Bot, BarChart3, + Braces, ShieldCheck, Store, Settings, User, + Users, + Network, + Scale, + TrendingUp, + Activity, + Layers, } export function Sidebar() { const pathname = usePathname() const collapsed = usePreferencesStore((s) => s.sidebarCollapsed) const toggleSidebar = usePreferencesStore((s) => s.toggleSidebar) + const { user } = useAuth() const sections = groupBySections(navItems) + const primaryRole = user?.roles?.[0] ?? null return (
{/* Nav sections */} -