+
{collapsed ? (
)}
+
)}
diff --git a/src/UILayer/web/src/components/Navigation/navItems.ts b/src/UILayer/web/src/components/Navigation/navItems.ts
index 76aa5837..6649ffe4 100644
--- a/src/UILayer/web/src/components/Navigation/navItems.ts
+++ b/src/UILayer/web/src/components/Navigation/navItems.ts
@@ -4,25 +4,31 @@ export interface NavItem {
icon: string
section: string
badge?: string
+ preview?: boolean
}
-export const navItems: NavItem[] = [
+const allNavItems: NavItem[] = [
{ label: "Dashboard", href: "/dashboard", icon: "LayoutDashboard", section: "Core" },
- { label: "Agents", href: "/agents", icon: "Bot", section: "Core" },
+ { label: "Agents", href: "/agents", icon: "Bot", section: "Core", badge: "Preview", preview: true },
{ label: "Analytics", href: "/analytics", icon: "BarChart3", section: "Core" },
- { label: "Context Engineering", href: "/context-engineering", icon: "Braces", section: "Core" },
+ { label: "Context Engineering", href: "/context-engineering", icon: "Braces", section: "Core", badge: "Preview", preview: true },
{ label: "Compliance", href: "/compliance", icon: "ShieldCheck", section: "Governance" },
{ label: "Balance", href: "/balance", icon: "Scale", section: "Governance" },
- { label: "Value", href: "/value", icon: "TrendingUp", section: "Governance" },
+ { label: "Value", href: "/value", icon: "TrendingUp", section: "Governance", badge: "Preview", preview: true },
{ label: "Impact", href: "/impact", icon: "Activity", section: "Governance" },
- { label: "Sandwich", href: "/sandwich", icon: "Layers", section: "Governance" },
- { label: "Convener", href: "/convener", icon: "Users", section: "Governance" },
- { label: "Org Mesh", href: "/org-mesh", icon: "Network", section: "Governance" },
- { label: "Marketplace", href: "/marketplace", icon: "Store", section: "Governance" },
+ { label: "Sandwich", href: "/sandwich", icon: "Layers", section: "Governance", badge: "Preview", preview: true },
+ { label: "Convener", href: "/convener", icon: "Users", section: "Governance", badge: "Preview", preview: true },
+ { label: "Org Mesh", href: "/org-mesh", icon: "Network", section: "Governance", badge: "Preview", preview: true },
+ { label: "Marketplace", href: "/marketplace", icon: "Store", section: "Governance", badge: "Preview", preview: true },
{ label: "Settings", href: "/settings", icon: "Settings", section: "System" },
{ label: "Profile", href: "/profile", icon: "User", section: "System" },
]
+export const navItems: NavItem[] =
+ process.env.NEXT_PUBLIC_SHOW_PREVIEW_NAV === "true"
+ ? allNavItems
+ : allNavItems.filter((item) => !item.preview)
+
export const sectionOrder = ["Core", "Governance", "System"]
export function groupBySections(items: NavItem[]): Map
{
diff --git a/src/UILayer/web/src/components/widgets/AdaptiveBalance/AdaptiveBalanceDashboard.tsx b/src/UILayer/web/src/components/widgets/AdaptiveBalance/AdaptiveBalanceDashboard.tsx
index 793c916e..9ba67974 100644
--- a/src/UILayer/web/src/components/widgets/AdaptiveBalance/AdaptiveBalanceDashboard.tsx
+++ b/src/UILayer/web/src/components/widgets/AdaptiveBalance/AdaptiveBalanceDashboard.tsx
@@ -29,23 +29,26 @@ export default function AdaptiveBalanceDashboard() {
setLoading(true);
setError(null);
try {
- const [bal, ref] = await Promise.all([
+ const [balanceResult, reflexionResult] = await Promise.allSettled([
getAdaptiveBalance(),
getReflexionStatus(),
]);
- setBalance(bal);
- setReflexion(ref);
- // Select first dimension by default
- if (bal.dimensions.length > 0 && !selectedDim) {
- setSelectedDim(bal.dimensions[0].dimension);
+
+ if (balanceResult.status === 'rejected') {
+ throw balanceResult.reason;
}
+
+ const bal = balanceResult.value;
+ setBalance(bal);
+ setReflexion(reflexionResult.status === 'fulfilled' ? reflexionResult.value : null);
+ setSelectedDim((current) => current ?? bal.dimensions[0]?.dimension ?? null);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load adaptive balance data.';
setError(msg);
} finally {
setLoading(false);
}
- }, [selectedDim]);
+ }, []);
// Fetch history when dimension changes
useEffect(() => {
diff --git a/src/UILayer/web/src/components/widgets/Convener/ConvenerDashboard.tsx b/src/UILayer/web/src/components/widgets/Convener/ConvenerDashboard.tsx
index 4685b5b4..9cd9f6df 100644
--- a/src/UILayer/web/src/components/widgets/Convener/ConvenerDashboard.tsx
+++ b/src/UILayer/web/src/components/widgets/Convener/ConvenerDashboard.tsx
@@ -1,93 +1,336 @@
'use client';
-import React from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import SessionTimeline from './SessionTimeline';
+import {
+ discoverConvenerChampions,
+ getConvenerCommunityPulse,
+ getConvenerInnovationSpread,
+ getConvenerLearningRecommendations,
+} from '../api';
+import type {
+ ChampionSummary,
+ CommunityPulseResponse,
+ InnovationSpreadResult,
+ LearningCatalystResponse,
+} from '../types';
+
+type EndpointKey = 'champions' | 'pulse' | 'learning' | 'innovation';
+
+interface EndpointResult {
+ data: T | null;
+ error: string | null;
+}
+
+interface ConvenerDashboardProps {
+ skill?: string;
+ channelId?: string;
+ ideaId?: string;
+}
+
+async function capture(request: Promise): Promise> {
+ try {
+ return { data: await request, error: null };
+ } catch (err) {
+ return {
+ data: null,
+ error: err instanceof Error ? err.message : 'Request failed.',
+ };
+ }
+}
+
+function formatPercent(value?: number | null): string {
+ if (typeof value !== 'number' || Number.isNaN(value)) return '-';
+ return `${Math.round(value)}%`;
+}
+
+function formatScore(value?: number | null): string {
+ if (typeof value !== 'number' || Number.isNaN(value)) return '-';
+ return value.toFixed(2);
+}
+
+function formatActivityType(value: string | number): string {
+ if (typeof value === 'string') return value;
+ const labels = ['Article', 'Course', 'Mentorship', 'Project', 'Peer Session'];
+ return labels[value] ?? 'Learning';
+}
/**
* FE-018: Convener Dashboard widget.
*
- * Displays meeting and session orchestration information.
- * Currently renders a placeholder layout since the backend Convener API is not yet deployed.
+ * Displays live Convener API data exposed by ApiHost when the backend services
+ * are reachable, with partial rendering for unavailable endpoints.
*/
-export default function ConvenerDashboard() {
+export default function ConvenerDashboard({
+ skill = '',
+ channelId = 'default-channel',
+ ideaId = 'default-idea',
+}: ConvenerDashboardProps) {
+ const [champions, setChampions] = useState([]);
+ const [totalEvaluated, setTotalEvaluated] = useState(0);
+ const [pulse, setPulse] = useState(null);
+ const [learning, setLearning] = useState(null);
+ const [innovation, setInnovation] = useState(null);
+ const [errors, setErrors] = useState>>({});
+ const [loading, setLoading] = useState(true);
+
+ const fetchData = useCallback(async () => {
+ setLoading(true);
+ setErrors({});
+
+ const [championResult, pulseResult, learningResult, innovationResult] =
+ await Promise.all([
+ capture(discoverConvenerChampions(skill, 5)),
+ capture(getConvenerCommunityPulse(channelId, 30)),
+ capture(getConvenerLearningRecommendations({ focusAreas: skill ? [skill] : [], maxRecommendations: 5 })),
+ capture(getConvenerInnovationSpread(ideaId)),
+ ]);
+
+ setChampions(championResult.data?.champions ?? []);
+ setTotalEvaluated(championResult.data?.totalEvaluated ?? 0);
+ setPulse(pulseResult.data);
+ setLearning(learningResult.data);
+ setInnovation(innovationResult.data);
+
+ setErrors({
+ ...(championResult.error ? { champions: championResult.error } : {}),
+ ...(pulseResult.error ? { pulse: pulseResult.error } : {}),
+ ...(learningResult.error ? { learning: learningResult.error } : {}),
+ ...(innovationResult.error ? { innovation: innovationResult.error } : {}),
+ });
+ setLoading(false);
+ }, [channelId, ideaId, skill]);
+
+ useEffect(() => {
+ const refreshTimer = window.setTimeout(() => void fetchData(), 0);
+ return () => window.clearTimeout(refreshTimer);
+ }, [fetchData]);
+
+ const errorEntries = Object.entries(errors);
+ const hasAnyData =
+ champions.length > 0 ||
+ Boolean(pulse) ||
+ Boolean(learning?.recommendations.length) ||
+ Boolean(innovation);
+
+ const timelineSessions = useMemo(() => {
+ const sessions = [];
+
+ if (champions.length > 0) {
+ sessions.push({
+ sessionId: 'champion-discovery',
+ title: `Champion discovery returned ${champions.length} match${champions.length === 1 ? '' : 'es'}`,
+ status: 'completed' as const,
+ startedAt: champions[0]?.lastActiveDate ?? new Date().toISOString(),
+ participants: champions.length,
+ });
+ }
+
+ if (pulse) {
+ sessions.push({
+ sessionId: 'community-pulse',
+ title: `Community pulse for ${pulse.channelId}`,
+ status: 'active' as const,
+ startedAt: pulse.endDate ?? new Date().toISOString(),
+ participants: pulse.engagement?.activeUsers ?? 0,
+ });
+ }
+
+ if (learning?.recommendations.length) {
+ sessions.push({
+ sessionId: 'learning-catalysts',
+ title: 'Learning catalyst recommendations refreshed',
+ status: 'scheduled' as const,
+ startedAt: new Date().toISOString(),
+ participants: learning.recommendations.length,
+ });
+ }
+
+ if (innovation) {
+ sessions.push({
+ sessionId: `innovation-${innovation.ideaId}`,
+ title: `Innovation spread phase: ${innovation.phase}`,
+ status: 'completed' as const,
+ startedAt: innovation.proposedAt,
+ participants: innovation.adoptionCount,
+ });
+ }
+
+ return sessions;
+ }, [champions, innovation, learning, pulse]);
+
+ if (loading) {
+ return (
+
+
+
+ {[1, 2, 3, 4].map((key) => (
+
+ ))}
+
+
+
+ );
+ }
+
return (
- {/* Header */}
-
-
Convener
-
- Session orchestration, meeting management, and collaboration coordination
-
+
+
+
Convener
+
+ Champion discovery, community pulse, learning catalysts, and innovation spread
+
+
+
- {/* Coming soon banner */}
-
-
- Convener data will be available when the backend API is deployed.
-
-
- This dashboard will orchestrate multi-agent sessions, coordinate
- collaborative reasoning, and manage decision-making workflows.
-
-
+ {errorEntries.length > 0 && (
+
+
+ Some Convener endpoints are unavailable.
+
+
+ {errorEntries.map(([key, message]) => (
+ -
+ {key}: {message}
+
+ ))}
+
+
+ )}
+
+ {!hasAnyData && (
+
+
No Convener data is available yet.
+
+ The dashboard is wired to the ApiHost Convener routes and will populate as those services return data.
+
+
+ )}
- {/* Metrics row */}
- {[
- { label: 'Active Sessions', value: '--' },
- { label: 'Total Sessions', value: '--' },
- { label: 'Avg. Duration', value: '--' },
- { label: 'Participants Today', value: '--' },
- ].map((metric) => (
-
-
{metric.label}
-
{metric.value}
-
- ))}
+
+
+
+
- {/* Session timeline placeholder */}
-
-
Recent Sessions
-
+
+
+ Top Champions
+ {champions.length === 0 ? (
+ No champion matches returned.
+ ) : (
+
+ {champions.map((champion) => (
+
+
+
{champion.userId}
+
+ {formatScore(champion.influenceScore)}
+
+
+
+ {champion.interactionCount} interactions - active {new Date(champion.lastActiveDate).toLocaleDateString()}
+
+ {champion.skills.length > 0 && (
+
+ {champion.skills.slice(0, 4).map((item) => (
+
+ {item}
+
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ Community Pulse
+ {!pulse ? (
+ No community pulse returned.
+ ) : (
+
+
+
+
+
+
+ )}
+
- {/* Orchestration modes */}
-
-
- Orchestration Modes
-
-
- {[
- {
- mode: 'Debate',
- description: 'Multi-agent adversarial reasoning sessions',
- },
- {
- mode: 'Sequential',
- description: 'Ordered step-by-step agent collaboration',
- },
- {
- mode: 'Strategic Simulation',
- description: 'Scenario-based strategy evaluation',
- },
- ].map((item) => (
-
-
{item.mode}
-
{item.description}
-
- Pending
-
+
+
+ Learning Catalysts
+ {!learning?.recommendations.length ? (
+ No recommendations returned.
+ ) : (
+
+ {learning.recommendations.slice(0, 5).map((item) => (
+
+
+
+
{item.title}
+
{item.description}
+
+
{formatScore(item.relevanceScore)}
+
+
+ {formatActivityType(item.activityType)} - {item.targetSkill} - {item.estimatedMinutes} min
+
+
+ ))}
- ))}
-
+ )}
+
+
+
+ Recent Convener Signals
+
+
);
}
+
+function MetricCard({
+ label,
+ value,
+ detail,
+ compact = false,
+}: {
+ label: string;
+ value: string;
+ detail: string;
+ compact?: boolean;
+}) {
+ return (
+
+
{label}
+
{value}
+
{detail}
+
+ );
+}
diff --git a/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactMetricsDashboard.tsx b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactMetricsDashboard.tsx
index aa11e896..9b9ede0d 100644
--- a/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactMetricsDashboard.tsx
+++ b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactMetricsDashboard.tsx
@@ -1,80 +1,160 @@
'use client';
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useEffect, useCallback, useMemo } 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';
+import { getImpactReport, getImpactUsageSummary, getResistancePatterns, getSafetyScoreHistory } from '../api';
+import type { AdoptionTelemetry, ImpactReport, PsychologicalSafetyScore, ResistanceIndicator } from '../types';
interface ImpactMetricsDashboardProps {
tenantId?: string;
+ teamId?: string;
}
-export default function ImpactMetricsDashboard({ tenantId = 'default-tenant' }: ImpactMetricsDashboardProps) {
+const DEFAULT_TENANT_ID = process.env.NEXT_PUBLIC_MYSTIRA_TENANT_ID ?? 'demo-tenant';
+const DEFAULT_TEAM_ID = process.env.NEXT_PUBLIC_IMPACT_METRICS_TEAM_ID ?? 'default-team';
+
+function formatDimensionLabel(value: string): string {
+ return value.replace(/([a-z])([A-Z])/g, '$1 $2').replace('AI', 'AI');
+}
+
+function toErrorMessage(value: unknown): string {
+ return value instanceof Error ? value.message : 'Request failed.';
+}
+
+export default function ImpactMetricsDashboard({
+ tenantId = DEFAULT_TENANT_ID,
+ teamId = DEFAULT_TEAM_ID,
+}: ImpactMetricsDashboardProps) {
const [report, setReport] = useState
(null);
const [resistance, setResistance] = useState([]);
+ const [usage, setUsage] = useState([]);
+ const [safetyHistory, setSafetyHistory] = useState([]);
const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(null);
+ const [warnings, setWarnings] = useState([]);
- const fetchData = useCallback(async () => {
- setLoading(true);
+ const fetchData = useCallback(async (isRefresh = false) => {
+ if (isRefresh) {
+ setRefreshing(true);
+ } else {
+ 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);
+ setWarnings([]);
+
+ const [reportResult, resistanceResult, usageResult, safetyResult] = await Promise.allSettled([
+ getImpactReport(tenantId),
+ getResistancePatterns(tenantId),
+ getImpactUsageSummary(tenantId),
+ getSafetyScoreHistory(teamId, tenantId),
+ ]);
+
+ const nextWarnings: string[] = [];
+
+ if (reportResult.status === 'fulfilled') {
+ setReport(reportResult.value);
+ } else {
+ setError(`Impact report unavailable: ${toErrorMessage(reportResult.reason)}`);
+ }
+
+ if (resistanceResult.status === 'fulfilled') {
+ setResistance(resistanceResult.value);
+ } else {
+ nextWarnings.push(`Resistance patterns unavailable: ${toErrorMessage(resistanceResult.reason)}`);
+ }
+
+ if (usageResult.status === 'fulfilled') {
+ setUsage(usageResult.value);
+ } else {
+ nextWarnings.push(`Usage summary unavailable: ${toErrorMessage(usageResult.reason)}`);
+ }
+
+ if (safetyResult.status === 'fulfilled') {
+ setSafetyHistory(safetyResult.value);
+ } else {
+ nextWarnings.push(`Safety history unavailable: ${toErrorMessage(safetyResult.reason)}`);
}
- }, [tenantId]);
- useEffect(() => { void fetchData(); }, [fetchData]);
+ setWarnings(nextWarnings);
+ setLoading(false);
+ setRefreshing(false);
+ }, [teamId, tenantId]);
+
+ useEffect(() => {
+ const timeoutId = window.setTimeout(() => void fetchData(), 0);
+ return () => window.clearTimeout(timeoutId);
+ }, [fetchData]);
+
+ const latestSafety = useMemo(
+ () => [...safetyHistory].sort((a, b) => Date.parse(b.calculatedAt) - Date.parse(a.calculatedAt))[0],
+ [safetyHistory],
+ );
+
+ const activeUsageCount = usage.filter((item) =>
+ item.action === 'FeatureUse' ||
+ item.action === 'WorkflowComplete' ||
+ item.action === 'Login'
+ ).length;
+
+ const radarLabels: string[] = [];
+ const radarValues: number[] = [];
+ if (latestSafety && Object.keys(latestSafety.dimensions).length > 0) {
+ for (const [label, value] of Object.entries(latestSafety.dimensions)) {
+ radarLabels.push(formatDimensionLabel(label));
+ radarValues.push(value);
+ }
+ } else if (report) {
+ radarLabels.push('Safety', 'Alignment', 'Adoption', 'Overall');
+ radarValues.push(report.safetyScore, report.alignmentScore * 100, report.adoptionRate * 100, report.overallImpactScore);
+ }
if (loading) {
return (
-
- {[1, 2, 3].map((k) =>
)}
+
+ {[1, 2, 3, 4].map((k) =>
)}
);
}
- if (error) {
+ if (error && !report) {
return (
Error loading impact metrics
{error}
-
+
Tenant: {tenantId}
+
);
}
- 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 &&
Report generated {new Date(report.generatedAt).toLocaleDateString()} for tenant {tenantId}
}
-
+
+ {(error || warnings.length > 0) && (
+
+ {error &&
{error}
}
+ {warnings.map((warning) => (
+
{warning}
+ ))}
+
+ )}
+
{report && (
<>
@@ -95,9 +175,29 @@ export default function ImpactMetricsDashboard({ tenantId = 'default-tenant' }:
-
-
Impact Dimensions
-
+
+
+
Impact Dimensions
+
+
+
+
+
Live API Signals
+
+
+
- Telemetry events
+ - {usage.length}
+
+
+
- Active-use events
+ - {activeUsageCount}
+
+
+
- Safety history points
+ - {safetyHistory.length}
+
+
+
{report.recommendations.length > 0 && (
diff --git a/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactTimeline.tsx b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactTimeline.tsx
index 8eb91fda..5fb3c983 100644
--- a/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactTimeline.tsx
+++ b/src/UILayer/web/src/components/widgets/ImpactMetrics/ImpactTimeline.tsx
@@ -7,9 +7,15 @@ 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';
+function severityLabel(severity: number): string {
+ if (severity >= 0.7) return 'High';
+ if (severity >= 0.35) return 'Medium';
+ return 'Low';
+}
+
+function severityBadgeClass(severity: number): string {
+ if (severity >= 0.7) return 'bg-red-500/20 text-red-400';
+ if (severity >= 0.35) return 'bg-yellow-500/20 text-yellow-400';
return 'bg-gray-500/20 text-gray-400';
}
@@ -20,14 +26,15 @@ export default function ImpactTimeline({ indicators }: ImpactTimelineProps) {
return (
{indicators.map((ind) => (
-
+
- {ind.pattern}
- {ind.severity}
+ {ind.indicatorType}
+ {severityLabel(ind.severity)}
-
{ind.affectedUsers} affected users - {new Date(ind.detectedAt).toLocaleDateString()}
+
{ind.description}
+
{ind.affectedUserCount} affected users - {new Date(ind.firstDetectedAt).toLocaleDateString()}
))}
diff --git a/src/UILayer/web/src/components/widgets/NistCompliance/NistComplianceDashboard.tsx b/src/UILayer/web/src/components/widgets/NistCompliance/NistComplianceDashboard.tsx
index 2536c8d8..f5635fe7 100644
--- a/src/UILayer/web/src/components/widgets/NistCompliance/NistComplianceDashboard.tsx
+++ b/src/UILayer/web/src/components/widgets/NistCompliance/NistComplianceDashboard.tsx
@@ -4,8 +4,13 @@ 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';
+import { getNistScore, getNistRoadmap, getNistAuditLog, getNistChecklist } from '../api';
+import type {
+ NISTScoreResponse,
+ NISTRoadmapResponse,
+ NISTAuditEntry,
+ NISTChecklistResponse,
+} from '../types';
interface NistComplianceDashboardProps {
organizationId?: string;
@@ -23,35 +28,62 @@ export default function NistComplianceDashboard({
const [scoreData, setScoreData] = useState
(null);
const [roadmapData, setRoadmapData] = useState(null);
const [auditEntries, setAuditEntries] = useState([]);
+ const [checklistData, setChecklistData] = useState(null);
const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+ const [endpointErrors, setEndpointErrors] = useState([]);
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);
+ setEndpointErrors([]);
+
+ const results = await Promise.allSettled([
+ getNistScore(organizationId),
+ getNistRoadmap(organizationId),
+ getNistAuditLog(organizationId, 50),
+ getNistChecklist(organizationId),
+ ]);
+
+ const nextErrors: string[] = [];
+ const labels = ['score', 'roadmap', 'audit log', 'checklist'];
+
+ results.forEach((result, index) => {
+ if (result.status === 'rejected') {
+ const reason = result.reason instanceof Error ? result.reason.message : 'request failed';
+ nextErrors.push(`${labels[index]}: ${reason}`);
+ }
+ });
+
+ const [scoreResult, roadmapResult, auditResult, checklistResult] = results;
+
+ if (scoreResult.status === 'fulfilled') {
+ setScoreData(scoreResult.value);
+ }
+
+ if (roadmapResult.status === 'fulfilled') {
+ setRoadmapData(roadmapResult.value);
}
+
+ if (auditResult.status === 'fulfilled') {
+ setAuditEntries(auditResult.value.entries);
+ }
+
+ if (checklistResult.status === 'fulfilled') {
+ setChecklistData(checklistResult.value);
+ }
+
+ setEndpointErrors(nextErrors);
+ setLoading(false);
}, [organizationId]);
useEffect(() => {
- void fetchData();
+ const refreshTimer = window.setTimeout(() => void fetchData(), 0);
+ return () => window.clearTimeout(refreshTimer);
}, [fetchData]);
- // Loading skeleton
- if (loading) {
+ const hasAnyData = Boolean(scoreData || roadmapData || auditEntries.length > 0 || checklistData);
+ const failedCompletely = !loading && !hasAnyData && endpointErrors.length > 0;
+
+ if (loading && !hasAnyData) {
return (
@@ -65,12 +97,11 @@ export default function NistComplianceDashboard({
);
}
- // Error state
- if (error) {
+ if (failedCompletely) {
return (
Error loading compliance data
-
{error}
+
{endpointErrors.join('; ')}