Extract diagnostics settings owners

This commit is contained in:
rcourtman
2026-03-20 21:35:42 +00:00
parent 63d6608846
commit e2275133a5
8 changed files with 908 additions and 821 deletions
@@ -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
@@ -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,
@@ -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<string, unknown>;
lastPoll?: string;
clusterInfo?: Record<string, unknown>;
}
interface DiagnosticsPBS {
id: string;
name: string;
host: string;
connected: boolean;
error?: string;
details?: Record<string, unknown>;
}
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 (
<div class={`rounded-md border p-4 transition-all hover:shadow-sm ${tone().panelClass}`}>
<div class="flex items-center gap-3 mb-3">
<div class={`p-2 rounded-md bg-surface ${tone().iconClass}`}>
<props.icon class="w-4 h-4" />
</div>
<h4 class="text-sm font-semibold text-base-content">{props.title}</h4>
</div>
<div class="text-xs text-muted space-y-1.5">{props.children}</div>
</div>
);
};
// StatusBadge component
const StatusBadge: Component<{
status: 'online' | 'offline' | 'warning' | 'unknown';
label?: string;
}> = (props) => {
const indicator = () => getSimpleStatusIndicator(props.status);
return (
<span
class={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wide ${getStatusIndicatorBadgeToneClasses(indicator().variant)}`}
>
<StatusDot
variant={indicator().variant}
size="xs"
ariaHidden={true}
class="translate-y-[0.5px]"
/>
{props.label || indicator().label}
</span>
);
};
// MetricRow component for displaying key-value pairs
const MetricRow: Component<{
label: string;
value: string | number | undefined;
mono?: boolean;
}> = (props) => (
<div class="flex items-center justify-between py-1.5 border-b border-border-subtle last:border-0">
<span class="text-muted">{props.label}</span>
<span class={`text-base-content ${props.mono ? 'font-mono text-[11px]' : 'font-medium'}`}>
{props.value ?? 'Unknown'}
</span>
</div>
);
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<DiagnosticsData | null>(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<string, unknown>) => ({
...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<string, unknown>, 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<string, unknown>) => ({
...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<string, unknown>, 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<string, unknown>, i: number) => ({
...s,
instance: `node-${i + 1}`,
}));
}
if (Array.isArray(raw2.guestSnapshots)) {
raw2.guestSnapshots = raw2.guestSnapshots.map((s: Record<string, unknown>, i: number) => ({
...s,
instance: `node-${i + 1}`,
}));
}
if (Array.isArray(raw2.memorySources)) {
raw2.memorySources = raw2.memorySources.map((s: Record<string, unknown>, 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 (
<div class="space-y-6">
{/* Header Card */}
<OperationsPanel
title="System Diagnostics"
description="Connection health, configuration status, and troubleshooting tools"
@@ -403,7 +38,6 @@ export const DiagnosticsPanel: Component = () => {
</div>
}
>
{/* Quick Actions */}
<div class="px-4 sm:px-6 py-3 sm:py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<p class="text-xs text-muted">Test all connections and inspect runtime configuration</p>
<Show when={diagnosticsData()}>
@@ -430,428 +64,11 @@ export const DiagnosticsPanel: Component = () => {
</Show>
</div>
</OperationsPanel>
{/* Diagnostics Content */}
<Show
when={diagnosticsData()}
fallback={
<Card padding="lg" class="text-center">
<div class="py-12">
<Activity class="w-12 h-12 mx-auto text-muted mb-4" />
<h3 class="text-lg font-medium text-base-content mb-2">No diagnostics data yet</h3>
<p class="text-sm text-muted mb-6">
Click "Run Diagnostics" above to test connections and inspect system status
</p>
<button
type="button"
onClick={runDiagnostics}
disabled={loading()}
class="inline-flex min-h-10 sm:min-h-9 items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium text-sm transition-colors disabled:opacity-50"
>
<RefreshCw class={`w-4 h-4 ${loading() ? 'animate-spin' : ''}`} />
Run Diagnostics
</button>
</div>
</Card>
}
>
{/* Summary Grid */}
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{/* System Info Card */}
<DiagnosticCard title="System Runtime" icon={Cpu} status="info">
<MetricRow
label="OS / Arch"
value={`${diagnosticsData()?.system?.os || '?'} / ${diagnosticsData()?.system?.arch || '?'}`}
/>
<MetricRow label="Go Runtime" value={diagnosticsData()?.system?.goVersion} mono />
<MetricRow label="CPU Cores" value={diagnosticsData()?.system?.numCPU} />
<MetricRow label="Goroutines" value={diagnosticsData()?.system?.numGoroutine} />
<MetricRow label="Memory" value={`${diagnosticsData()?.system?.memoryMB || 0} MB`} />
</DiagnosticCard>
{/* Nodes Status Card */}
<DiagnosticCard
title="PVE Nodes"
icon={Server}
status={diagnosticsData()?.nodes?.every((n) => n.connected) ? 'success' : 'warning'}
>
<div class="flex items-center justify-between mb-2">
<span>Total Nodes</span>
<span class="font-bold text-lg text-base-content">
{diagnosticsData()?.nodes?.length || 0}
</span>
</div>
<div class="space-y-1">
<For each={diagnosticsData()?.nodes || []}>
{(node) => (
<div class="flex items-center justify-between py-1 border-b border-border-subtle last:border-0">
<span class="truncate max-w-[120px]" title={node.host}>
{node.name}
</span>
<StatusBadge status={node.connected ? 'online' : 'offline'} />
</div>
)}
</For>
</div>
</DiagnosticCard>
{/* PBS Status Card */}
<DiagnosticCard
title="PBS Instances"
icon={HardDrive}
status={
diagnosticsData()?.pbs?.every((p) => p.connected)
? 'success'
: diagnosticsData()?.pbs?.length
? 'warning'
: 'info'
}
>
<Show
when={(diagnosticsData()?.pbs?.length || 0) > 0}
fallback={
<div class="text-center py-4 text-muted">{DIAGNOSTICS_EMPTY_PBS_MESSAGE}</div>
}
>
<div class="flex items-center justify-between mb-2">
<span>Total Instances</span>
<span class="font-bold text-lg text-base-content">
{diagnosticsData()?.pbs?.length || 0}
</span>
</div>
<div class="space-y-1">
<For each={diagnosticsData()?.pbs || []}>
{(pbs) => (
<div class="flex items-center justify-between py-1 border-b border-border-subtle last:border-0">
<span class="truncate max-w-[120px]" title={pbs.host}>
{pbs.name}
</span>
<StatusBadge status={pbs.connected ? 'online' : 'offline'} />
</div>
)}
</For>
</div>
</Show>
</DiagnosticCard>
{/* Discovery Status Card */}
<DiagnosticCard
title="Network Discovery"
icon={Network}
status={diagnosticsData()?.discovery?.enabled ? 'success' : 'info'}
>
<MetricRow
label="Status"
value={diagnosticsData()?.discovery?.enabled ? 'Enabled' : 'Disabled'}
/>
<MetricRow
label="Subnet"
value={diagnosticsData()?.discovery?.configuredSubnet || 'auto'}
mono
/>
<MetricRow
label="Scan Interval"
value={diagnosticsData()?.discovery?.scanInterval || 'default'}
/>
<MetricRow
label="Last Scan"
value={formatRelativeTime(diagnosticsData()?.discovery?.lastScanStartedAt, {
compact: true,
emptyText: 'Never',
})}
/>
<MetricRow
label="Servers Found"
value={diagnosticsData()?.discovery?.lastResultServers ?? 0}
/>
</DiagnosticCard>
</div>
{/* Detailed Status Cards */}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Metrics Store */}
<Show when={diagnosticsData()?.metricsStore}>
<Card padding="md">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-border">
<div class="p-2 rounded-md bg-blue-100 dark:bg-blue-900">
<Database class="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-base-content">Metrics Store</h4>
<p class="text-xs text-muted">History persistence health</p>
</div>
<div class="ml-auto">
<StatusBadge
status={
diagnosticsData()?.metricsStore?.status === 'healthy'
? 'online'
: diagnosticsData()?.metricsStore?.status === 'buffering'
? 'warning'
: diagnosticsData()?.metricsStore?.status === 'empty'
? 'warning'
: 'offline'
}
label={diagnosticsData()?.metricsStore?.status || 'unknown'}
/>
</div>
</div>
<div class="space-y-2 text-xs">
<MetricRow
label="Enabled"
value={diagnosticsData()?.metricsStore?.enabled ? 'Yes' : 'No'}
/>
<MetricRow
label="DB Size"
value={`${Math.round((diagnosticsData()?.metricsStore?.dbSize ?? 0) / (1024 * 1024))} MB`}
/>
<MetricRow
label="Total Points"
value={diagnosticsData()?.metricsStore?.totalPoints ?? 0}
/>
<MetricRow
label="Raw Points"
value={diagnosticsData()?.metricsStore?.rawCount ?? 0}
/>
<MetricRow
label="Minute Points"
value={diagnosticsData()?.metricsStore?.minuteCount ?? 0}
/>
<MetricRow
label="Hourly Points"
value={diagnosticsData()?.metricsStore?.hourlyCount ?? 0}
/>
<MetricRow
label="Daily Points"
value={diagnosticsData()?.metricsStore?.dailyCount ?? 0}
/>
<MetricRow
label="Buffer Size"
value={diagnosticsData()?.metricsStore?.bufferSize ?? 0}
/>
</div>
<Show when={(diagnosticsData()?.metricsStore?.notes?.length || 0) > 0}>
<div class="mt-3 rounded-md bg-amber-50 dark:bg-amber-900 border border-amber-200 dark:border-amber-800 p-2">
<div class="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-300">
<AlertTriangle class="w-4 h-4 flex-shrink-0 mt-0.5" />
<div class="space-y-1">
<For each={diagnosticsData()?.metricsStore?.notes || []}>
{(note) => <div>{note}</div>}
</For>
</div>
</div>
</div>
</Show>
<Show when={diagnosticsData()?.metricsStore?.error}>
<div class="mt-3 rounded-md bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 p-2 text-xs text-red-700 dark:text-red-300">
{diagnosticsData()?.metricsStore?.error}
</div>
</Show>
</Card>
</Show>
{/* API Tokens */}
<Show when={diagnosticsData()?.apiTokens}>
<Card padding="md">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-border">
<div class="p-2 rounded-md bg-blue-100 dark:bg-blue-900">
<Shield class="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-base-content">API Tokens</h4>
<p class="text-xs text-muted">Authentication status</p>
</div>
<div class="ml-auto">
<StatusBadge
status={diagnosticsData()?.apiTokens?.enabled ? 'online' : 'warning'}
label={diagnosticsData()?.apiTokens?.enabled ? 'Enabled' : 'Disabled'}
/>
</div>
</div>
<div class="space-y-2 text-xs">
<MetricRow
label="Configured Tokens"
value={diagnosticsData()?.apiTokens?.tokenCount}
/>
<MetricRow
label="Unused Tokens"
value={diagnosticsData()?.apiTokens?.unusedTokenCount ?? 0}
/>
</div>
</Card>
</Show>
{/* Container Runtime Agents */}
<Show when={diagnosticsData()?.dockerAgents}>
<Card padding="md">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-border">
<div class="p-2 rounded-md bg-blue-100 dark:bg-blue-900">
<Database class="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-base-content">Container Runtime Agents</h4>
<p class="text-xs text-muted">Agent-backed container runtime monitoring</p>
</div>
<div class="ml-auto text-right">
<div class="text-lg font-bold text-base-content">
{diagnosticsData()?.dockerAgents?.agentsOnline}/
{diagnosticsData()?.dockerAgents?.agentsTotal}
</div>
<div class="text-[10px] text-muted">online</div>
</div>
</div>
<div class="space-y-2 text-xs">
<MetricRow
label="With Token Binding"
value={diagnosticsData()?.dockerAgents?.agentsWithTokenBinding}
/>
<MetricRow
label="Need Attention"
value={diagnosticsData()?.dockerAgents?.agentsNeedingAttention}
/>
<MetricRow
label="Outdated Version"
value={diagnosticsData()?.dockerAgents?.agentsOutdatedVersion ?? 0}
/>
</div>
<Show when={diagnosticsData()?.dockerAgents?.recommendedAgentVersion}>
<div class="mt-3 pt-2 border-t border-border-subtle text-xs text-muted">
Recommended version: {diagnosticsData()?.dockerAgents?.recommendedAgentVersion}
</div>
</Show>
</Card>
</Show>
{/* Alerts Configuration */}
<Show when={diagnosticsData()?.alerts}>
<Card padding="md">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-border">
<div class="p-2 rounded-md bg-rose-100 dark:bg-rose-900">
<AlertTriangle class="w-4 h-4 text-rose-600 dark:text-rose-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-base-content">Alerts Configuration</h4>
<p class="text-xs text-muted">Alert system status</p>
</div>
</div>
<div class="flex flex-wrap gap-2">
<span
class={`px-2 py-1 rounded text-xs font-medium ${
getStatusIndicatorBadgeToneClasses(
diagnosticsData()?.alerts?.missingCooldown ? 'warning' : 'success',
)
}`}
>
Cooldown: {diagnosticsData()?.alerts?.missingCooldown ? 'Missing' : 'Configured'}
</span>
<span
class={`px-2 py-1 rounded text-xs font-medium ${
getStatusIndicatorBadgeToneClasses(
diagnosticsData()?.alerts?.missingGroupingWindow ? 'warning' : 'success',
)
}`}
>
Grouping:{' '}
{diagnosticsData()?.alerts?.missingGroupingWindow ? 'Disabled' : 'Enabled'}
</span>
</div>
<Show when={(diagnosticsData()?.alerts?.notes?.length || 0) > 0}>
<ul class="mt-3 pt-2 border-t border-border-subtle space-y-1 text-xs text-muted list-disc pl-4">
<For each={diagnosticsData()?.alerts?.notes || []}>
{(note) => <li>{note}</li>}
</For>
</ul>
</Show>
</Card>
</Show>
{/* AI Chat Status */}
<Show when={diagnosticsData()?.aiChat}>
<Card padding="md">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-border">
<div class="p-2 rounded-md bg-blue-100 dark:bg-blue-900">
<Sparkles class="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-base-content">Pulse Assistant</h4>
<p class="text-xs text-muted">Pulse Assistant Service</p>
</div>
<div class="ml-auto">
<StatusBadge
status={
diagnosticsData()?.aiChat?.running
? 'online'
: diagnosticsData()?.aiChat?.enabled
? 'offline'
: 'unknown'
}
label={
diagnosticsData()?.aiChat?.running
? 'Running'
: diagnosticsData()?.aiChat?.enabled
? 'Stopped'
: 'Disabled'
}
/>
</div>
</div>
<div class="space-y-2 text-xs">
<MetricRow label="Model" value={diagnosticsData()?.aiChat?.model} />
<MetricRow label="Port" value={diagnosticsData()?.aiChat?.port} mono />
<MetricRow
label="Status"
value={diagnosticsData()?.aiChat?.healthy ? 'Healthy' : 'Unhealthy'}
/>
</div>
<div class="mt-3 pt-3 border-t border-border-subtle flex items-center justify-between text-xs">
<span class="text-muted">MCP Connection</span>
<div class="flex items-center gap-1.5">
{diagnosticsData()?.aiChat?.mcpConnected ? (
<CheckCircle class="w-3.5 h-3.5 text-emerald-400" />
) : (
<XCircle class="w-3.5 h-3.5 text-rose-400" />
)}
<span
class={
diagnosticsData()?.aiChat?.mcpConnected
? 'text-green-700 dark:text-green-300'
: 'text-slate-500'
}
>
{diagnosticsData()?.aiChat?.mcpConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
<Show when={(diagnosticsData()?.aiChat?.notes?.length || 0) > 0}>
<ul class="mt-3 bg-amber-50 dark:bg-amber-900 p-2 rounded text-xs text-amber-700 dark:text-amber-400 list-disc pl-4">
<For each={diagnosticsData()?.aiChat?.notes || []}>
{(note) => <li>{note}</li>}
</For>
</ul>
</Show>
</Card>
</Show>
</div>
{/* Errors Section */}
<Show when={(diagnosticsData()?.errors?.length || 0) > 0}>
<Card padding="md" class="border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900">
<div class="flex items-center gap-3 mb-3">
<XCircle class="w-5 h-5 text-red-600 dark:text-red-400" />
<h4 class="text-sm font-semibold text-red-900 dark:text-red-100">Errors Detected</h4>
</div>
<ul class="space-y-2 text-xs text-red-700 dark:text-red-300">
<For each={diagnosticsData()?.errors || []}>
{(error) => (
<li class="flex items-start gap-2 p-2 bg-red-100 dark:bg-red-900 rounded">
<span class="text-rose-400">•</span>
<span>{error}</span>
</li>
)}
</For>
</ul>
</Card>
</Show>
</Show>
<DiagnosticsResultsPanel
diagnosticsData={diagnosticsData()}
loading={loading()}
onRunDiagnostics={runDiagnostics}
/>
</div>
);
};
@@ -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 (
<div class={`rounded-md border p-4 transition-all hover:shadow-sm ${tone().panelClass}`}>
<div class="mb-3 flex items-center gap-3">
<div class={`rounded-md bg-surface p-2 ${tone().iconClass}`}>
<props.icon class="h-4 w-4" />
</div>
<h4 class="text-sm font-semibold text-base-content">{props.title}</h4>
</div>
<div class="space-y-1.5 text-xs text-muted">{props.children}</div>
</div>
);
};
const StatusBadge: Component<{
label?: string;
status: 'online' | 'offline' | 'warning' | 'unknown';
}> = (props) => {
const indicator = () => getSimpleStatusIndicator(props.status);
return (
<span
class={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${getStatusIndicatorBadgeToneClasses(indicator().variant)}`}
>
<StatusDot
variant={indicator().variant}
size="xs"
ariaHidden={true}
class="translate-y-[0.5px]"
/>
{props.label || indicator().label}
</span>
);
};
const MetricRow: Component<{
label: string;
mono?: boolean;
value: string | number | undefined;
}> = (props) => (
<div class="flex items-center justify-between border-b border-border-subtle py-1.5 last:border-0">
<span class="text-muted">{props.label}</span>
<span class={`text-base-content ${props.mono ? 'font-mono text-[11px]' : 'font-medium'}`}>
{props.value ?? 'Unknown'}
</span>
</div>
);
interface DiagnosticsResultsPanelProps {
diagnosticsData: DiagnosticsData | null;
loading: boolean;
onRunDiagnostics: () => void;
}
export const DiagnosticsResultsPanel: Component<DiagnosticsResultsPanelProps> = (props) => {
return (
<Show
when={props.diagnosticsData}
fallback={
<Card padding="lg" class="text-center">
<div class="py-12">
<Activity class="mx-auto mb-4 h-12 w-12 text-muted" />
<h3 class="mb-2 text-lg font-medium text-base-content">No diagnostics data yet</h3>
<p class="mb-6 text-sm text-muted">
Click "Run Diagnostics" above to test connections and inspect system status
</p>
<button
type="button"
onClick={props.onRunDiagnostics}
disabled={props.loading}
class="inline-flex min-h-10 items-center gap-2 rounded-md bg-blue-600 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50 sm:min-h-9"
>
<RefreshCw class={`h-4 w-4 ${props.loading ? 'animate-spin' : ''}`} />
Run Diagnostics
</button>
</div>
</Card>
}
>
<div class="space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<DiagnosticCard title="System Runtime" icon={Cpu} status="info">
<MetricRow
label="OS / Arch"
value={`${props.diagnosticsData?.system?.os || '?'} / ${props.diagnosticsData?.system?.arch || '?'}`}
/>
<MetricRow label="Go Runtime" value={props.diagnosticsData?.system?.goVersion} mono />
<MetricRow label="CPU Cores" value={props.diagnosticsData?.system?.numCPU} />
<MetricRow label="Goroutines" value={props.diagnosticsData?.system?.numGoroutine} />
<MetricRow label="Memory" value={`${props.diagnosticsData?.system?.memoryMB || 0} MB`} />
</DiagnosticCard>
<DiagnosticCard
title="PVE Nodes"
icon={Server}
status={props.diagnosticsData?.nodes?.every((node) => node.connected) ? 'success' : 'warning'}
>
<div class="mb-2 flex items-center justify-between">
<span>Total Nodes</span>
<span class="text-lg font-bold text-base-content">
{props.diagnosticsData?.nodes?.length || 0}
</span>
</div>
<div class="space-y-1">
<For each={props.diagnosticsData?.nodes || []}>
{(node) => (
<div class="flex items-center justify-between border-b border-border-subtle py-1 last:border-0">
<span class="max-w-[120px] truncate" title={node.host}>
{node.name}
</span>
<StatusBadge status={node.connected ? 'online' : 'offline'} />
</div>
)}
</For>
</div>
</DiagnosticCard>
<DiagnosticCard
title="PBS Instances"
icon={HardDrive}
status={
props.diagnosticsData?.pbs?.every((pbs) => pbs.connected)
? 'success'
: props.diagnosticsData?.pbs?.length
? 'warning'
: 'info'
}
>
<Show
when={(props.diagnosticsData?.pbs?.length || 0) > 0}
fallback={<div class="py-4 text-center text-muted">{DIAGNOSTICS_EMPTY_PBS_MESSAGE}</div>}
>
<div class="mb-2 flex items-center justify-between">
<span>Total Instances</span>
<span class="text-lg font-bold text-base-content">
{props.diagnosticsData?.pbs?.length || 0}
</span>
</div>
<div class="space-y-1">
<For each={props.diagnosticsData?.pbs || []}>
{(pbs) => (
<div class="flex items-center justify-between border-b border-border-subtle py-1 last:border-0">
<span class="max-w-[120px] truncate" title={pbs.host}>
{pbs.name}
</span>
<StatusBadge status={pbs.connected ? 'online' : 'offline'} />
</div>
)}
</For>
</div>
</Show>
</DiagnosticCard>
<DiagnosticCard
title="Network Discovery"
icon={Network}
status={props.diagnosticsData?.discovery?.enabled ? 'success' : 'info'}
>
<MetricRow
label="Status"
value={props.diagnosticsData?.discovery?.enabled ? 'Enabled' : 'Disabled'}
/>
<MetricRow
label="Subnet"
value={props.diagnosticsData?.discovery?.configuredSubnet || 'auto'}
mono
/>
<MetricRow
label="Scan Interval"
value={props.diagnosticsData?.discovery?.scanInterval || 'default'}
/>
<MetricRow
label="Last Scan"
value={formatRelativeTime(props.diagnosticsData?.discovery?.lastScanStartedAt, {
compact: true,
emptyText: 'Never',
})}
/>
<MetricRow
label="Servers Found"
value={props.diagnosticsData?.discovery?.lastResultServers ?? 0}
/>
</DiagnosticCard>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Show when={props.diagnosticsData?.metricsStore}>
<Card padding="md">
<div class="mb-4 flex items-center gap-3 border-b border-border pb-3">
<div class="rounded-md bg-blue-100 p-2 dark:bg-blue-900">
<Database class="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-base-content">Metrics Store</h4>
<p class="text-xs text-muted">History persistence health</p>
</div>
<div class="ml-auto">
<StatusBadge
status={
props.diagnosticsData?.metricsStore?.status === 'healthy'
? 'online'
: props.diagnosticsData?.metricsStore?.status === 'buffering'
? 'warning'
: props.diagnosticsData?.metricsStore?.status === 'empty'
? 'warning'
: 'offline'
}
label={props.diagnosticsData?.metricsStore?.status || 'unknown'}
/>
</div>
</div>
<div class="space-y-2 text-xs">
<MetricRow
label="Enabled"
value={props.diagnosticsData?.metricsStore?.enabled ? 'Yes' : 'No'}
/>
<MetricRow
label="DB Size"
value={`${Math.round((props.diagnosticsData?.metricsStore?.dbSize ?? 0) / (1024 * 1024))} MB`}
/>
<MetricRow
label="Total Points"
value={props.diagnosticsData?.metricsStore?.totalPoints ?? 0}
/>
<MetricRow
label="Raw Points"
value={props.diagnosticsData?.metricsStore?.rawCount ?? 0}
/>
<MetricRow
label="Minute Points"
value={props.diagnosticsData?.metricsStore?.minuteCount ?? 0}
/>
<MetricRow
label="Hourly Points"
value={props.diagnosticsData?.metricsStore?.hourlyCount ?? 0}
/>
<MetricRow
label="Daily Points"
value={props.diagnosticsData?.metricsStore?.dailyCount ?? 0}
/>
<MetricRow
label="Buffer Size"
value={props.diagnosticsData?.metricsStore?.bufferSize ?? 0}
/>
</div>
<Show when={(props.diagnosticsData?.metricsStore?.notes?.length || 0) > 0}>
<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-2 dark:border-amber-800 dark:bg-amber-900">
<div class="flex items-start gap-2 text-xs text-amber-700 dark:text-amber-300">
<AlertTriangle class="mt-0.5 h-4 w-4 flex-shrink-0" />
<div class="space-y-1">
<For each={props.diagnosticsData?.metricsStore?.notes || []}>
{(note) => <div>{note}</div>}
</For>
</div>
</div>
</div>
</Show>
<Show when={props.diagnosticsData?.metricsStore?.error}>
<div class="mt-3 rounded-md border border-red-200 bg-red-50 p-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-900 dark:text-red-300">
{props.diagnosticsData?.metricsStore?.error}
</div>
</Show>
</Card>
</Show>
<Show when={props.diagnosticsData?.apiTokens}>
<Card padding="md">
<div class="mb-4 flex items-center gap-3 border-b border-border pb-3">
<div class="rounded-md bg-blue-100 p-2 dark:bg-blue-900">
<Shield class="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-base-content">API Tokens</h4>
<p class="text-xs text-muted">Authentication status</p>
</div>
<div class="ml-auto">
<StatusBadge
status={props.diagnosticsData?.apiTokens?.enabled ? 'online' : 'warning'}
label={props.diagnosticsData?.apiTokens?.enabled ? 'Enabled' : 'Disabled'}
/>
</div>
</div>
<div class="space-y-2 text-xs">
<MetricRow
label="Configured Tokens"
value={props.diagnosticsData?.apiTokens?.tokenCount}
/>
<MetricRow
label="Unused Tokens"
value={props.diagnosticsData?.apiTokens?.unusedTokenCount ?? 0}
/>
</div>
</Card>
</Show>
<Show when={props.diagnosticsData?.dockerAgents}>
<Card padding="md">
<div class="mb-4 flex items-center gap-3 border-b border-border pb-3">
<div class="rounded-md bg-blue-100 p-2 dark:bg-blue-900">
<Database class="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-base-content">Container Runtime Agents</h4>
<p class="text-xs text-muted">Agent-backed container runtime monitoring</p>
</div>
<div class="ml-auto text-right">
<div class="text-lg font-bold text-base-content">
{props.diagnosticsData?.dockerAgents?.agentsOnline}/
{props.diagnosticsData?.dockerAgents?.agentsTotal}
</div>
<div class="text-[10px] text-muted">online</div>
</div>
</div>
<div class="space-y-2 text-xs">
<MetricRow
label="With Token Binding"
value={props.diagnosticsData?.dockerAgents?.agentsWithTokenBinding}
/>
<MetricRow
label="Need Attention"
value={props.diagnosticsData?.dockerAgents?.agentsNeedingAttention}
/>
<MetricRow
label="Outdated Version"
value={props.diagnosticsData?.dockerAgents?.agentsOutdatedVersion ?? 0}
/>
</div>
<Show when={props.diagnosticsData?.dockerAgents?.recommendedAgentVersion}>
<div class="mt-3 border-t border-border-subtle pt-2 text-xs text-muted">
Recommended version: {props.diagnosticsData?.dockerAgents?.recommendedAgentVersion}
</div>
</Show>
</Card>
</Show>
<Show when={props.diagnosticsData?.alerts}>
<Card padding="md">
<div class="mb-4 flex items-center gap-3 border-b border-border pb-3">
<div class="rounded-md bg-rose-100 p-2 dark:bg-rose-900">
<AlertTriangle class="h-4 w-4 text-rose-600 dark:text-rose-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-base-content">Alerts Configuration</h4>
<p class="text-xs text-muted">Alert system status</p>
</div>
</div>
<div class="flex flex-wrap gap-2">
<span
class={`rounded px-2 py-1 text-xs font-medium ${
getStatusIndicatorBadgeToneClasses(
props.diagnosticsData?.alerts?.missingCooldown ? 'warning' : 'success',
)
}`}
>
Cooldown:{' '}
{props.diagnosticsData?.alerts?.missingCooldown ? 'Missing' : 'Configured'}
</span>
<span
class={`rounded px-2 py-1 text-xs font-medium ${
getStatusIndicatorBadgeToneClasses(
props.diagnosticsData?.alerts?.missingGroupingWindow ? 'warning' : 'success',
)
}`}
>
Grouping:{' '}
{props.diagnosticsData?.alerts?.missingGroupingWindow ? 'Disabled' : 'Enabled'}
</span>
</div>
<Show when={(props.diagnosticsData?.alerts?.notes?.length || 0) > 0}>
<ul class="mt-3 list-disc space-y-1 border-t border-border-subtle pt-2 pl-4 text-xs text-muted">
<For each={props.diagnosticsData?.alerts?.notes || []}>
{(note) => <li>{note}</li>}
</For>
</ul>
</Show>
</Card>
</Show>
<Show when={props.diagnosticsData?.aiChat}>
<Card padding="md">
<div class="mb-4 flex items-center gap-3 border-b border-border pb-3">
<div class="rounded-md bg-blue-100 p-2 dark:bg-blue-900">
<Sparkles class="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-base-content">Pulse Assistant</h4>
<p class="text-xs text-muted">Pulse Assistant Service</p>
</div>
<div class="ml-auto">
<StatusBadge
status={
props.diagnosticsData?.aiChat?.running
? 'online'
: props.diagnosticsData?.aiChat?.enabled
? 'offline'
: 'unknown'
}
label={
props.diagnosticsData?.aiChat?.running
? 'Running'
: props.diagnosticsData?.aiChat?.enabled
? 'Stopped'
: 'Disabled'
}
/>
</div>
</div>
<div class="space-y-2 text-xs">
<MetricRow label="Model" value={props.diagnosticsData?.aiChat?.model} />
<MetricRow label="Port" value={props.diagnosticsData?.aiChat?.port} mono />
<MetricRow
label="Status"
value={props.diagnosticsData?.aiChat?.healthy ? 'Healthy' : 'Unhealthy'}
/>
</div>
<div class="mt-3 flex items-center justify-between border-t border-border-subtle pt-3 text-xs">
<span class="text-muted">MCP Connection</span>
<div class="flex items-center gap-1.5">
<Show
when={props.diagnosticsData?.aiChat?.mcpConnected}
fallback={<XCircle class="h-3.5 w-3.5 text-rose-400" />}
>
<CheckCircle class="h-3.5 w-3.5 text-emerald-400" />
</Show>
<span
class={
props.diagnosticsData?.aiChat?.mcpConnected
? 'text-green-700 dark:text-green-300'
: 'text-slate-500'
}
>
{props.diagnosticsData?.aiChat?.mcpConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
<Show when={(props.diagnosticsData?.aiChat?.notes?.length || 0) > 0}>
<ul class="mt-3 list-disc rounded bg-amber-50 p-2 pl-4 text-xs text-amber-700 dark:bg-amber-900 dark:text-amber-400">
<For each={props.diagnosticsData?.aiChat?.notes || []}>
{(note) => <li>{note}</li>}
</For>
</ul>
</Show>
</Card>
</Show>
</div>
<Show when={(props.diagnosticsData?.errors?.length || 0) > 0}>
<Card padding="md" class="border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900">
<div class="mb-3 flex items-center gap-3">
<XCircle class="h-5 w-5 text-red-600 dark:text-red-400" />
<h4 class="text-sm font-semibold text-red-900 dark:text-red-100">Errors Detected</h4>
</div>
<ul class="space-y-2 text-xs text-red-700 dark:text-red-300">
<For each={props.diagnosticsData?.errors || []}>
{(error) => (
<li class="flex items-start gap-2 rounded bg-red-100 p-2 dark:bg-red-900">
<span class="text-rose-400">•</span>
<span>{error}</span>
</li>
)}
</For>
</ul>
</Card>
</Show>
</div>
</Show>
);
};
@@ -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');
@@ -0,0 +1,254 @@
export interface DiagnosticsNode {
id: string;
name: string;
host: string;
type: string;
authMethod: string;
connected: boolean;
error?: string;
details?: Record<string, unknown>;
lastPoll?: string;
clusterInfo?: Record<string, unknown>;
}
export interface DiagnosticsPBS {
id: string;
name: string;
host: string;
connected: boolean;
error?: string;
details?: Record<string, unknown>;
}
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<Record<string, unknown>>;
};
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<Record<string, unknown>>;
usage?: Array<Record<string, unknown>>;
};
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<Record<string, unknown>>;
};
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<Record<string, unknown>>;
guestSnapshots?: Array<Record<string, unknown>>;
memorySources?: Array<Record<string, unknown>>;
};
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`;
}
@@ -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<DiagnosticsData | null>(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,
};
};
@@ -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');