From 87ad18571141136e1efb26d0dd246e63334664c8 Mon Sep 17 00:00:00 2001 From: Johannes Date: Tue, 16 Jun 2026 17:43:10 -0400 Subject: [PATCH] feat(aria): group Device Posture table by institution with collapsible accordion 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 --- frontend/src/components/DevicePosture.svelte | 199 +++++++++++++------ 1 file changed, 139 insertions(+), 60 deletions(-) diff --git a/frontend/src/components/DevicePosture.svelte b/frontend/src/components/DevicePosture.svelte index d424622..dcb0c92 100644 --- a/frontend/src/components/DevicePosture.svelte +++ b/frontend/src/components/DevicePosture.svelte @@ -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(); + 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(); + 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 = { page, size: pageSize }; + const params: Record = { 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 @@
{ currentPage = 0; load(0); }}> + on:change={() => load()}> @@ -224,14 +262,14 @@ Score: {filterScore === 'low' ? 'Low (<50)' : 'Below Good (<80)'} - + {/if} {#if filterEol} OS: {eolLabel(filterEol)} - + {/if}
@@ -245,12 +283,6 @@
- { currentPage = e.detail.page; load(currentPage); }} - on:pageSizeChange={e => { pageSize = e.detail.size; currentPage = 0; load(0); }} - /> -
{#if loading}
Loading…
@@ -274,46 +306,66 @@ - - {#each postures as device (device.deviceId)} - openPanel(device)}> - {device.deviceAgentId ?? device.deviceId} - {device.osVersion ?? '—'} - - - {eolLabel(device.osEolStatus)} - - - {boolCell(device.diskEncryptionEnabled)} - {boolCell(device.auditPolicyCompliant)} - {boolCell(device.softwareWhitelistEnabled)} - - {#if imageMismatch(device)} - Drift - {:else if device.goldImageHash} - Match - {:else} - - {/if} - - -
-
-
-
- - {device.postureScore ?? '—'} - + {#each grouped as group (group.key)} + + + toggleInstitution(group.key)}> + +
+ {expandedInstitutions.has(group.key) ? '▼' : '▶'} + {group.key} + {group.devices.length} device{group.devices.length !== 1 ? 's' : ''} + {#if group.avgScore != null} + avg {group.avgScore} + {/if} + {#if group.hasIssues} + ⚠ below 80 + {/if}
- {formatDate(device.lastPostureCheckAt)} - - - - {/each} - + {#if expandedInstitutions.has(group.key)} + {#each group.devices as device (device.deviceId)} + openPanel(device)}> + {device.deviceAgentId ?? device.deviceId} + {device.osVersion ?? '—'} + + + {eolLabel(device.osEolStatus)} + + + {boolCell(device.diskEncryptionEnabled)} + {boolCell(device.auditPolicyCompliant)} + {boolCell(device.softwareWhitelistEnabled)} + + {#if imageMismatch(device)} + Drift + {:else if device.goldImageHash} + Match + {:else} + + {/if} + + +
+
+
+
+ + {device.postureScore ?? '—'} + +
+ + {formatDate(device.lastPostureCheckAt)} + + + + + {/each} + {/if} + + {/each} {/if}
@@ -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; }