Improve auth timeout handling (related to #705)

This commit is contained in:
rcourtman 2025-11-12 21:50:53 +00:00
parent 4f950de29d
commit 3fa7356ff3
11 changed files with 87 additions and 97 deletions

View file

@ -40,6 +40,7 @@ export class NodesAPI {
nodeCount?: number;
clusterNodeCount?: number;
datastoreCount?: number;
warnings?: string[];
}> {
return apiFetchJSON(`${this.baseUrl}/test-connection`, {
method: 'POST',
@ -51,6 +52,7 @@ export class NodesAPI {
status: string;
message?: string;
latency?: number;
warnings?: string[];
}> {
return apiFetchJSON(`${this.baseUrl}/${nodeId}/test`, {
method: 'POST',

View file

@ -1,4 +1,4 @@
import { createMemo, createSignal, createEffect, on, Show, For } from 'solid-js';
import { createMemo, createSignal, createEffect, Show, For } from 'solid-js';
import type { VM, Container } from '@/types/api';
import { formatBytes, formatPercent, formatUptime } from '@/utils/format';
import { MetricBar } from './MetricBar';
@ -60,7 +60,6 @@ interface GuestRowProps {
}
export function GuestRow(props: GuestRowProps) {
const initialGuestId = buildGuestId(props.guest);
const guestId = createMemo(() => buildGuestId(props.guest));
const isEditingUrl = createMemo(() => currentlyEditingGuestId() === guestId());

View file

@ -1,4 +1,4 @@
import { Component, JSX, createSignal, ErrorBoundary as SolidErrorBoundary } from 'solid-js';
import { Component, JSX, ErrorBoundary as SolidErrorBoundary } from 'solid-js';
import { logError } from '@/utils/logger';
import { SectionHeader } from '@/components/shared/SectionHeader';
@ -9,8 +9,6 @@ interface ErrorBoundaryProps {
}
const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (props) => {
const [details, setDetails] = createSignal(false);
return (
<div class="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-900 p-4">
<div class="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">

View file

@ -23,7 +23,6 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool
const [copied, setCopied] = createSignal<'password' | 'token' | null>(null);
const [themeMode, setThemeMode] = createSignal<'system' | 'light' | 'dark'>('system');
const [bootstrapToken, setBootstrapToken] = createSignal('');
const [isUnlocking, setIsUnlocking] = createSignal(false);
const [isUnlocked, setIsUnlocked] = createSignal(false);
const [bootstrapTokenPath, setBootstrapTokenPath] = createSignal<string>('');
const [isDocker, setIsDocker] = createSignal<boolean>(false);
@ -371,10 +370,10 @@ IMPORTANT: Keep these credentials secure!
<button
type="button"
onClick={handleUnlock}
disabled={isUnlocking() || !bootstrapToken().trim()}
disabled={!bootstrapToken().trim()}
class="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white rounded-lg font-medium transition-colors disabled:cursor-not-allowed"
>
{isUnlocking() ? 'Unlocking...' : 'Unlock Wizard'}
Unlock Wizard
</button>
</div>
</div>

View file

@ -85,6 +85,9 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
const [quickSetupCommand, setQuickSetupCommand] = createSignal('');
const [quickSetupToken, setQuickSetupToken] = createSignal('');
const [quickSetupExpiry, setQuickSetupExpiry] = createSignal<number | null>(null);
const showTemperatureMonitoringSection = () =>
typeof props.temperatureMonitoringEnabled === 'boolean';
const temperatureMonitoringEnabledValue = () => props.temperatureMonitoringEnabled ?? true;
const quickSetupExpiryLabel = () => {
const expiry = quickSetupExpiry();
if (!expiry) {
@ -1764,40 +1767,39 @@ export const NodeModal: Component<NodeModalProps> = (props) => {
</div>
</div>
<Show when={typeof props.temperatureMonitoringEnabled === 'boolean'}>
{() => {
const enabled = props.temperatureMonitoringEnabled ?? true;
return (
<div class="rounded-lg border border-gray-200 bg-white p-3 text-sm text-gray-700 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200">
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">Temperature monitoring</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Uses the Pulse sensors key or proxy to read CPU/NVMe temperatures for this node. Disable if you don't need temperature data or haven't deployed the proxy yet.
</p>
</div>
<TogglePrimitive
checked={enabled}
onChange={(event) => {
props.onToggleTemperatureMonitoring?.(event.currentTarget.checked);
}}
disabled={props.temperatureMonitoringLocked || props.savingTemperatureSetting}
ariaLabel={enabled ? 'Disable temperature monitoring' : 'Enable temperature monitoring'}
/>
</div>
<Show when={!enabled}>
<p class="mt-3 rounded border border-blue-200 bg-blue-50 p-2 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-200">
Pulse will skip SSH temperature polling for this node. Existing dashboard readings will stop refreshing.
</p>
</Show>
<Show when={props.temperatureMonitoringLocked}>
<p class="mt-3 rounded border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-200">
Locked by environment variables. Remove the override (ENABLE_TEMPERATURE_MONITORING) and restart Pulse to manage it in the UI.
</p>
</Show>
<Show when={showTemperatureMonitoringSection()}>
<div class="rounded-lg border border-gray-200 bg-white p-3 text-sm text-gray-700 shadow-sm dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200">
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">Temperature monitoring</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Uses the Pulse sensors key or proxy to read CPU/NVMe temperatures for this node. Disable if you don't need temperature data or haven't deployed the proxy yet.
</p>
</div>
);
}}
<TogglePrimitive
checked={temperatureMonitoringEnabledValue()}
onChange={(event) => {
props.onToggleTemperatureMonitoring?.(event.currentTarget.checked);
}}
disabled={props.temperatureMonitoringLocked || props.savingTemperatureSetting}
ariaLabel={
temperatureMonitoringEnabledValue()
? 'Disable temperature monitoring'
: 'Enable temperature monitoring'
}
/>
</div>
<Show when={!temperatureMonitoringEnabledValue()}>
<p class="mt-3 rounded border border-blue-200 bg-blue-50 p-2 text-xs text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-200">
Pulse will skip SSH temperature polling for this node. Existing dashboard readings will stop refreshing.
</p>
</Show>
<Show when={props.temperatureMonitoringLocked}>
<p class="mt-3 rounded border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-200">
Locked by environment variables. Remove the override (ENABLE_TEMPERATURE_MONITORING) and restart Pulse to manage it in the UI.
</p>
</Show>
</div>
</Show>
</div>
</Show>

View file

@ -9,7 +9,6 @@ import {
onCleanup,
on,
} from 'solid-js';
import type { JSX } from 'solid-js';
import { useNavigate, useLocation } from '@solidjs/router';
import { useWebSocket } from '@/App';
import { showSuccess, showError, showWarning } from '@/utils/toast';
@ -535,6 +534,14 @@ const Settings: Component<SettingsProps> = (props) => {
const [showDeleteNodeModal, setShowDeleteNodeModal] = createSignal(false);
const [nodePendingDelete, setNodePendingDelete] = createSignal<NodeConfigWithStatus | null>(null);
const [deleteNodeLoading, setDeleteNodeLoading] = createSignal(false);
const isNodeModalVisible = (type: 'pve' | 'pbs' | 'pmg') =>
Boolean(showNodeModal() && currentNodeType() === type);
const resolveTemperatureMonitoringEnabled = (node?: NodeConfigWithStatus | null) => {
if (node && typeof node.temperatureMonitoringEnabled === 'boolean') {
return node.temperatureMonitoringEnabled;
}
return temperatureMonitoringEnabled();
};
const [initialLoadComplete, setInitialLoadComplete] = createSignal(false);
const [discoveryScanStatus, setDiscoveryScanStatus] = createSignal<DiscoveryScanStatus>({
scanning: false,
@ -6714,7 +6721,7 @@ const Settings: Component<SettingsProps> = (props) => {
</Show>
{/* Node Modal - Use separate modals for PVE and PBS to ensure clean state */}
<Show when={showNodeModal() && currentNodeType() === 'pve'}>
<Show when={isNodeModalVisible('pve')}>
<NodeModal
isOpen={true}
resetKey={modalResetKey()}
@ -6727,11 +6734,9 @@ const Settings: Component<SettingsProps> = (props) => {
nodeType="pve"
editingNode={editingNode()?.type === 'pve' ? (editingNode() ?? undefined) : undefined}
securityStatus={securityStatus() ?? undefined}
temperatureMonitoringEnabled={
editingNode()?.temperatureMonitoringEnabled !== undefined
? editingNode()!.temperatureMonitoringEnabled
: temperatureMonitoringEnabled()
}
temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled(
editingNode()?.type === 'pve' ? editingNode() : null,
)}
temperatureMonitoringLocked={temperatureMonitoringLocked()}
savingTemperatureSetting={savingTemperatureSetting()}
onToggleTemperatureMonitoring={
@ -6788,7 +6793,7 @@ const Settings: Component<SettingsProps> = (props) => {
</Show>
{/* PBS Node Modal - Separate instance to prevent contamination */}
<Show when={showNodeModal() && currentNodeType() === 'pbs'}>
<Show when={isNodeModalVisible('pbs')}>
<NodeModal
isOpen={true}
resetKey={modalResetKey()}
@ -6801,11 +6806,9 @@ const Settings: Component<SettingsProps> = (props) => {
nodeType="pbs"
editingNode={editingNode()?.type === 'pbs' ? (editingNode() ?? undefined) : undefined}
securityStatus={securityStatus() ?? undefined}
temperatureMonitoringEnabled={
editingNode()?.temperatureMonitoringEnabled !== undefined
? editingNode()!.temperatureMonitoringEnabled
: temperatureMonitoringEnabled()
}
temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled(
editingNode()?.type === 'pbs' ? editingNode() : null,
)}
temperatureMonitoringLocked={temperatureMonitoringLocked()}
savingTemperatureSetting={savingTemperatureSetting()}
onToggleTemperatureMonitoring={
@ -6861,7 +6864,7 @@ const Settings: Component<SettingsProps> = (props) => {
</Show>
{/* PMG Node Modal */}
<Show when={showNodeModal() && currentNodeType() === 'pmg'}>
<Show when={isNodeModalVisible('pmg')}>
<NodeModal
isOpen={true}
resetKey={modalResetKey()}
@ -6873,11 +6876,9 @@ const Settings: Component<SettingsProps> = (props) => {
nodeType="pmg"
editingNode={editingNode()?.type === 'pmg' ? (editingNode() ?? undefined) : undefined}
securityStatus={securityStatus() ?? undefined}
temperatureMonitoringEnabled={
editingNode()?.temperatureMonitoringEnabled !== undefined
? editingNode()!.temperatureMonitoringEnabled
: temperatureMonitoringEnabled()
}
temperatureMonitoringEnabled={resolveTemperatureMonitoringEnabled(
editingNode()?.type === 'pmg' ? editingNode() : null,
)}
temperatureMonitoringLocked={temperatureMonitoringLocked()}
savingTemperatureSetting={savingTemperatureSetting()}
onToggleTemperatureMonitoring={

View file

@ -1,5 +1,6 @@
import { createSignal, Show, onMount, onCleanup, createEffect } from 'solid-js';
import { createSignal, Show, onCleanup, createEffect } from 'solid-js';
import { UpdatesAPI, type UpdateStatus } from '@/api/updates';
import { apiFetch } from '@/utils/apiClient';
import { logger } from '@/utils/logger';
interface UpdateProgressModalProps {
@ -16,7 +17,6 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
const [hasError, setHasError] = createSignal(false);
const [isRestarting, setIsRestarting] = createSignal(false);
const [wsDisconnected, setWsDisconnected] = createSignal(false);
const [usingSSE, setUsingSSE] = createSignal(false);
let pollInterval: number | undefined;
let healthCheckTimer: number | undefined;
let healthCheckAttempts = 0;
@ -30,12 +30,12 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
};
const closeSSE = () => {
if (eventSource) {
eventSource.close();
eventSource = undefined;
setUsingSSE(false);
logger.info('SSE connection closed');
if (!eventSource) {
return;
}
eventSource.close();
eventSource = undefined;
logger.info('SSE connection closed');
};
const setupSSE = () => {
@ -45,7 +45,6 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
try {
// Create EventSource connection to SSE endpoint
eventSource = new EventSource('/api/updates/stream');
setUsingSSE(true);
eventSource.onopen = () => {
logger.info('SSE connection established');
@ -73,7 +72,7 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
if (updateStatus.status === 'completed' && !updateStatus.error) {
closeSSE();
// Verify backend health and reload
fetch('/api/health', { cache: 'no-store' })
apiFetch('/api/health', { cache: 'no-store' })
.then((healthCheck) => {
if (healthCheck.ok) {
logger.info('Update completed, backend healthy, reloading...');
@ -157,7 +156,7 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
}
// Verify backend is healthy and reload
try {
const healthCheck = await fetch('/api/health', { cache: 'no-store' });
const healthCheck = await apiFetch('/api/health', { cache: 'no-store' });
if (healthCheck.ok) {
logger.info('Update completed, backend healthy, reloading...');
window.location.reload();
@ -214,7 +213,7 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
let isHealthy = false;
try {
const response = await fetch('/api/health', { cache: 'no-store' });
const response = await apiFetch('/api/health', { cache: 'no-store' });
if (response.ok) {
isHealthy = true;
}
@ -258,7 +257,7 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
// Give it a moment for the backend to fully initialize
setTimeout(async () => {
try {
const response = await fetch('/api/health', { cache: 'no-store' });
const response = await apiFetch('/api/health', { cache: 'no-store' });
if (response.ok) {
logger.info('Backend healthy after websocket reconnect, reloading...');
window.location.reload();

View file

@ -651,20 +651,20 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
: 'text-green-600 dark:text-green-400';
const temp = node!.temperature;
const cpuMin = temp?.cpuMin;
const cpuMax = temp?.cpuMaxRecord;
const hasMinMax =
typeof cpuMin === 'number' &&
cpuMin > 0 &&
typeof cpuMax === 'number' &&
cpuMax > 0;
const cpuMinValue =
typeof temp?.cpuMin === 'number' && temp.cpuMin > 0 ? temp.cpuMin : null;
const cpuMaxValue =
typeof temp?.cpuMaxRecord === 'number' && temp.cpuMaxRecord > 0
? temp.cpuMaxRecord
: null;
const hasMinMax = cpuMinValue !== null && cpuMaxValue !== null;
const gpus = temp?.gpu ?? [];
const hasGPU = gpus.length > 0;
if (hasMinMax || hasGPU) {
const min = Math.round(cpuMin);
const max = Math.round(cpuMax);
const min = Math.round(cpuMinValue!);
const max = Math.round(cpuMaxValue!);
const getTooltipColor = (temp: number) => {
if (temp >= 80) return 'text-red-400';

View file

@ -5,7 +5,7 @@
* Optimized for rendering many sparklines simultaneously in tables.
*/
import { onMount, onCleanup, createEffect, createSignal, Component, Show } from 'solid-js';
import { onCleanup, createEffect, createSignal, Component, Show } from 'solid-js';
import { Portal } from 'solid-js/web';
import type { MetricSnapshot } from '@/stores/metricsHistory';
import { scheduleSparkline } from '@/utils/canvasRenderQueue';
@ -202,12 +202,6 @@ export const Sparkline: Component<SparklineProps> = (props) => {
// Redraw when data or dimensions change
createEffect(() => {
// Capture current values to avoid stale closures
const currentData = props.data;
const currentWidth = width();
const currentHeight = height();
const currentMetric = props.metric;
// Unregister previous draw callback if it exists
if (unregister) {
unregister();
@ -230,7 +224,6 @@ export const Sparkline: Component<SparklineProps> = (props) => {
const rect = canvasRef.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const w = width();
const h = height();
const data = props.data;
const values = data.map(d => d[props.metric]);
@ -244,10 +237,6 @@ export const Sparkline: Component<SparklineProps> = (props) => {
const timestamp = data[nearestIndex].timestamp;
// Calculate absolute viewport position for portal
const minValue = 0;
const maxValue = Math.max(100, ...values);
const y = h - ((value - minValue) / (maxValue - minValue)) * h;
setHoveredPoint({
value,
timestamp,

View file

@ -14,7 +14,6 @@ import { recordMetric } from './metricsHistory';
import { getMetricsViewMode } from './metricsViewMode';
import { buildMetricKey } from '@/utils/metricsKeys';
import { logger } from '@/utils/logger';
import type { Node, VM, Container, DockerHost } from '@/types/api';
const SAMPLE_INTERVAL_MS = 30 * 1000; // 30 seconds
@ -110,9 +109,9 @@ function sampleMetrics(): void {
const contCpu = container.cpuPercent || 0;
const contMem = container.memoryPercent || 0;
const contDisk = container.disk && container.disk.total > 0
? (container.disk.used / container.disk.total) * 100
: 0;
const usableTotal = container.rootFilesystemBytes ?? 0;
const writable = container.writableLayerBytes ?? 0;
const contDisk = usableTotal > 0 ? (writable / usableTotal) * 100 : 0;
const containerKey = buildMetricKey('dockerContainer', container.id);
recordMetric(containerKey, contCpu, contMem, contDisk);

View file

@ -4,6 +4,8 @@
import { logger } from '@/utils/logger';
import { STORAGE_KEYS } from '@/utils/localStorage';
const AUTH_STORAGE_KEY = STORAGE_KEYS.AUTH;
const getSessionStorage = (): Storage | undefined => {
if (typeof window === 'undefined') {
return undefined;