refactor(settings): Extract DiagnosticsPanel and improve AI Settings UX

- Created standalone DiagnosticsPanel component with modern visual design
  - Gradient header with system health indicator
  - Card-based layout for System Runtime, PVE Nodes, PBS, Discovery
  - Status badges and improved visual hierarchy
  - Export functionality (full and GitHub-sanitized)

- Reduced Settings.tsx from 8,035 to 5,956 lines (26% reduction)
  - Removed ~2,079 lines of inline diagnostics code
  - Improved maintainability by encapsulating diagnostics logic

- Improved AI Settings UX:
  - Cost Controls: Gradient design, RECOMMENDED badge, $ prefix
  - Shows daily/weekly budget equivalents when value is entered
  - Auto-Fix: Streamlined acknowledgment with clear button instead of checkbox
  - Better visual hierarchy throughout
This commit is contained in:
rcourtman 2025-12-13 07:51:49 +00:00
parent 0074e49792
commit 6a3c143740
3 changed files with 1316 additions and 2671 deletions

View file

@ -1037,29 +1037,45 @@ export const AISettings: Component = () => {
/>
</div>
{/* Auto-Fix Warning & Acknowledgement */}
{/* Auto-Fix Warning & Acknowledgement - Simplified inline flow */}
<Show when={!form.patrolAutoFix}>
<div class="mt-3 p-3 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg">
<div class="flex gap-2">
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="text-xs text-amber-800 dark:text-amber-200">
<p class="font-semibold mb-1">Before enabling Auto-Fix Mode:</p>
<ul class="list-disc pl-4 space-y-0.5 mb-2">
<li>AI will execute commands <strong>without asking for approval</strong></li>
<li>Actions may be <strong>irreversible</strong> (e.g., restarting services, clearing caches)</li>
<li>Recommended to test in non-production environments first</li>
</ul>
<label class="flex items-center gap-2 cursor-pointer mt-2 pt-2 border-t border-amber-200 dark:border-amber-700">
<input
type="checkbox"
checked={autoFixAcknowledged()}
onChange={(e) => setAutoFixAcknowledged(e.currentTarget.checked)}
class="w-4 h-4 rounded border-amber-400 text-amber-600 focus:ring-amber-500"
/>
<span class="font-medium">I understand the risks and want to enable Auto-Fix</span>
</label>
<div class="mt-3 p-4 bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-900/30 dark:to-orange-900/30 border border-amber-200 dark:border-amber-800 rounded-xl">
<div class="flex items-start gap-3">
<div class="p-2 bg-amber-100 dark:bg-amber-800 rounded-lg flex-shrink-0">
<svg class="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-amber-800 dark:text-amber-200 mb-2">Enable Auto-Fix Mode?</p>
<div class="text-xs text-amber-700 dark:text-amber-300 space-y-1 mb-3">
<div class="flex items-center gap-2">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="10" r="3" /></svg>
<span>AI executes remediation commands <strong>without approval</strong></span>
</div>
<div class="flex items-center gap-2">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="10" r="3" /></svg>
<span>Actions may be <strong>irreversible</strong> (restarts, cache clears, etc.)</span>
</div>
<div class="flex items-center gap-2">
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><circle cx="10" cy="10" r="3" /></svg>
<span>Test in staging/dev environments first</span>
</div>
</div>
<button
type="button"
onClick={() => {
setAutoFixAcknowledged(true);
setForm('patrolAutoFix', true);
}}
disabled={saving()}
class="inline-flex items-center gap-2 px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
I understand, enable Auto-Fix
</button>
</div>
</div>
</div>
@ -1122,36 +1138,67 @@ export const AISettings: Component = () => {
</div>
</div>
{/* AI Cost Controls */}
<div class={`${formField} p-4 rounded-lg border bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800`}>
<div class="mb-2">
<label class={`${labelClass()} flex items-center gap-2`}>
AI Cost Controls
</label>
<p class="text-xs text-emerald-700 dark:text-emerald-300 mt-1">
This budget is a cross-provider estimate for Pulse usage. Provider dashboards remain the source of truth for billing.
</p>
{/* AI Cost Controls - Prominent positioning */}
<div class={`${formField} p-4 rounded-lg border bg-gradient-to-r from-emerald-50 to-teal-50 dark:from-emerald-900/20 dark:to-teal-900/20 border-emerald-200 dark:border-emerald-800`}>
<div class="flex items-center gap-3 mb-3">
<div class="p-2 bg-emerald-100 dark:bg-emerald-800 rounded-lg">
<svg class="w-5 h-5 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="flex-1">
<label class={`${labelClass()} flex items-center gap-2`}>
AI Cost Controls
<span class="px-1.5 py-0.5 text-[10px] font-semibold bg-emerald-100 dark:bg-emerald-800 text-emerald-700 dark:text-emerald-300 rounded">RECOMMENDED</span>
</label>
<p class="text-xs text-emerald-700 dark:text-emerald-300 mt-0.5">
Set a budget alert for cross-provider cost tracking
</p>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
Budget alert (USD per 30 days)
</label>
<input
type="number"
class={controlClass()}
value={form.costBudgetUSD30d}
onInput={(e) => setForm('costBudgetUSD30d', e.currentTarget.value)}
min={0}
step={1}
placeholder="0 (disabled)"
disabled={saving()}
style={{ width: '180px' }}
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Set to 0 to disable. Cost dashboard pro-rates for shorter ranges.
</p>
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex-shrink-0">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
30-day budget (USD)
</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 text-sm">$</span>
<input
type="number"
class="w-32 pl-7 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
value={form.costBudgetUSD30d}
onInput={(e) => setForm('costBudgetUSD30d', e.currentTarget.value)}
min={0}
step={1}
placeholder="0"
disabled={saving()}
/>
</div>
</div>
<div class="flex-1 min-w-0">
<Show when={parseFloat(form.costBudgetUSD30d) > 0}>
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div class="flex items-center justify-between">
<span>Daily equivalent</span>
<span class="font-medium text-gray-900 dark:text-gray-100">${(parseFloat(form.costBudgetUSD30d) / 30).toFixed(2)}/day</span>
</div>
<div class="flex items-center justify-between">
<span>Weekly equivalent</span>
<span class="font-medium text-gray-900 dark:text-gray-100">${(parseFloat(form.costBudgetUSD30d) / 4.3).toFixed(2)}/week</span>
</div>
</div>
</Show>
<Show when={!form.costBudgetUSD30d || parseFloat(form.costBudgetUSD30d) === 0}>
<div class="p-2 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 rounded text-xs text-amber-700 dark:text-amber-300">
💡 Set a budget to get proactive alerts before overspending
</div>
</Show>
</div>
</div>
<p class="text-[10px] text-emerald-600 dark:text-emerald-400 mt-2">
This is a cross-provider estimate. Provider dashboards are the source of truth for billing.
</p>
</div>

View file

@ -0,0 +1,674 @@
import { Component, Show, For, createSignal } from 'solid-js';
import { apiFetchJSON } from '@/utils/apiClient';
import { showSuccess, showError } from '@/utils/toast';
import { Card } from '@/components/shared/Card';
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';
// 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 TemperatureProxyDiagnostic {
legacySSHDetected: boolean;
recommendProxyUpgrade: boolean;
socketFound: boolean;
socketPath?: string;
socketPermissions?: string;
socketOwner?: string;
socketGroup?: string;
proxyReachable?: boolean;
proxyVersion?: string;
notes?: string[];
}
interface APITokenDiagnostic {
enabled: boolean;
tokenCount: number;
hasEnvTokens: boolean;
hasLegacyToken: boolean;
recommendTokenSetup: boolean;
recommendTokenRotation: boolean;
legacyDockerHostCount?: number;
unusedTokenCount?: number;
notes?: string[];
}
interface DockerAgentDiagnostic {
hostsTotal: number;
hostsOnline: number;
hostsReportingVersion: number;
hostsWithTokenBinding: number;
hostsWithoutTokenBinding: number;
hostsWithoutVersion?: number;
hostsOutdatedVersion?: number;
hostsWithStaleCommand?: number;
hostsPendingUninstall?: number;
hostsNeedingAttention: number;
recommendedAgentVersion?: string;
notes?: string[];
}
interface AlertsDiagnostic {
legacyThresholdsDetected: boolean;
legacyThresholdSources?: string[];
legacyScheduleSettings?: string[];
missingCooldown: boolean;
missingGroupingWindow: boolean;
notes?: string[];
}
interface DiagnosticsData {
version: string;
runtime: string;
uptime: number;
nodes: DiagnosticsNode[];
pbs: DiagnosticsPBS[];
system: SystemDiagnostic;
temperatureProxy?: TemperatureProxyDiagnostic | null;
apiTokens?: APITokenDiagnostic | null;
dockerAgents?: DockerAgentDiagnostic | null;
alerts?: AlertsDiagnostic | 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`;
}
function formatRelativeTime(timestamp?: string | number): string {
if (!timestamp) return 'Never';
const date = typeof timestamp === 'string' ? new Date(timestamp) : new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return 'just now';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
return `${Math.floor(diffSec / 86400)}d ago`;
}
// DiagnosticCard - a mini card component for individual diagnostic items
const DiagnosticCard: Component<{
title: string;
icon: Component<{ class?: string }>;
status?: 'success' | 'warning' | 'error' | 'info';
children: any;
}> = (props) => {
const statusColors = {
success: 'border-green-200 dark:border-green-800 bg-green-50/50 dark:bg-green-900/20',
warning: 'border-amber-200 dark:border-amber-800 bg-amber-50/50 dark:bg-amber-900/20',
error: 'border-red-200 dark:border-red-800 bg-red-50/50 dark:bg-red-900/20',
info: 'border-blue-200 dark:border-blue-800 bg-blue-50/50 dark:bg-blue-900/20',
};
const iconColors = {
success: 'text-green-600 dark:text-green-400',
warning: 'text-amber-600 dark:text-amber-400',
error: 'text-red-600 dark:text-red-400',
info: 'text-blue-600 dark:text-blue-400',
};
return (
<div class={`rounded-xl border p-4 transition-all hover:shadow-md ${statusColors[props.status || 'info']}`}>
<div class="flex items-center gap-3 mb-3">
<div class={`p-2 rounded-lg bg-white/60 dark:bg-gray-800/60 ${iconColors[props.status || 'info']}`}>
<props.icon class="w-4 h-4" />
</div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">{props.title}</h4>
</div>
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-1.5">
{props.children}
</div>
</div>
);
};
// StatusBadge component
const StatusBadge: Component<{
status: 'online' | 'offline' | 'warning' | 'unknown';
label?: string;
}> = (props) => {
const colors = {
online: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
offline: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
warning: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
unknown: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
};
return (
<span class={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wide ${colors[props.status]}`}>
<span class={`w-1.5 h-1.5 rounded-full ${props.status === 'online' ? 'bg-green-500' : props.status === 'offline' ? 'bg-red-500' : props.status === 'warning' ? 'bg-amber-500' : 'bg-gray-400'}`} />
{props.label || props.status}
</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-gray-100 dark:border-gray-700/50 last:border-0">
<span class="text-gray-500 dark:text-gray-400">{props.label}</span>
<span class={`text-gray-900 dark:text-gray-100 ${props.mono ? 'font-mono text-[11px]' : 'font-medium'}`}>
{props.value ?? 'Unknown'}
</span>
</div>
);
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);
}
};
const exportDiagnostics = async (sanitize: boolean) => {
setExportLoading(true);
try {
const data = diagnosticsData();
if (!data) {
showError('Run diagnostics first');
return;
}
const blob = new Blob([JSON.stringify(data, 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);
}
};
// Calculate overall system health
const systemHealth = () => {
const data = diagnosticsData();
if (!data) return 'unknown';
const issues: string[] = [];
// Check node connectivity
const disconnectedNodes = data.nodes?.filter(n => !n.connected).length || 0;
if (disconnectedNodes > 0) issues.push('nodes');
// Check PBS connectivity
const disconnectedPbs = data.pbs?.filter(p => !p.connected).length || 0;
if (disconnectedPbs > 0) issues.push('pbs');
// Check for errors
if (data.errors?.length > 0) issues.push('errors');
// Check temperature proxy
if (data.temperatureProxy?.legacySSHDetected) issues.push('temperature');
// Check alerts config
if (data.alerts?.legacyThresholdsDetected || data.alerts?.missingCooldown) issues.push('alerts');
if (issues.length === 0) return 'healthy';
if (issues.length <= 2) return 'warning';
return 'critical';
};
const healthColor = () => {
const health = systemHealth();
if (health === 'healthy') return 'from-green-500 to-emerald-600';
if (health === 'warning') return 'from-amber-500 to-orange-600';
if (health === 'critical') return 'from-red-500 to-rose-600';
return 'from-gray-400 to-gray-500';
};
return (
<div class="space-y-6">
{/* Header Card */}
<Card
padding="none"
class="overflow-hidden border border-gray-200 dark:border-gray-700"
border={false}
>
<div class={`bg-gradient-to-r ${healthColor()} px-6 py-5`}>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="p-3 bg-white/20 rounded-xl backdrop-blur-sm">
<Activity class="w-6 h-6 text-white" />
</div>
<div>
<h2 class="text-lg font-bold text-white">System Diagnostics</h2>
<p class="text-sm text-white/80">
Connection health, configuration status, and troubleshooting tools
</p>
</div>
</div>
<div class="flex items-center gap-3">
<Show when={diagnosticsData()}>
<div class="text-right text-white/80 text-xs">
<div>Version {diagnosticsData()?.version}</div>
<div>Uptime: {formatUptime(diagnosticsData()?.uptime || 0)}</div>
</div>
</Show>
<button
type="button"
onClick={runDiagnostics}
disabled={loading()}
class="flex items-center gap-2 px-4 py-2.5 bg-white/20 hover:bg-white/30 text-white rounded-lg font-medium text-sm transition-all disabled:opacity-50 backdrop-blur-sm"
>
<RefreshCw class={`w-4 h-4 ${loading() ? 'animate-spin' : ''}`} />
{loading() ? 'Running...' : 'Run Diagnostics'}
</button>
</div>
</div>
</div>
{/* Quick Actions */}
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<p class="text-xs text-gray-500 dark:text-gray-400">
Test all connections and inspect runtime configuration
</p>
<Show when={diagnosticsData()}>
<div class="flex items-center gap-2">
<button
type="button"
onClick={() => exportDiagnostics(false)}
disabled={exportLoading()}
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
>
<Download class="w-3.5 h-3.5" />
Export Full
</button>
<button
type="button"
onClick={() => exportDiagnostics(true)}
disabled={exportLoading()}
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg hover:bg-green-100 dark:hover:bg-green-900/50 transition-colors"
>
<Download class="w-3.5 h-3.5" />
Export for GitHub
</button>
</div>
</Show>
</div>
</Card>
{/* 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-gray-300 dark:text-gray-600 mb-4" />
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
No diagnostics data yet
</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Click "Run Diagnostics" above to test connections and inspect system status
</p>
<button
type="button"
onClick={runDiagnostics}
disabled={loading()}
class="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg 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-gray-900 dark:text-gray-100">
{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-gray-100 dark:border-gray-700/50 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-gray-400 dark:text-gray-500">
No PBS configured
</div>
}>
<div class="flex items-center justify-between mb-2">
<span>Total Instances</span>
<span class="font-bold text-lg text-gray-900 dark:text-gray-100">
{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-gray-100 dark:border-gray-700/50 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)}
/>
<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">
{/* Temperature Proxy */}
<Show when={diagnosticsData()?.temperatureProxy}>
<Card padding="md">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<div class="p-2 rounded-lg bg-orange-100 dark:bg-orange-900/30">
<Activity class="w-4 h-4 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Temperature Proxy</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">Hardware temperature monitoring</p>
</div>
<div class="ml-auto">
<StatusBadge
status={diagnosticsData()?.temperatureProxy?.socketFound ? 'online' : 'warning'}
label={diagnosticsData()?.temperatureProxy?.socketFound ? 'Connected' : 'Not Found'}
/>
</div>
</div>
<div class="grid grid-cols-2 gap-3 text-xs">
<div class="flex items-center gap-2">
{diagnosticsData()?.temperatureProxy?.socketFound ?
<CheckCircle class="w-4 h-4 text-green-500" /> :
<XCircle class="w-4 h-4 text-red-500" />
}
<span>Proxy Socket</span>
</div>
<div class="flex items-center gap-2">
{diagnosticsData()?.temperatureProxy?.proxyReachable ?
<CheckCircle class="w-4 h-4 text-green-500" /> :
<AlertTriangle class="w-4 h-4 text-amber-500" />
}
<span>Daemon</span>
</div>
</div>
<Show when={diagnosticsData()?.temperatureProxy?.proxyVersion}>
<div class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700/50 text-xs text-gray-500 dark:text-gray-400">
Version: {diagnosticsData()?.temperatureProxy?.proxyVersion}
</div>
</Show>
<Show when={diagnosticsData()?.temperatureProxy?.legacySSHDetected}>
<div class="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded text-xs text-amber-700 dark:text-amber-300">
Legacy SSH temperature collection detected - consider upgrading
</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-gray-200 dark:border-gray-700">
<div class="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
<Shield class="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">API Tokens</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">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} />
<MetricRow label="Legacy Docker Hosts" value={diagnosticsData()?.apiTokens?.legacyDockerHostCount ?? 0} />
</div>
<Show when={diagnosticsData()?.apiTokens?.hasLegacyToken}>
<div class="mt-3 p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded text-xs text-amber-700 dark:text-amber-300">
Legacy token detected - consider migrating to scoped tokens
</div>
</Show>
</Card>
</Show>
{/* Docker Agents */}
<Show when={diagnosticsData()?.dockerAgents}>
<Card padding="md">
<div class="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<div class="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/30">
<Database class="w-4 h-4 text-purple-600 dark:text-purple-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Docker Agents</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">Container monitoring</p>
</div>
<div class="ml-auto text-right">
<div class="text-lg font-bold text-gray-900 dark:text-gray-100">
{diagnosticsData()?.dockerAgents?.hostsOnline}/{diagnosticsData()?.dockerAgents?.hostsTotal}
</div>
<div class="text-[10px] text-gray-500 dark:text-gray-400">online</div>
</div>
</div>
<div class="space-y-2 text-xs">
<MetricRow label="With Token Binding" value={diagnosticsData()?.dockerAgents?.hostsWithTokenBinding} />
<MetricRow label="Need Attention" value={diagnosticsData()?.dockerAgents?.hostsNeedingAttention} />
<MetricRow label="Outdated Version" value={diagnosticsData()?.dockerAgents?.hostsOutdatedVersion ?? 0} />
</div>
<Show when={diagnosticsData()?.dockerAgents?.recommendedAgentVersion}>
<div class="mt-3 pt-2 border-t border-gray-100 dark:border-gray-700/50 text-xs text-gray-500 dark:text-gray-400">
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-gray-200 dark:border-gray-700">
<div class="p-2 rounded-lg bg-rose-100 dark:bg-rose-900/30">
<AlertTriangle class="w-4 h-4 text-rose-600 dark:text-rose-400" />
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100">Alerts Configuration</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">Alert system status</p>
</div>
</div>
<div class="flex flex-wrap gap-2">
<span class={`px-2 py-1 rounded text-xs font-medium ${diagnosticsData()?.alerts?.legacyThresholdsDetected
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}`}>
Legacy thresholds: {diagnosticsData()?.alerts?.legacyThresholdsDetected ? 'Detected' : 'Migrated'}
</span>
<span class={`px-2 py-1 rounded text-xs font-medium ${diagnosticsData()?.alerts?.missingCooldown
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}`}>
Cooldown: {diagnosticsData()?.alerts?.missingCooldown ? 'Missing' : 'Configured'}
</span>
<span class={`px-2 py-1 rounded text-xs font-medium ${diagnosticsData()?.alerts?.missingGroupingWindow
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
}`}>
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-gray-100 dark:border-gray-700/50 space-y-1 text-xs text-gray-500 dark:text-gray-400 list-disc pl-4">
<For each={diagnosticsData()?.alerts?.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/50 dark:bg-red-900/20">
<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/40 rounded">
<span class="text-red-500"></span>
<span>{error}</span>
</li>
)}
</For>
</ul>
</Card>
</Show>
</Show>
</div>
);
};
export default DiagnosticsPanel;

File diff suppressed because it is too large Load diff