feat(web): add 5s polling and real-time idle timer countdown

- Add 5-second polling interval to refresh all dashboard data
- IdleTimer now counts down every second client-side
- Timer turns orange when < 60 seconds remain
- ServiceList shows target URL, health status, and last check time
- Unhealthy services now show red dot instead of gray

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Vadim Sobinin
2026-02-10 15:28:49 +03:00
parent 21f7f04be8
commit 59add2a549
3 changed files with 70 additions and 5 deletions

View File

@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte';
interface Props { interface Props {
lastActivity: string; lastActivity: string;
remainingSeconds: number; remainingSeconds: number;
@@ -6,6 +8,27 @@
let { lastActivity, remainingSeconds }: Props = $props(); let { lastActivity, remainingSeconds }: Props = $props();
let localRemaining = $state(remainingSeconds);
let interval: ReturnType<typeof setInterval> | null = null;
$effect(() => {
localRemaining = remainingSeconds;
});
$effect(() => {
if (interval) clearInterval(interval);
interval = setInterval(() => {
if (localRemaining > 0) localRemaining--;
}, 1000);
return () => {
if (interval) clearInterval(interval);
};
});
onDestroy(() => {
if (interval) clearInterval(interval);
});
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60); const m = Math.floor(seconds / 60);
const s = seconds % 60; const s = seconds % 60;
@@ -15,12 +38,14 @@
function formatDate(iso: string): string { function formatDate(iso: string): string {
return new Date(iso).toLocaleTimeString(); return new Date(iso).toLocaleTimeString();
} }
let urgent = $derived(localRemaining < 60);
</script> </script>
<div class="card"> <div class="card">
<h2>Idle Timer</h2> <h2>Idle Timer</h2>
<div class="timer-display"> <div class="timer-display">
<span class="time">{formatTime(remainingSeconds)}</span> <span class="time" class:urgent>{formatTime(localRemaining)}</span>
<span class="label">until idle check</span> <span class="label">until idle check</span>
</div> </div>
<p class="last-activity">Last activity: {formatDate(lastActivity)}</p> <p class="last-activity">Last activity: {formatDate(lastActivity)}</p>
@@ -38,6 +63,9 @@
font-weight: 700; font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.time.urgent {
color: var(--orange);
}
.label { .label {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);

View File

@@ -6,6 +6,14 @@
} }
let { services }: Props = $props(); let { services }: Props = $props();
function timeAgo(iso: string | null): string {
if (!iso) return 'never';
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return `${diff}s ago`;
return `${Math.floor(diff / 60)}m ago`;
}
</script> </script>
<div class="card"> <div class="card">
@@ -19,7 +27,13 @@
<span class="dot" class:healthy={service.healthy}></span> <span class="dot" class:healthy={service.healthy}></span>
<div class="service-info"> <div class="service-info">
<span class="service-name">{service.name}</span> <span class="service-name">{service.name}</span>
<span class="service-host">{service.host}</span> <span class="service-target">{service.target}</span>
<span class="service-meta">
{service.healthy ? 'Healthy' : 'Unhealthy'}
{#if service.lastCheck}
&middot; checked {timeAgo(service.lastCheck)}
{/if}
</span>
</div> </div>
</li> </li>
{/each} {/each}
@@ -50,7 +64,7 @@
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: var(--text-dim); background: var(--red);
flex-shrink: 0; flex-shrink: 0;
} }
.dot.healthy { .dot.healthy {
@@ -64,8 +78,14 @@
font-weight: 500; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
} }
.service-host { .service-target {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
font-family: monospace;
}
.service-meta {
font-size: 0.7rem;
color: var(--text-dim);
margin-top: 0.15rem;
} }
</style> </style>

View File

@@ -11,6 +11,7 @@ export const machineState = derived(status, ($s) => $s?.state ?? MachineState.OF
let ws: WebSocket | null = null; let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
function getWsUrl(): string { function getWsUrl(): string {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -20,13 +21,14 @@ function getWsUrl(): string {
export function connectWs(): void { export function connectWs(): void {
if (ws) return; if (ws) return;
startPolling();
try { try {
ws = new WebSocket(getWsUrl()); ws = new WebSocket(getWsUrl());
ws.onopen = () => { ws.onopen = () => {
connected.set(true); connected.set(true);
error.set(null); error.set(null);
// Fetch full status on connect
refreshStatus(); refreshStatus();
}; };
@@ -78,7 +80,22 @@ export async function refreshStatus(): Promise<void> {
} }
} }
function startPolling(): void {
if (pollTimer) return;
pollTimer = setInterval(() => {
refreshStatus();
}, 5000);
}
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
export function disconnectWs(): void { export function disconnectWs(): void {
stopPolling();
if (reconnectTimer) { if (reconnectTimer) {
clearTimeout(reconnectTimer); clearTimeout(reconnectTimer);
reconnectTimer = null; reconnectTimer = null;