mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-11 23:12:06 +00:00
Extract diagnostics settings owners
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user