fix(aria): replace plain Refresh button with canonical circular auto-refresh control
CD - Develop / build-and-deploy (push) Failing after 13m3s

All three list pages (ThreatEvents, PrecursorAlerts, DevicePosture) now
use the template's btn-manual-refresh icon button + SVG countdown timer
in header-controls, matching the canonical TablePage.svelte pattern.
Plain text Refresh button removed from toolbar-right.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 16:39:21 -04:00
parent 726ccb4ecf
commit c78289bfef
3 changed files with 252 additions and 24 deletions
+83 -11
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { ariaApi, type DeviceSecurityPosture, type OsEolStatus } from '../lib/api';
import { addToast } from '../lib/stores';
import Pagination from './common/Pagination.svelte';
@@ -88,7 +88,42 @@
return !!(d.goldImageHash && d.currentImageHash && d.goldImageHash !== d.currentImageHash);
}
onMount(() => load(0));
// Auto-refresh
const REFRESH_SECONDS = 60;
const CIRCUMFERENCE = 2 * Math.PI * 14;
let secondsLeft = REFRESH_SECONDS;
let refreshing = false;
let autoRefreshEnabled = true;
let tickInterval: ReturnType<typeof setInterval>;
$: dashOffset = CIRCUMFERENCE * (1 - secondsLeft / REFRESH_SECONDS);
function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled;
if (autoRefreshEnabled) secondsLeft = REFRESH_SECONDS;
}
async function doRefresh() {
refreshing = true;
await load(currentPage);
refreshing = false;
secondsLeft = REFRESH_SECONDS;
}
onMount(() => {
load(0);
tickInterval = setInterval(() => {
if (!autoRefreshEnabled) return;
if (secondsLeft > 1) {
secondsLeft -= 1;
} else {
secondsLeft = REFRESH_SECONDS;
doRefresh();
}
}, 1000);
});
onDestroy(() => clearInterval(tickInterval));
</script>
<div class="page-wrapper">
@@ -98,6 +133,34 @@
<h1>Device Posture</h1>
<p>Per-device security compliance — disk encryption, image integrity, OS currency</p>
</div>
<div class="header-controls">
<button class="btn-manual-refresh" on:click={doRefresh} disabled={refreshing}
title="Refresh now" aria-label="Refresh">{refreshing ? '⟳' : '↻'}</button>
<button class="refresh-countdown" class:paused={!autoRefreshEnabled}
on:click={toggleAutoRefresh}
title={autoRefreshEnabled ? `Auto-refresh in ${secondsLeft}s — click to pause` : 'Auto-refresh paused — click to resume'}
aria-label="Toggle auto-refresh">
<svg width="72" height="72" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>
{#if autoRefreshEnabled}
<circle cx="18" cy="18" r="14" fill="none"
stroke={secondsLeft <= 5 ? '#f87171' : 'rgba(255,255,255,0.85)'}
stroke-width="3" stroke-dasharray={CIRCUMFERENCE} stroke-dashoffset={dashOffset}
stroke-linecap="round" transform="rotate(-90 18 18)" class:pulsing={secondsLeft <= 5}/>
{:else}
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(255,255,255,0.3)"
stroke-width="3" stroke-dasharray="4 3" transform="rotate(-90 18 18)"/>
{/if}
{#if refreshing}
<text x="18" y="22" text-anchor="middle" font-size="9" fill="rgba(255,255,255,0.9)" font-family="sans-serif">⟳</text>
{:else if autoRefreshEnabled}
<text x="18" y="22" text-anchor="middle" font-size="9" fill="rgba(255,255,255,0.9)" font-family="sans-serif">{secondsLeft}</text>
{:else}
<text x="18" y="22" text-anchor="middle" font-size="9" fill="rgba(255,255,255,0.5)" font-family="sans-serif">⏸</text>
{/if}
</svg>
</button>
</div>
</div>
<div class="page-body">
@@ -179,9 +242,6 @@
<div class="toolbar">
<span class="toolbar-count">{totalElements} device{totalElements !== 1 ? 's' : ''}</span>
<div class="toolbar-right">
<button class="btn-refresh" on:click={() => load(currentPage)}>Refresh</button>
</div>
</div>
<div class="table-card">
@@ -462,12 +522,25 @@
}
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; flex-shrink: 0; }
.toolbar-count { font-size: var(--font-size-body-sm); color: #6b7280; }
.toolbar-right { display: flex; gap: 0.5rem; }
.btn-refresh {
background: #f3f4f6; color: #374151; border: 1px solid #d1d5db;
border-radius: 6px; padding: 6px 12px; font-size: var(--font-size-body-sm); font-weight: 500; cursor: pointer;
.header-controls { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.btn-manual-refresh {
background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.3);
color: white; border-radius: 50%; width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
font-size: 1.1rem; cursor: pointer; transition: background 0.15s; flex-shrink: 0;
}
.btn-refresh:hover { background: #e5e7eb; }
.btn-manual-refresh:hover:not(:disabled) { background: rgba(255,255,255,0.28); }
.btn-manual-refresh:disabled { opacity: 0.5; cursor: not-allowed; }
.refresh-countdown {
flex-shrink: 0; background: none; border: none; cursor: pointer; padding: 0;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
opacity: 0.9; transition: opacity 0.15s, transform 0.15s;
}
.refresh-countdown:hover { opacity: 1; transform: scale(1.08); }
.refresh-countdown.paused { opacity: 0.5; }
.refresh-countdown.paused:hover { opacity: 0.8; }
@keyframes countdown-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
:global(.pulsing) { animation: countdown-pulse 0.8s ease-in-out infinite; }
/* Table card — canonical */
.table-card {
@@ -590,7 +663,6 @@
:global(.dark-mode) .filter-tag-remove { background: #374151; color: #e5e7eb; }
:global(.dark-mode) .toolbar-count { color: #9ca3af; }
:global(.dark-mode) .btn-refresh { background: #374151; color: #e5e7eb; border-color: #4b5563; }
:global(.dark-mode) .table-card { background: #1f2937; }
:global(.dark-mode) thead { background: #111827; border-bottom-color: #374151; }
+84 -11
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { ariaApi, type PrecursorSequenceAlert, type SequenceType, type ThreatEvent } from '../lib/api';
import { addToast } from '../lib/stores';
import Pagination from './common/Pagination.svelte';
@@ -108,7 +108,42 @@
return Math.round(c * 100) + '%';
}
onMount(() => load(0));
// Auto-refresh
const REFRESH_SECONDS = 60;
const CIRCUMFERENCE = 2 * Math.PI * 14;
let secondsLeft = REFRESH_SECONDS;
let refreshing = false;
let autoRefreshEnabled = true;
let tickInterval: ReturnType<typeof setInterval>;
$: dashOffset = CIRCUMFERENCE * (1 - secondsLeft / REFRESH_SECONDS);
function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled;
if (autoRefreshEnabled) secondsLeft = REFRESH_SECONDS;
}
async function doRefresh() {
refreshing = true;
await load(currentPage);
refreshing = false;
secondsLeft = REFRESH_SECONDS;
}
onMount(() => {
load(0);
tickInterval = setInterval(() => {
if (!autoRefreshEnabled) return;
if (secondsLeft > 1) {
secondsLeft -= 1;
} else {
secondsLeft = REFRESH_SECONDS;
doRefresh();
}
}, 1000);
});
onDestroy(() => clearInterval(tickInterval));
</script>
<script lang="ts" context="module">
@@ -126,6 +161,34 @@
<h1>Precursor Alerts</h1>
<p>Multi-phase attack sequence detection — jackpotting and skimming precursors</p>
</div>
<div class="header-controls">
<button class="btn-manual-refresh" on:click={doRefresh} disabled={refreshing}
title="Refresh now" aria-label="Refresh">{refreshing ? '⟳' : '↻'}</button>
<button class="refresh-countdown" class:paused={!autoRefreshEnabled}
on:click={toggleAutoRefresh}
title={autoRefreshEnabled ? `Auto-refresh in ${secondsLeft}s — click to pause` : 'Auto-refresh paused — click to resume'}
aria-label="Toggle auto-refresh">
<svg width="72" height="72" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>
{#if autoRefreshEnabled}
<circle cx="18" cy="18" r="14" fill="none"
stroke={secondsLeft <= 5 ? '#f87171' : 'rgba(255,255,255,0.85)'}
stroke-width="3" stroke-dasharray={CIRCUMFERENCE} stroke-dashoffset={dashOffset}
stroke-linecap="round" transform="rotate(-90 18 18)" class:pulsing={secondsLeft <= 5}/>
{:else}
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(255,255,255,0.3)"
stroke-width="3" stroke-dasharray="4 3" transform="rotate(-90 18 18)"/>
{/if}
{#if refreshing}
<text x="18" y="22" text-anchor="middle" font-size="9" fill="rgba(255,255,255,0.9)" font-family="sans-serif">⟳</text>
{:else if autoRefreshEnabled}
<text x="18" y="22" text-anchor="middle" font-size="9" fill="rgba(255,255,255,0.9)" font-family="sans-serif">{secondsLeft}</text>
{:else}
<text x="18" y="22" text-anchor="middle" font-size="9" fill="rgba(255,255,255,0.5)" font-family="sans-serif">⏸</text>
{/if}
</svg>
</button>
</div>
</div>
<div class="page-body">
@@ -220,9 +283,6 @@
<div class="toolbar">
<span class="toolbar-count">{totalElements} alert{totalElements !== 1 ? 's' : ''}</span>
<div class="toolbar-right">
<button class="btn-refresh" on:click={() => load(currentPage)}>Refresh</button>
</div>
</div>
<div class="table-card">
@@ -488,12 +548,26 @@
}
.toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; flex-shrink: 0; }
.toolbar-count { font-size: var(--font-size-body-sm); color: #6b7280; }
.toolbar-right { display: flex; gap: 0.5rem; }
.btn-refresh {
background: #f3f4f6; color: #374151; border: 1px solid #d1d5db;
border-radius: 6px; padding: 6px 12px; font-size: var(--font-size-body-sm); font-weight: 500; cursor: pointer;
.header-controls { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.btn-manual-refresh {
background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.3);
color: white; border-radius: 50%; width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
font-size: 1.1rem; cursor: pointer; transition: background 0.15s; flex-shrink: 0;
}
.btn-refresh:hover { background: #e5e7eb; }
.btn-manual-refresh:hover:not(:disabled) { background: rgba(255,255,255,0.28); }
.btn-manual-refresh:disabled { opacity: 0.5; cursor: not-allowed; }
.refresh-countdown {
flex-shrink: 0; background: none; border: none; cursor: pointer; padding: 0;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
opacity: 0.9; transition: opacity 0.15s, transform 0.15s;
}
.refresh-countdown:hover { opacity: 1; transform: scale(1.08); }
.refresh-countdown.paused { opacity: 0.5; }
.refresh-countdown.paused:hover { opacity: 0.8; }
@keyframes countdown-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
:global(.pulsing) { animation: countdown-pulse 0.8s ease-in-out infinite; }
/* Table card — canonical */
.table-card {
@@ -631,7 +705,6 @@
:global(.dark-mode) .filter-tag-remove { background: #374151; color: #e5e7eb; }
:global(.dark-mode) .toolbar-count { color: #9ca3af; }
:global(.dark-mode) .btn-refresh { background: #374151; color: #e5e7eb; border-color: #4b5563; }
:global(.dark-mode) .table-card { background: #1f2937; }
:global(.dark-mode) thead { background: #111827; border-bottom-color: #374151; }
+85 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { ariaApi, type ThreatEvent, type ThreatSeverity } from '../lib/api';
import { addToast } from '../lib/stores';
import Pagination from './common/Pagination.svelte';
@@ -89,7 +89,42 @@
load();
}
onMount(load);
// Auto-refresh
const REFRESH_SECONDS = 60;
const CIRCUMFERENCE = 2 * Math.PI * 14;
let secondsLeft = REFRESH_SECONDS;
let refreshing = false;
let autoRefreshEnabled = true;
let tickInterval: ReturnType<typeof setInterval>;
$: dashOffset = CIRCUMFERENCE * (1 - secondsLeft / REFRESH_SECONDS);
function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled;
if (autoRefreshEnabled) secondsLeft = REFRESH_SECONDS;
}
async function doRefresh() {
refreshing = true;
await load();
refreshing = false;
secondsLeft = REFRESH_SECONDS;
}
onMount(() => {
load();
tickInterval = setInterval(() => {
if (!autoRefreshEnabled) return;
if (secondsLeft > 1) {
secondsLeft -= 1;
} else {
secondsLeft = REFRESH_SECONDS;
doRefresh();
}
}, 1000);
});
onDestroy(() => clearInterval(tickInterval));
</script>
<div class="page-wrap">
@@ -98,6 +133,34 @@
<h1>Threat Events</h1>
<p>IOC match events detected across the fleet</p>
</div>
<div class="header-controls">
<button class="btn-manual-refresh" on:click={doRefresh} disabled={refreshing}
title="Refresh now" aria-label="Refresh">{refreshing ? '⟳' : '↻'}</button>
<button class="refresh-countdown" class:paused={!autoRefreshEnabled}
on:click={toggleAutoRefresh}
title={autoRefreshEnabled ? `Auto-refresh in ${secondsLeft}s — click to pause` : 'Auto-refresh paused — click to resume'}
aria-label="Toggle auto-refresh">
<svg width="72" height="72" viewBox="0 0 36 36">
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>
{#if autoRefreshEnabled}
<circle cx="18" cy="18" r="14" fill="none"
stroke={secondsLeft <= 5 ? '#f87171' : 'rgba(255,255,255,0.85)'}
stroke-width="3" stroke-dasharray={CIRCUMFERENCE} stroke-dashoffset={dashOffset}
stroke-linecap="round" transform="rotate(-90 18 18)" class:pulsing={secondsLeft <= 5}/>
{:else}
<circle cx="18" cy="18" r="14" fill="none" stroke="rgba(255,255,255,0.3)"
stroke-width="3" stroke-dasharray="4 3" transform="rotate(-90 18 18)"/>
{/if}
{#if refreshing}
<text x="18" y="22" text-anchor="middle" font-size="9" fill="rgba(255,255,255,0.9)" font-family="sans-serif">⟳</text>
{:else if autoRefreshEnabled}
<text x="18" y="22" text-anchor="middle" font-size="9" fill="rgba(255,255,255,0.9)" font-family="sans-serif">{secondsLeft}</text>
{:else}
<text x="18" y="22" text-anchor="middle" font-size="9" fill="rgba(255,255,255,0.5)" font-family="sans-serif">⏸</text>
{/if}
</svg>
</button>
</div>
</div>
<div class="page-body">
@@ -229,6 +292,26 @@
padding: 24px 24px 0;
}
.header-controls { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.btn-manual-refresh {
background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.3);
color: white; border-radius: 50%; width: 36px; height: 36px;
display: flex; align-items: center; justify-content: center;
font-size: 1.1rem; cursor: pointer; transition: background 0.15s; flex-shrink: 0;
}
.btn-manual-refresh:hover:not(:disabled) { background: rgba(255,255,255,0.28); }
.btn-manual-refresh:disabled { opacity: 0.5; cursor: not-allowed; }
.refresh-countdown {
flex-shrink: 0; background: none; border: none; cursor: pointer; padding: 0;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
opacity: 0.9; transition: opacity 0.15s, transform 0.15s;
}
.refresh-countdown:hover { opacity: 1; transform: scale(1.08); }
.refresh-countdown.paused { opacity: 0.5; }
.refresh-countdown.paused:hover { opacity: 0.8; }
@keyframes countdown-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
:global(.pulsing) { animation: countdown-pulse 0.8s ease-in-out infinite; }
.page-body {
display: flex; flex: 1; overflow: hidden; min-height: 0;
}