feat(aria): group Device Posture table by institution with collapsible accordion
CD - Develop / build-and-deploy (push) Failing after 14m7s

All institutions expanded by default. Header row shows device count,
avg posture score, and a warning badge when any device scores below 80.
Fetches all records in one call (size=500) instead of paginating.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 17:43:10 -04:00
parent e288bcdfe9
commit 87ad185711
+103 -24
View File
@@ -2,14 +2,9 @@
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';
let postures: DeviceSecurityPosture[] = [];
let totalElements = 0;
let totalPages = 0;
let currentPage = 0;
let pageSize = 25;
let loading = true;
// Filter sidebar
@@ -23,18 +18,62 @@
let panelOpen = false;
let panelDevice: DeviceSecurityPosture | null = null;
async function load(page = 0) {
// Accordion — all expanded by default
let expandedInstitutions = new Set<string>();
let allLoaded = false;
interface InstGroup {
key: string;
devices: DeviceSecurityPosture[];
avgScore: number | null;
hasIssues: boolean;
}
$: grouped = groupByInstitution(postures);
function groupByInstitution(list: DeviceSecurityPosture[]): InstGroup[] {
const map = new Map<string, DeviceSecurityPosture[]>();
for (const d of list) {
const k = d.institutionKey ?? 'Unknown';
if (!map.has(k)) map.set(k, []);
map.get(k)!.push(d);
}
return Array.from(map.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, devices]) => {
const scored = devices.filter(d => d.postureScore != null);
const avgScore = scored.length
? Math.round(scored.reduce((s, d) => s + d.postureScore!, 0) / scored.length)
: null;
const hasIssues = devices.some(d => d.postureScore != null && d.postureScore < 80);
return { key, devices, avgScore, hasIssues };
});
}
function toggleInstitution(key: string) {
if (expandedInstitutions.has(key)) {
expandedInstitutions.delete(key);
} else {
expandedInstitutions.add(key);
}
expandedInstitutions = expandedInstitutions;
}
async function load() {
loading = true;
try {
const params: Record<string, any> = { page, size: pageSize };
const params: Record<string, any> = { page: 0, size: 500 };
if (filterScore) params.scoreFilter = filterScore;
if (filterEol) params.osEolStatus = filterEol;
const res = await ariaApi.getPostures(params);
postures = res.data.content;
totalElements = res.data.page.totalElements;
totalPages = res.data.page.totalPages;
currentPage = res.data.page.number;
if (!allLoaded) {
expandedInstitutions = new Set(postures.map(d => d.institutionKey ?? 'Unknown'));
allLoaded = true;
}
} catch {
addToast('error', 'Load Failed', 'Could not load device posture data');
} finally {
@@ -45,8 +84,7 @@
function clearAllFilters() {
filterScore = '';
filterEol = '';
currentPage = 0;
load(0);
load();
}
function openPanel(device: DeviceSecurityPosture) {
@@ -105,13 +143,13 @@
async function doRefresh() {
refreshing = true;
await load(currentPage);
await load();
refreshing = false;
secondsLeft = REFRESH_SECONDS;
}
onMount(() => {
load(0);
load();
tickInterval = setInterval(() => {
if (!autoRefreshEnabled) return;
if (secondsLeft > 1) {
@@ -193,7 +231,7 @@
<div class="filter-section">
<label class="filter-section-label">Score</label>
<select class="filter-select" bind:value={filterScore}
on:change={() => { currentPage = 0; load(0); }}>
on:change={() => load()}>
<option value="">All Scores</option>
<option value="low">Low (&lt;50)</option>
<option value="medium">Below Good (&lt;80)</option>
@@ -203,7 +241,7 @@
<div class="filter-section">
<label class="filter-section-label">OS Status</label>
<select class="filter-select" bind:value={filterEol}
on:change={() => { currentPage = 0; load(0); }}>
on:change={() => load()}>
<option value="">All</option>
<option value="EOL">EOL</option>
<option value="SUPPORTED">Supported</option>
@@ -224,14 +262,14 @@
<span class="filter-tag" style="border-color: #d97706">
<span class="filter-tag-dot" style="background-color: #d97706"></span>
Score: {filterScore === 'low' ? 'Low (<50)' : 'Below Good (<80)'}
<button class="filter-tag-remove" on:click={() => { filterScore = ''; currentPage = 0; load(0); }}>&times;</button>
<button class="filter-tag-remove" on:click={() => { filterScore = ''; load(); }}>&times;</button>
</span>
{/if}
{#if filterEol}
<span class="filter-tag" style="border-color: {filterEol === 'EOL' ? '#dc2626' : filterEol === 'SUPPORTED' ? '#16a34a' : '#6b7280'}">
<span class="filter-tag-dot" style="background-color: {filterEol === 'EOL' ? '#dc2626' : filterEol === 'SUPPORTED' ? '#16a34a' : '#6b7280'}"></span>
OS: {eolLabel(filterEol)}
<button class="filter-tag-remove" on:click={() => { filterEol = ''; currentPage = 0; load(0); }}>&times;</button>
<button class="filter-tag-remove" on:click={() => { filterEol = ''; load(); }}>&times;</button>
</span>
{/if}
</div>
@@ -245,12 +283,6 @@
</div>
<div class="table-card">
<Pagination
page={currentPage} {totalPages} {totalElements} size={pageSize}
on:pageChange={e => { currentPage = e.detail.page; load(currentPage); }}
on:pageSizeChange={e => { pageSize = e.detail.size; currentPage = 0; load(0); }}
/>
<div class="table-container">
{#if loading}
<div class="table-msg">Loading…</div>
@@ -274,8 +306,26 @@
<th></th>
</tr>
</thead>
{#each grouped as group (group.key)}
<tbody>
{#each postures as device (device.deviceId)}
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<tr class="inst-header" on:click={() => toggleInstitution(group.key)}>
<td colspan="10">
<div class="inst-header-inner">
<span class="inst-chevron">{expandedInstitutions.has(group.key) ? '▼' : '▶'}</span>
<span class="inst-key">{group.key}</span>
<span class="inst-count">{group.devices.length} device{group.devices.length !== 1 ? 's' : ''}</span>
{#if group.avgScore != null}
<span class="inst-avg score-text-{scoreColor(group.avgScore)}">avg {group.avgScore}</span>
{/if}
{#if group.hasIssues}
<span class="inst-warn">⚠ below 80</span>
{/if}
</div>
</td>
</tr>
{#if expandedInstitutions.has(group.key)}
{#each group.devices as device (device.deviceId)}
<tr on:click={() => openPanel(device)}>
<td class="mono-cell">{device.deviceAgentId ?? device.deviceId}</td>
<td class="os-cell">{device.osVersion ?? '—'}</td>
@@ -313,7 +363,9 @@
</td>
</tr>
{/each}
{/if}
</tbody>
{/each}
</table>
{/if}
</div>
@@ -644,6 +696,26 @@
}
.check-divider { height: 1px; background: #f3f4f6; margin: 4px 0; }
/* Institution accordion header rows */
tr.inst-header { cursor: pointer; }
tr.inst-header td { padding: 0; border-bottom: 1px solid #e5e7eb; }
tr.inst-header:hover td { background: #f1f5f9; }
.inst-header-inner {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.55rem 0.75rem;
background: #f1f5f9;
font-size: 0.78rem;
}
.inst-chevron { color: #6b7280; font-size: 0.65rem; flex-shrink: 0; width: 10px; }
.inst-key { font-weight: 700; color: #1e3a5f; font-size: 0.82rem; letter-spacing: 0.2px; }
.inst-count { color: #6b7280; font-size: 0.75rem; }
.inst-avg { font-weight: 700; font-size: 0.75rem; }
.inst-warn {
font-size: 0.72rem; font-weight: 600; color: #b45309;
background: #fef3c7; border: 1px solid #fde68a;
border-radius: 10px; padding: 1px 8px;
}
/* Dark mode */
:global(.dark-mode) .content-frame { border-color: #374151; background: #1f2937; }
:global(.dark-mode) .filter-sidebar { background: #1f2937; border-right-color: #374151; }
@@ -684,6 +756,13 @@
:global(.dark-mode) .img-badge.drift { background: #450a0a; color: #fca5a5; }
:global(.dark-mode) .img-badge.unknown { background: #1e293b; color: #94a3b8; }
:global(.dark-mode) .inst-header-inner { background: #1a2744; }
:global(.dark-mode) tr.inst-header:hover td { background: #243356; }
:global(.dark-mode) tr.inst-header td { border-bottom-color: #374151; }
:global(.dark-mode) .inst-key { color: #93c5fd; }
:global(.dark-mode) .inst-count { color: #6b7280; }
:global(.dark-mode) .inst-warn { background: #451a03; border-color: #78350f; color: #fbbf24; }
:global(.dark-mode) .panel { background: #1e293b; }
:global(.dark-mode) .panel-footer { background: #162032; border-top-color: #334155; }
:global(.dark-mode) .panel-score-row { background: #111827; }