From e2275133a51cd407f8807d1ebbc483e3f1b5f252 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 20 Mar 2026 21:35:42 +0000 Subject: [PATCH] Extract diagnostics settings owners --- .../subsystems/frontend-primitives.md | 64 +- .../v6/internal/subsystems/registry.json | 8 + .../components/Settings/DiagnosticsPanel.tsx | 805 +----------------- .../Settings/DiagnosticsResultsPanel.tsx | 497 +++++++++++ .../__tests__/settingsArchitecture.test.ts | 25 + .../components/Settings/diagnosticsModel.ts | 254 ++++++ .../Settings/useDiagnosticsPanelState.ts | 60 ++ .../frontendResourceTypeBoundaries.test.ts | 16 +- 8 files changed, 908 insertions(+), 821 deletions(-) create mode 100644 frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx create mode 100644 frontend-modern/src/components/Settings/diagnosticsModel.ts create mode 100644 frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 0fc0fd253a..06af676822 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -32,31 +32,35 @@ work extends shared components instead of creating new local variants. 10. `frontend-modern/src/components/Settings/AuditLogPanel.tsx` 11. `frontend-modern/src/components/Settings/useAuditLogPanelState.ts` 12. `frontend-modern/src/components/Settings/AuditWebhookPanel.tsx` -13. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx` -14. `frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx` -15. `frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx` -16. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` -17. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` -18. `frontend-modern/src/components/Settings/settingsHeaderMeta.ts` -19. `frontend-modern/src/components/Settings/SettingsPageShell.tsx` -20. `frontend-modern/src/components/Settings/settingsPanelRegistry.ts` -21. `frontend-modern/src/components/Settings/SSOProvidersPanel.tsx` -22. `frontend-modern/src/components/Settings/useSSOProvidersState.ts` -23. `frontend-modern/src/components/Settings/ssoProvidersModel.ts` -24. `frontend-modern/src/components/Settings/CopyCommandBlock.tsx` -25. `frontend-modern/src/components/Settings/UpdateInstallGuide.tsx` -26. `frontend-modern/src/components/Settings/updatesSettingsModel.ts` -27. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` -28. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts` -29. `tests/integration/tests/15-settings-shell-consistency.spec.ts` -30. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts` -31. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts` -32. `frontend-modern/src/features/` -33. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx` -34. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx` -35. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx` -36. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx` -37. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx` +13. `frontend-modern/src/components/Settings/CopyCommandBlock.tsx` +14. `frontend-modern/src/components/Settings/diagnosticsModel.ts` +15. `frontend-modern/src/components/Settings/DiagnosticsPanel.tsx` +16. `frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx` +17. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx` +18. `frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx` +19. `frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx` +20. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` +21. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` +22. `frontend-modern/src/components/Settings/settingsHeaderMeta.ts` +23. `frontend-modern/src/components/Settings/SettingsPageShell.tsx` +24. `frontend-modern/src/components/Settings/settingsPanelRegistry.ts` +25. `frontend-modern/src/components/Settings/SSOProvidersPanel.tsx` +26. `frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts` +27. `frontend-modern/src/components/Settings/useSSOProvidersState.ts` +28. `frontend-modern/src/components/Settings/ssoProvidersModel.ts` +29. `frontend-modern/src/components/Settings/UpdateInstallGuide.tsx` +30. `frontend-modern/src/components/Settings/updatesSettingsModel.ts` +31. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` +32. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts` +33. `tests/integration/tests/15-settings-shell-consistency.spec.ts` +34. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts` +35. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts` +36. `frontend-modern/src/features/` +37. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx` +38. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx` +39. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx` +40. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx` +41. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx` ## Shared Boundaries @@ -126,6 +130,16 @@ license/paywall lifecycle, persisted filters, verification flow, and audit-log fetch orchestration. The shell must not re-accumulate localStorage or API runtime logic inline. +The diagnostics settings surface now follows that same owner split. +`frontend-modern/src/components/Settings/DiagnosticsPanel.tsx` stays the +top-level diagnostics shell, while +`frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts`, +`frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx`, and +`frontend-modern/src/components/Settings/diagnosticsModel.ts` own the +diagnostics run/export lifecycle, results rendering, and sanitization/model +helpers. The shell must not re-accumulate inline API calls, export-download +plumbing, or diagnostics-card composition. + The updates settings surface now follows the same presentation-owner rule. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` stays the top-level settings shell, while diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 6852132130..a40929177c 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1648,6 +1648,9 @@ "frontend-modern/src/components/Settings/AuditLogPanel.tsx", "frontend-modern/src/components/Settings/AuditWebhookPanel.tsx", "frontend-modern/src/components/Settings/CopyCommandBlock.tsx", + "frontend-modern/src/components/Settings/diagnosticsModel.ts", + "frontend-modern/src/components/Settings/DiagnosticsPanel.tsx", + "frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx", "frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx", "frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx", "frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx", @@ -1663,6 +1666,7 @@ "frontend-modern/src/components/Settings/updatesSettingsModel.ts", "frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx", "frontend-modern/src/components/Settings/useAuditLogPanelState.ts", + "frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts", "frontend-modern/src/components/Settings/useSSOProvidersState.ts", "frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx", "frontend-modern/src/components/SetupWizard/SetupWizard.tsx" @@ -1709,6 +1713,9 @@ "frontend-modern/src/components/Settings/AuditLogPanel.tsx", "frontend-modern/src/components/Settings/AuditWebhookPanel.tsx", "frontend-modern/src/components/Settings/CopyCommandBlock.tsx", + "frontend-modern/src/components/Settings/diagnosticsModel.ts", + "frontend-modern/src/components/Settings/DiagnosticsPanel.tsx", + "frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx", "frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx", "frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx", "frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx", @@ -1724,6 +1731,7 @@ "frontend-modern/src/components/Settings/updatesSettingsModel.ts", "frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx", "frontend-modern/src/components/Settings/useAuditLogPanelState.ts", + "frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts", "frontend-modern/src/components/Settings/useSSOProvidersState.ts" ], "allow_same_subsystem_tests": false, diff --git a/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx b/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx index d4ef6336a1..db1030c948 100644 --- a/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx +++ b/frontend-modern/src/components/Settings/DiagnosticsPanel.tsx @@ -1,383 +1,18 @@ -import { Component, Show, For, createSignal } from 'solid-js'; -import { apiFetchJSON } from '@/utils/apiClient'; -import { showSuccess, showError } from '@/utils/toast'; -import { formatRelativeTime } from '@/utils/format'; -import { Card } from '@/components/shared/Card'; +import { Component, Show } from 'solid-js'; import OperationsPanel from '@/components/Settings/OperationsPanel'; import Activity from 'lucide-solid/icons/activity'; -import Server from 'lucide-solid/icons/server'; -import HardDrive from 'lucide-solid/icons/hard-drive'; -import Database from 'lucide-solid/icons/database'; -import Network from 'lucide-solid/icons/network'; -import Shield from 'lucide-solid/icons/shield'; -import Cpu from 'lucide-solid/icons/cpu'; import RefreshCw from 'lucide-solid/icons/refresh-cw'; import Download from 'lucide-solid/icons/download'; -import CheckCircle from 'lucide-solid/icons/check-circle'; -import XCircle from 'lucide-solid/icons/x-circle'; -import AlertTriangle from 'lucide-solid/icons/alert-triangle'; -import Sparkles from 'lucide-solid/icons/sparkles'; -import { StatusDot } from '@/components/shared/StatusDot'; -import { getSimpleStatusIndicator, getStatusIndicatorBadgeToneClasses } from '@/utils/status'; -import { getSemanticTonePresentation } from '@/utils/semanticTonePresentation'; -import { DIAGNOSTICS_EMPTY_PBS_MESSAGE } from '@/utils/diagnosticsPresentation'; - -// Type definitions -interface DiagnosticsNode { - id: string; - name: string; - host: string; - type: string; - authMethod: string; - connected: boolean; - error?: string; - details?: Record; - lastPoll?: string; - clusterInfo?: Record; -} - -interface DiagnosticsPBS { - id: string; - name: string; - host: string; - connected: boolean; - error?: string; - details?: Record; -} - -interface SystemDiagnostic { - os: string; - arch: string; - goVersion: string; - numCPU: number; - numGoroutine: number; - memoryMB: number; -} - -interface DiscoveryDiagnostic { - enabled: boolean; - configuredSubnet?: string; - activeSubnet?: string; - environmentOverride?: string; - subnetAllowlist?: string[]; - subnetBlocklist?: string[]; - scanning?: boolean; - scanInterval?: string; - lastScanStartedAt?: string; - lastResultTimestamp?: string; - lastResultServers?: number; - lastResultErrors?: number; -} - -interface APITokenDiagnostic { - enabled: boolean; - tokenCount: number; - recommendTokenSetup: boolean; - unusedTokenCount?: number; - notes?: string[]; -} - -interface DockerAgentDiagnostic { - agentsTotal: number; - agentsOnline: number; - agentsReportingVersion: number; - agentsWithTokenBinding: number; - agentsWithoutTokenBinding: number; - agentsWithoutVersion?: number; - agentsOutdatedVersion?: number; - agentsWithStaleCommand?: number; - agentsPendingUninstall?: number; - agentsNeedingAttention: number; - recommendedAgentVersion?: string; - notes?: string[]; -} - -interface AlertsDiagnostic { - missingCooldown: boolean; - missingGroupingWindow: boolean; - notes?: string[]; -} - -interface MetricsStoreDiagnostic { - enabled: boolean; - status: 'healthy' | 'buffering' | 'empty' | 'unavailable'; - dbSize?: number; - rawCount?: number; - minuteCount?: number; - hourlyCount?: number; - dailyCount?: number; - totalPoints?: number; - bufferSize?: number; - notes?: string[]; - error?: string; -} - -interface AIChatDiagnostic { - enabled: boolean; - running: boolean; - healthy: boolean; - port?: number; - url?: string; - model?: string; - mcpConnected: boolean; - mcpToolCount?: number; - notes?: string[]; -} - -interface DiagnosticsData { - version: string; - runtime: string; - uptime: number; - nodes: DiagnosticsNode[]; - pbs: DiagnosticsPBS[]; - system: SystemDiagnostic; - metricsStore?: MetricsStoreDiagnostic | null; - apiTokens?: APITokenDiagnostic | null; - dockerAgents?: DockerAgentDiagnostic | null; - alerts?: AlertsDiagnostic | null; - aiChat?: AIChatDiagnostic | null; - discovery?: DiscoveryDiagnostic | null; - errors: string[]; -} - -// Utility functions -function formatUptime(seconds: number): string { - if (seconds < 60) return `${seconds}s`; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - if (hours < 24) return `${hours}h ${minutes}m`; - const days = Math.floor(hours / 24); - return `${days}d ${hours % 24}h`; -} - -import type { JSX } from 'solid-js'; - -// ... - -// DiagnosticCard - a mini card component for individual diagnostic items -const DiagnosticCard: Component<{ - title: string; - icon: Component<{ class?: string }>; - status?: 'success' | 'warning' | 'error' | 'info'; - children: JSX.Element; -}> = (props) => { - const tone = () => getSemanticTonePresentation(props.status || 'info'); - - return ( -
-
-
- -
-

{props.title}

-
-
{props.children}
-
- ); -}; - -// StatusBadge component -const StatusBadge: Component<{ - status: 'online' | 'offline' | 'warning' | 'unknown'; - label?: string; -}> = (props) => { - const indicator = () => getSimpleStatusIndicator(props.status); - - return ( - - - {props.label || indicator().label} - - ); -}; - -// MetricRow component for displaying key-value pairs -const MetricRow: Component<{ - label: string; - value: string | number | undefined; - mono?: boolean; -}> = (props) => ( -
- {props.label} - - {props.value ?? 'Unknown'} - -
-); +import { DiagnosticsResultsPanel } from '@/components/Settings/DiagnosticsResultsPanel'; +import { useDiagnosticsPanelState } from '@/components/Settings/useDiagnosticsPanelState'; +import { formatUptime } from '@/components/Settings/diagnosticsModel'; export const DiagnosticsPanel: Component = () => { - const [loading, setLoading] = createSignal(false); - const [diagnosticsData, setDiagnosticsData] = createSignal(null); - const [exportLoading, setExportLoading] = createSignal(false); - - const runDiagnostics = async () => { - setLoading(true); - try { - const data = (await apiFetchJSON('/api/diagnostics')) as DiagnosticsData; - setDiagnosticsData(data); - showSuccess('Diagnostics completed'); - } catch (error) { - showError(error instanceof Error ? error.message : 'Failed to run diagnostics'); - } finally { - setLoading(false); - } - }; - - // sanitizeDiagnostics redacts IPs, hostnames, subnets, and token hints - // so the export is safe to attach to a public GitHub issue. - const sanitizeDiagnostics = (raw: DiagnosticsData): DiagnosticsData => { - const data: DiagnosticsData = JSON.parse(JSON.stringify(raw)); - - // Regex matching IPv4 addresses, IPv6 addresses, hostnames with dots, and CIDR notation - const ipv4Re = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(\/\d{1,2})?\b/g; - - const redactString = (s: string): string => s.replace(ipv4Re, '[REDACTED_IP]'); - - // Redact node hosts - if (Array.isArray(data.nodes)) { - data.nodes = data.nodes.map((node, i) => ({ - ...node, - host: `node-${i + 1}`, - name: `node-${i + 1}`, - id: `node-${i + 1}`, - error: node.error ? redactString(node.error) : undefined, - })); - } - - // Redact PBS hosts - if (Array.isArray(data.pbs)) { - data.pbs = data.pbs.map((p, i) => ({ - ...p, - host: `pbs-${i + 1}`, - name: `pbs-${i + 1}`, - id: `pbs-${i + 1}`, - error: p.error ? redactString(p.error) : undefined, - })); - } - - // Redact discovery subnets - if (data.discovery) { - data.discovery = { - ...data.discovery, - configuredSubnet: data.discovery.configuredSubnet ? '[REDACTED_SUBNET]' : undefined, - activeSubnet: data.discovery.activeSubnet ? '[REDACTED_SUBNET]' : undefined, - environmentOverride: data.discovery.environmentOverride ? '[REDACTED]' : undefined, - subnetAllowlist: data.discovery.subnetAllowlist?.map(() => '[REDACTED_SUBNET]'), - subnetBlocklist: data.discovery.subnetBlocklist?.map(() => '[REDACTED_SUBNET]'), - }; - // Redact IPs in discovery history (field exists in backend but not in TS interface) - const disc = data.discovery as any; - if (Array.isArray(disc.history)) { - disc.history = disc.history.map((h: Record) => ({ - ...h, - subnet: '[REDACTED_SUBNET]', - })); - } - } - - // Redact API token details (tokens/usage arrays exist in backend but not in TS interface) - if (data.apiTokens) { - const tokens = data.apiTokens as any; - if (Array.isArray(tokens.tokens)) { - tokens.tokens = tokens.tokens.map((t: Record, i: number) => ({ - ...t, - hint: '[REDACTED]', - id: `token-${i + 1}`, - name: `token-${i + 1}`, - })); - } - if (Array.isArray(tokens.usage)) { - tokens.usage = tokens.usage.map((u: Record) => ({ - ...u, - hosts: undefined, - })); - } - } - - // Redact Docker agent identifiers - if (data.dockerAgents) { - const agents = data.dockerAgents as any; - if (Array.isArray(agents.attention)) { - agents.attention = agents.attention.map((a: Record, i: number) => ({ - ...a, - agentId: `docker-host-${i + 1}`, - name: `docker-host-${i + 1}`, - tokenHint: a.tokenHint ? '[REDACTED]' : undefined, - })); - } - } - - // Redact AI chat URL - if (data.aiChat?.url) { - data.aiChat.url = '[REDACTED]'; - } - - // Redact IPs in error messages - if (Array.isArray(data.errors)) { - data.errors = data.errors.map(redactString); - } - - // Redact IPs from any raw snapshot data that may be present - const raw2 = data as any; - if (Array.isArray(raw2.nodeSnapshots)) { - raw2.nodeSnapshots = raw2.nodeSnapshots.map((s: Record, i: number) => ({ - ...s, - instance: `node-${i + 1}`, - })); - } - if (Array.isArray(raw2.guestSnapshots)) { - raw2.guestSnapshots = raw2.guestSnapshots.map((s: Record, i: number) => ({ - ...s, - instance: `node-${i + 1}`, - })); - } - if (Array.isArray(raw2.memorySources)) { - raw2.memorySources = raw2.memorySources.map((s: Record, i: number) => ({ - ...s, - instance: `node-${i + 1}`, - })); - } - - return data; - }; - - const exportDiagnostics = async (sanitize: boolean) => { - setExportLoading(true); - try { - const data = diagnosticsData(); - if (!data) { - showError('Run diagnostics first'); - return; - } - - const exportData = sanitize ? sanitizeDiagnostics(data) : data; - const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - const type = sanitize ? 'sanitized' : 'full'; - a.download = `pulse-diagnostics-${type}-${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - showSuccess(`Diagnostics exported (${type})`); - } finally { - setExportLoading(false); - } - }; + const { diagnosticsData, exportDiagnostics, exportLoading, loading, runDiagnostics } = + useDiagnosticsPanelState(); return (
- {/* Header Card */} {
} > - {/* Quick Actions */}

Test all connections and inspect runtime configuration

@@ -430,428 +64,11 @@ export const DiagnosticsPanel: Component = () => {
- - {/* Diagnostics Content */} - -
- -

No diagnostics data yet

-

- Click "Run Diagnostics" above to test connections and inspect system status -

- -
- - } - > - {/* Summary Grid */} -
- {/* System Info Card */} - - - - - - - - - {/* Nodes Status Card */} - n.connected) ? 'success' : 'warning'} - > -
- Total Nodes - - {diagnosticsData()?.nodes?.length || 0} - -
-
- - {(node) => ( -
- - {node.name} - - -
- )} -
-
-
- - {/* PBS Status Card */} - p.connected) - ? 'success' - : diagnosticsData()?.pbs?.length - ? 'warning' - : 'info' - } - > - 0} - fallback={ -
{DIAGNOSTICS_EMPTY_PBS_MESSAGE}
- } - > -
- Total Instances - - {diagnosticsData()?.pbs?.length || 0} - -
-
- - {(pbs) => ( -
- - {pbs.name} - - -
- )} -
-
-
-
- - {/* Discovery Status Card */} - - - - - - - -
- - {/* Detailed Status Cards */} -
- {/* Metrics Store */} - - -
-
- -
-
-

Metrics Store

-

History persistence health

-
-
- -
-
-
- - - - - - - - -
- 0}> -
-
- -
- - {(note) =>
{note}
} -
-
-
-
-
- -
- {diagnosticsData()?.metricsStore?.error} -
-
-
-
- - {/* API Tokens */} - - -
-
- -
-
-

API Tokens

-

Authentication status

-
-
- -
-
-
- - -
-
-
- - {/* Container Runtime Agents */} - - -
-
- -
-
-

Container Runtime Agents

-

Agent-backed container runtime monitoring

-
-
-
- {diagnosticsData()?.dockerAgents?.agentsOnline}/ - {diagnosticsData()?.dockerAgents?.agentsTotal} -
-
online
-
-
-
- - - -
- -
- Recommended version: {diagnosticsData()?.dockerAgents?.recommendedAgentVersion} -
-
-
-
- - {/* Alerts Configuration */} - - -
-
- -
-
-

Alerts Configuration

-

Alert system status

-
-
-
- - Cooldown: {diagnosticsData()?.alerts?.missingCooldown ? 'Missing' : 'Configured'} - - - Grouping:{' '} - {diagnosticsData()?.alerts?.missingGroupingWindow ? 'Disabled' : 'Enabled'} - -
- 0}> -
    - - {(note) =>
  • {note}
  • } -
    -
-
-
-
- - {/* AI Chat Status */} - - -
-
- -
-
-

Pulse Assistant

-

Pulse Assistant Service

-
-
- -
-
-
- - - -
-
- MCP Connection -
- {diagnosticsData()?.aiChat?.mcpConnected ? ( - - ) : ( - - )} - - {diagnosticsData()?.aiChat?.mcpConnected ? 'Connected' : 'Disconnected'} - -
-
- 0}> -
    - - {(note) =>
  • {note}
  • } -
    -
-
-
-
-
- - {/* Errors Section */} - 0}> - -
- -

Errors Detected

-
-
    - - {(error) => ( -
  • - • - {error} -
  • - )} -
    -
-
-
-
+ ); }; diff --git a/frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx b/frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx new file mode 100644 index 0000000000..dfed404b9e --- /dev/null +++ b/frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx @@ -0,0 +1,497 @@ +import { Component, For, Show } from 'solid-js'; +import type { JSX } from 'solid-js'; +import { formatRelativeTime } from '@/utils/format'; +import { Card } from '@/components/shared/Card'; +import Activity from 'lucide-solid/icons/activity'; +import AlertTriangle from 'lucide-solid/icons/alert-triangle'; +import CheckCircle from 'lucide-solid/icons/check-circle'; +import Cpu from 'lucide-solid/icons/cpu'; +import Database from 'lucide-solid/icons/database'; +import HardDrive from 'lucide-solid/icons/hard-drive'; +import Network from 'lucide-solid/icons/network'; +import RefreshCw from 'lucide-solid/icons/refresh-cw'; +import Server from 'lucide-solid/icons/server'; +import Shield from 'lucide-solid/icons/shield'; +import Sparkles from 'lucide-solid/icons/sparkles'; +import XCircle from 'lucide-solid/icons/x-circle'; +import { StatusDot } from '@/components/shared/StatusDot'; +import { getSimpleStatusIndicator, getStatusIndicatorBadgeToneClasses } from '@/utils/status'; +import { getSemanticTonePresentation } from '@/utils/semanticTonePresentation'; +import { DIAGNOSTICS_EMPTY_PBS_MESSAGE } from '@/utils/diagnosticsPresentation'; +import type { DiagnosticsData } from '@/components/Settings/diagnosticsModel'; + +const DiagnosticCard: Component<{ + children: JSX.Element; + icon: Component<{ class?: string }>; + status?: 'success' | 'warning' | 'error' | 'info'; + title: string; +}> = (props) => { + const tone = () => getSemanticTonePresentation(props.status || 'info'); + + return ( +
+
+
+ +
+

{props.title}

+
+
{props.children}
+
+ ); +}; + +const StatusBadge: Component<{ + label?: string; + status: 'online' | 'offline' | 'warning' | 'unknown'; +}> = (props) => { + const indicator = () => getSimpleStatusIndicator(props.status); + + return ( + + + {props.label || indicator().label} + + ); +}; + +const MetricRow: Component<{ + label: string; + mono?: boolean; + value: string | number | undefined; +}> = (props) => ( +
+ {props.label} + + {props.value ?? 'Unknown'} + +
+); + +interface DiagnosticsResultsPanelProps { + diagnosticsData: DiagnosticsData | null; + loading: boolean; + onRunDiagnostics: () => void; +} + +export const DiagnosticsResultsPanel: Component = (props) => { + return ( + +
+ +

No diagnostics data yet

+

+ Click "Run Diagnostics" above to test connections and inspect system status +

+ +
+ + } + > +
+
+ + + + + + + + + node.connected) ? 'success' : 'warning'} + > +
+ Total Nodes + + {props.diagnosticsData?.nodes?.length || 0} + +
+
+ + {(node) => ( +
+ + {node.name} + + +
+ )} +
+
+
+ + pbs.connected) + ? 'success' + : props.diagnosticsData?.pbs?.length + ? 'warning' + : 'info' + } + > + 0} + fallback={
{DIAGNOSTICS_EMPTY_PBS_MESSAGE}
} + > +
+ Total Instances + + {props.diagnosticsData?.pbs?.length || 0} + +
+
+ + {(pbs) => ( +
+ + {pbs.name} + + +
+ )} +
+
+
+
+ + + + + + + + +
+ +
+ + +
+
+ +
+
+

Metrics Store

+

History persistence health

+
+
+ +
+
+
+ + + + + + + + +
+ 0}> +
+
+ +
+ + {(note) =>
{note}
} +
+
+
+
+
+ +
+ {props.diagnosticsData?.metricsStore?.error} +
+
+
+
+ + + +
+
+ +
+
+

API Tokens

+

Authentication status

+
+
+ +
+
+
+ + +
+
+
+ + + +
+
+ +
+
+

Container Runtime Agents

+

Agent-backed container runtime monitoring

+
+
+
+ {props.diagnosticsData?.dockerAgents?.agentsOnline}/ + {props.diagnosticsData?.dockerAgents?.agentsTotal} +
+
online
+
+
+
+ + + +
+ +
+ Recommended version: {props.diagnosticsData?.dockerAgents?.recommendedAgentVersion} +
+
+
+
+ + + +
+
+ +
+
+

Alerts Configuration

+

Alert system status

+
+
+
+ + Cooldown:{' '} + {props.diagnosticsData?.alerts?.missingCooldown ? 'Missing' : 'Configured'} + + + Grouping:{' '} + {props.diagnosticsData?.alerts?.missingGroupingWindow ? 'Disabled' : 'Enabled'} + +
+ 0}> +
    + + {(note) =>
  • {note}
  • } +
    +
+
+
+
+ + + +
+
+ +
+
+

Pulse Assistant

+

Pulse Assistant Service

+
+
+ +
+
+
+ + + +
+
+ MCP Connection +
+ } + > + + + + {props.diagnosticsData?.aiChat?.mcpConnected ? 'Connected' : 'Disconnected'} + +
+
+ 0}> +
    + + {(note) =>
  • {note}
  • } +
    +
+
+
+
+
+ + 0}> + +
+ +

Errors Detected

+
+
    + + {(error) => ( +
  • + • + {error} +
  • + )} +
    +
+
+
+
+
+ ); +}; diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 49fed3d7a2..b3546bccc6 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -20,6 +20,9 @@ import aiSettingsPanelSource from '../AISettings.tsx?raw'; import aiProviderConfigurationSectionSource from '../AIProviderConfigurationSection.tsx?raw'; import aiSettingsDialogsSource from '../AISettingsDialogs.tsx?raw'; import aiSettingsModelSource from '../aiSettingsModel.ts?raw'; +import diagnosticsModelSource from '../diagnosticsModel.ts?raw'; +import diagnosticsPanelSource from '../DiagnosticsPanel.tsx?raw'; +import diagnosticsResultsPanelSource from '../DiagnosticsResultsPanel.tsx?raw'; import networkSettingsPanelSource from '../NetworkSettingsPanel.tsx?raw'; import copyCommandBlockSource from '../CopyCommandBlock.tsx?raw'; import updateInstallGuideSource from '../UpdateInstallGuide.tsx?raw'; @@ -40,6 +43,7 @@ import securityAuthPanelSource from '../SecurityAuthPanel.tsx?raw'; import ssoProvidersPanelSource from '../SSOProvidersPanel.tsx?raw'; import ssoProvidersStateSource from '../useSSOProvidersState.ts?raw'; import ssoProvidersModelSource from '../ssoProvidersModel.ts?raw'; +import diagnosticsStateSource from '../useDiagnosticsPanelState.ts?raw'; import rolesPanelSource from '../RolesPanel.tsx?raw'; import userAssignmentsPanelSource from '../UserAssignmentsPanel.tsx?raw'; import { SETTINGS_HEADER_META } from '../settingsHeaderMeta'; @@ -64,9 +68,13 @@ const extractedModules = [ '../AIProviderConfigurationSection.tsx', '../AISettingsDialogs.tsx', '../aiSettingsModel.ts', + '../diagnosticsModel.ts', + '../DiagnosticsPanel.tsx', + '../DiagnosticsResultsPanel.tsx', '../CopyCommandBlock.tsx', '../UpdateInstallGuide.tsx', '../updatesSettingsModel.ts', + '../useDiagnosticsPanelState.ts', '../useSSOProvidersState.ts', '../ssoProvidersModel.ts', '../ProxmoxSettingsPanel.tsx', @@ -369,6 +377,23 @@ describe('Settings architecture guardrails', () => { expect(updatesSettingsModelSource).toContain('export function buildUpdateInstallGuide'); }); + it('keeps the diagnostics shell behind extracted runtime and results owners', () => { + expect(diagnosticsPanelSource).toContain('@/components/Settings/DiagnosticsResultsPanel'); + expect(diagnosticsPanelSource).toContain('@/components/Settings/useDiagnosticsPanelState'); + expect(diagnosticsPanelSource).toContain('formatUptime'); + expect(diagnosticsPanelSource).not.toContain("apiFetchJSON('/api/diagnostics')"); + expect(diagnosticsPanelSource).not.toContain('URL.createObjectURL'); + expect(diagnosticsPanelSource).not.toContain('sanitizeDiagnosticsData'); + expect(diagnosticsResultsPanelSource).toContain('DIAGNOSTICS_EMPTY_PBS_MESSAGE'); + expect(diagnosticsResultsPanelSource).toContain('getStatusIndicatorBadgeToneClasses('); + expect(diagnosticsStateSource).toContain('export const useDiagnosticsPanelState ='); + expect(diagnosticsStateSource).toContain("apiFetchJSON('/api/diagnostics')"); + expect(diagnosticsStateSource).toContain('URL.createObjectURL'); + expect(diagnosticsModelSource).toContain('export function sanitizeDiagnosticsData'); + expect(diagnosticsModelSource).toContain('export function buildDiagnosticsExportFilename'); + expect(diagnosticsModelSource).toContain('export function formatUptime'); + }); + it('keeps the audit log shell behind an extracted runtime owner', () => { expect(auditLogPanelSource).toContain('@/components/Settings/useAuditLogPanelState'); expect(auditLogPanelSource).not.toContain('createLocalStorageStringSignal'); diff --git a/frontend-modern/src/components/Settings/diagnosticsModel.ts b/frontend-modern/src/components/Settings/diagnosticsModel.ts new file mode 100644 index 0000000000..474343bcd0 --- /dev/null +++ b/frontend-modern/src/components/Settings/diagnosticsModel.ts @@ -0,0 +1,254 @@ +export interface DiagnosticsNode { + id: string; + name: string; + host: string; + type: string; + authMethod: string; + connected: boolean; + error?: string; + details?: Record; + lastPoll?: string; + clusterInfo?: Record; +} + +export interface DiagnosticsPBS { + id: string; + name: string; + host: string; + connected: boolean; + error?: string; + details?: Record; +} + +export interface SystemDiagnostic { + os: string; + arch: string; + goVersion: string; + numCPU: number; + numGoroutine: number; + memoryMB: number; +} + +export interface DiscoveryDiagnostic { + enabled: boolean; + configuredSubnet?: string; + activeSubnet?: string; + environmentOverride?: string; + subnetAllowlist?: string[]; + subnetBlocklist?: string[]; + scanning?: boolean; + scanInterval?: string; + lastScanStartedAt?: string; + lastResultTimestamp?: string; + lastResultServers?: number; + lastResultErrors?: number; +} + +export interface APITokenDiagnostic { + enabled: boolean; + tokenCount: number; + recommendTokenSetup: boolean; + unusedTokenCount?: number; + notes?: string[]; +} + +export interface DockerAgentDiagnostic { + agentsTotal: number; + agentsOnline: number; + agentsReportingVersion: number; + agentsWithTokenBinding: number; + agentsWithoutTokenBinding: number; + agentsWithoutVersion?: number; + agentsOutdatedVersion?: number; + agentsWithStaleCommand?: number; + agentsPendingUninstall?: number; + agentsNeedingAttention: number; + recommendedAgentVersion?: string; + notes?: string[]; +} + +export interface AlertsDiagnostic { + missingCooldown: boolean; + missingGroupingWindow: boolean; + notes?: string[]; +} + +export interface MetricsStoreDiagnostic { + enabled: boolean; + status: 'healthy' | 'buffering' | 'empty' | 'unavailable'; + dbSize?: number; + rawCount?: number; + minuteCount?: number; + hourlyCount?: number; + dailyCount?: number; + totalPoints?: number; + bufferSize?: number; + notes?: string[]; + error?: string; +} + +export interface AIChatDiagnostic { + enabled: boolean; + running: boolean; + healthy: boolean; + port?: number; + url?: string; + model?: string; + mcpConnected: boolean; + mcpToolCount?: number; + notes?: string[]; +} + +export interface DiagnosticsData { + version: string; + runtime: string; + uptime: number; + nodes: DiagnosticsNode[]; + pbs: DiagnosticsPBS[]; + system: SystemDiagnostic; + metricsStore?: MetricsStoreDiagnostic | null; + apiTokens?: APITokenDiagnostic | null; + dockerAgents?: DockerAgentDiagnostic | null; + alerts?: AlertsDiagnostic | null; + aiChat?: AIChatDiagnostic | null; + discovery?: DiscoveryDiagnostic | null; + errors: string[]; +} + +export function formatUptime(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours < 24) return `${hours}h ${minutes}m`; + const days = Math.floor(hours / 24); + return `${days}d ${hours % 24}h`; +} + +export function sanitizeDiagnosticsData(raw: DiagnosticsData): DiagnosticsData { + const data: DiagnosticsData = JSON.parse(JSON.stringify(raw)); + const ipv4Re = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(\/\d{1,2})?\b/g; + const redactString = (value: string): string => value.replace(ipv4Re, '[REDACTED_IP]'); + + if (Array.isArray(data.nodes)) { + data.nodes = data.nodes.map((node, index) => ({ + ...node, + host: `node-${index + 1}`, + name: `node-${index + 1}`, + id: `node-${index + 1}`, + error: node.error ? redactString(node.error) : undefined, + })); + } + + if (Array.isArray(data.pbs)) { + data.pbs = data.pbs.map((pbs, index) => ({ + ...pbs, + host: `pbs-${index + 1}`, + name: `pbs-${index + 1}`, + id: `pbs-${index + 1}`, + error: pbs.error ? redactString(pbs.error) : undefined, + })); + } + + if (data.discovery) { + data.discovery = { + ...data.discovery, + configuredSubnet: data.discovery.configuredSubnet ? '[REDACTED_SUBNET]' : undefined, + activeSubnet: data.discovery.activeSubnet ? '[REDACTED_SUBNET]' : undefined, + environmentOverride: data.discovery.environmentOverride ? '[REDACTED]' : undefined, + subnetAllowlist: data.discovery.subnetAllowlist?.map(() => '[REDACTED_SUBNET]'), + subnetBlocklist: data.discovery.subnetBlocklist?.map(() => '[REDACTED_SUBNET]'), + }; + + const discovery = data.discovery as DiscoveryDiagnostic & { + history?: Array>; + }; + if (Array.isArray(discovery.history)) { + discovery.history = discovery.history.map((historyEntry) => ({ + ...historyEntry, + subnet: '[REDACTED_SUBNET]', + })); + } + } + + if (data.apiTokens) { + const apiTokens = data.apiTokens as APITokenDiagnostic & { + tokens?: Array>; + usage?: Array>; + }; + + if (Array.isArray(apiTokens.tokens)) { + apiTokens.tokens = apiTokens.tokens.map((token, index) => ({ + ...token, + hint: '[REDACTED]', + id: `token-${index + 1}`, + name: `token-${index + 1}`, + })); + } + + if (Array.isArray(apiTokens.usage)) { + apiTokens.usage = apiTokens.usage.map((usage) => ({ + ...usage, + hosts: undefined, + })); + } + } + + if (data.dockerAgents) { + const dockerAgents = data.dockerAgents as DockerAgentDiagnostic & { + attention?: Array>; + }; + + if (Array.isArray(dockerAgents.attention)) { + dockerAgents.attention = dockerAgents.attention.map((attention, index) => ({ + ...attention, + agentId: `docker-host-${index + 1}`, + name: `docker-host-${index + 1}`, + tokenHint: attention.tokenHint ? '[REDACTED]' : undefined, + })); + } + } + + if (data.aiChat?.url) { + data.aiChat.url = '[REDACTED]'; + } + + if (Array.isArray(data.errors)) { + data.errors = data.errors.map(redactString); + } + + const rawSnapshotData = data as DiagnosticsData & { + nodeSnapshots?: Array>; + guestSnapshots?: Array>; + memorySources?: Array>; + }; + + if (Array.isArray(rawSnapshotData.nodeSnapshots)) { + rawSnapshotData.nodeSnapshots = rawSnapshotData.nodeSnapshots.map((snapshot, index) => ({ + ...snapshot, + instance: `node-${index + 1}`, + })); + } + + if (Array.isArray(rawSnapshotData.guestSnapshots)) { + rawSnapshotData.guestSnapshots = rawSnapshotData.guestSnapshots.map((snapshot, index) => ({ + ...snapshot, + instance: `node-${index + 1}`, + })); + } + + if (Array.isArray(rawSnapshotData.memorySources)) { + rawSnapshotData.memorySources = rawSnapshotData.memorySources.map((snapshot, index) => ({ + ...snapshot, + instance: `node-${index + 1}`, + })); + } + + return data; +} + +export function buildDiagnosticsExportFilename(sanitize: boolean, now = new Date()): string { + const exportType = sanitize ? 'sanitized' : 'full'; + const date = now.toISOString().split('T')[0]; + return `pulse-diagnostics-${exportType}-${date}.json`; +} diff --git a/frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts b/frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts new file mode 100644 index 0000000000..a191855440 --- /dev/null +++ b/frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts @@ -0,0 +1,60 @@ +import { createSignal } from 'solid-js'; +import { apiFetchJSON } from '@/utils/apiClient'; +import { showError, showSuccess } from '@/utils/toast'; +import { + buildDiagnosticsExportFilename, + sanitizeDiagnosticsData, + type DiagnosticsData, +} from '@/components/Settings/diagnosticsModel'; + +export const useDiagnosticsPanelState = () => { + const [loading, setLoading] = createSignal(false); + const [diagnosticsData, setDiagnosticsData] = createSignal(null); + const [exportLoading, setExportLoading] = createSignal(false); + + const runDiagnostics = async () => { + setLoading(true); + try { + const data = (await apiFetchJSON('/api/diagnostics')) as DiagnosticsData; + setDiagnosticsData(data); + showSuccess('Diagnostics completed'); + } catch (error) { + showError(error instanceof Error ? error.message : 'Failed to run diagnostics'); + } finally { + setLoading(false); + } + }; + + const exportDiagnostics = async (sanitize: boolean) => { + setExportLoading(true); + try { + const data = diagnosticsData(); + if (!data) { + showError('Run diagnostics first'); + return; + } + + const exportData = sanitize ? sanitizeDiagnosticsData(data) : data; + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = buildDiagnosticsExportFilename(sanitize); + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + showSuccess(`Diagnostics exported (${sanitize ? 'sanitized' : 'full'})`); + } finally { + setExportLoading(false); + } + }; + + return { + diagnosticsData, + exportDiagnostics, + exportLoading, + loading, + runDiagnostics, + }; +}; diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index aad5752357..f3d4668ca0 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -22,6 +22,9 @@ import updatesSettingsPanelSource from '@/components/Settings/UpdatesSettingsPan import copyCommandBlockSource from '@/components/Settings/CopyCommandBlock.tsx?raw'; import updateInstallGuideSource from '@/components/Settings/UpdateInstallGuide.tsx?raw'; import updatesSettingsModelSource from '@/components/Settings/updatesSettingsModel.ts?raw'; +import diagnosticsModelSource from '@/components/Settings/diagnosticsModel.ts?raw'; +import diagnosticsResultsPanelSource from '@/components/Settings/DiagnosticsResultsPanel.tsx?raw'; +import diagnosticsStateSource from '@/components/Settings/useDiagnosticsPanelState.ts?raw'; import updatesPresentationSource from '@/utils/updatesPresentation.ts?raw'; import environmentLockBadgeSource from '@/components/shared/EnvironmentLockBadge.tsx?raw'; import environmentLockPresentationSource from '@/utils/environmentLockPresentation.ts?raw'; @@ -2669,15 +2672,24 @@ describe('frontend resource type boundaries', () => { expect(auditLogPresentationSource).toContain('export const AUDIT_REFRESH_BUTTON_CLASS'); expect(auditLogPresentationSource).toContain('export const AUDIT_VERIFY_ALL_BUTTON_CLASS'); expect(auditLogPresentationSource).toContain('export const AUDIT_VERIFY_ROW_BUTTON_CLASS'); - expect(diagnosticsPanelSource).toContain('getStatusIndicatorBadgeToneClasses('); - expect(diagnosticsPanelSource).toContain('DIAGNOSTICS_EMPTY_PBS_MESSAGE'); + expect(diagnosticsPanelSource).toContain('@/components/Settings/DiagnosticsResultsPanel'); + expect(diagnosticsPanelSource).toContain('@/components/Settings/useDiagnosticsPanelState'); + expect(diagnosticsPanelSource).toContain('formatUptime'); expect(diagnosticsPanelSource).not.toContain('No PBS configured'); + expect(diagnosticsPanelSource).not.toContain("apiFetchJSON('/api/diagnostics')"); + expect(diagnosticsPanelSource).not.toContain('URL.createObjectURL'); expect(diagnosticsPanelSource).not.toContain( "'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300'", ); expect(diagnosticsPanelSource).not.toContain( "'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'", ); + expect(diagnosticsResultsPanelSource).toContain('getStatusIndicatorBadgeToneClasses('); + expect(diagnosticsResultsPanelSource).toContain('DIAGNOSTICS_EMPTY_PBS_MESSAGE'); + expect(diagnosticsStateSource).toContain('export const useDiagnosticsPanelState ='); + expect(diagnosticsStateSource).toContain("apiFetchJSON('/api/diagnostics')"); + expect(diagnosticsStateSource).toContain('URL.createObjectURL'); + expect(diagnosticsModelSource).toContain('export function sanitizeDiagnosticsData'); expect(diagnosticsPresentationSource).toContain('export const DIAGNOSTICS_EMPTY_PBS_MESSAGE'); expect(updatesSettingsPanelSource).toContain('getUpdateBuildBadges'); expect(updatesSettingsPanelSource).toContain('getUpdateAvailabilityHeading');