feat(aria): group Device Posture table by institution with collapsible accordion
CD - Develop / build-and-deploy (push) Failing after 14m7s
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:
@@ -2,14 +2,9 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { ariaApi, type DeviceSecurityPosture, type OsEolStatus } from '../lib/api';
|
import { ariaApi, type DeviceSecurityPosture, type OsEolStatus } from '../lib/api';
|
||||||
import { addToast } from '../lib/stores';
|
import { addToast } from '../lib/stores';
|
||||||
import Pagination from './common/Pagination.svelte';
|
|
||||||
|
|
||||||
let postures: DeviceSecurityPosture[] = [];
|
let postures: DeviceSecurityPosture[] = [];
|
||||||
let totalElements = 0;
|
let totalElements = 0;
|
||||||
let totalPages = 0;
|
|
||||||
let currentPage = 0;
|
|
||||||
let pageSize = 25;
|
|
||||||
|
|
||||||
let loading = true;
|
let loading = true;
|
||||||
|
|
||||||
// Filter sidebar
|
// Filter sidebar
|
||||||
@@ -23,18 +18,62 @@
|
|||||||
let panelOpen = false;
|
let panelOpen = false;
|
||||||
let panelDevice: DeviceSecurityPosture | null = null;
|
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;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
const params: Record<string, any> = { page, size: pageSize };
|
const params: Record<string, any> = { page: 0, size: 500 };
|
||||||
if (filterScore) params.scoreFilter = filterScore;
|
if (filterScore) params.scoreFilter = filterScore;
|
||||||
if (filterEol) params.osEolStatus = filterEol;
|
if (filterEol) params.osEolStatus = filterEol;
|
||||||
|
|
||||||
const res = await ariaApi.getPostures(params);
|
const res = await ariaApi.getPostures(params);
|
||||||
postures = res.data.content;
|
postures = res.data.content;
|
||||||
totalElements = res.data.page.totalElements;
|
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 {
|
} catch {
|
||||||
addToast('error', 'Load Failed', 'Could not load device posture data');
|
addToast('error', 'Load Failed', 'Could not load device posture data');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -45,8 +84,7 @@
|
|||||||
function clearAllFilters() {
|
function clearAllFilters() {
|
||||||
filterScore = '';
|
filterScore = '';
|
||||||
filterEol = '';
|
filterEol = '';
|
||||||
currentPage = 0;
|
load();
|
||||||
load(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPanel(device: DeviceSecurityPosture) {
|
function openPanel(device: DeviceSecurityPosture) {
|
||||||
@@ -105,13 +143,13 @@
|
|||||||
|
|
||||||
async function doRefresh() {
|
async function doRefresh() {
|
||||||
refreshing = true;
|
refreshing = true;
|
||||||
await load(currentPage);
|
await load();
|
||||||
refreshing = false;
|
refreshing = false;
|
||||||
secondsLeft = REFRESH_SECONDS;
|
secondsLeft = REFRESH_SECONDS;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
load(0);
|
load();
|
||||||
tickInterval = setInterval(() => {
|
tickInterval = setInterval(() => {
|
||||||
if (!autoRefreshEnabled) return;
|
if (!autoRefreshEnabled) return;
|
||||||
if (secondsLeft > 1) {
|
if (secondsLeft > 1) {
|
||||||
@@ -193,7 +231,7 @@
|
|||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<label class="filter-section-label">Score</label>
|
<label class="filter-section-label">Score</label>
|
||||||
<select class="filter-select" bind:value={filterScore}
|
<select class="filter-select" bind:value={filterScore}
|
||||||
on:change={() => { currentPage = 0; load(0); }}>
|
on:change={() => load()}>
|
||||||
<option value="">All Scores</option>
|
<option value="">All Scores</option>
|
||||||
<option value="low">Low (<50)</option>
|
<option value="low">Low (<50)</option>
|
||||||
<option value="medium">Below Good (<80)</option>
|
<option value="medium">Below Good (<80)</option>
|
||||||
@@ -203,7 +241,7 @@
|
|||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<label class="filter-section-label">OS Status</label>
|
<label class="filter-section-label">OS Status</label>
|
||||||
<select class="filter-select" bind:value={filterEol}
|
<select class="filter-select" bind:value={filterEol}
|
||||||
on:change={() => { currentPage = 0; load(0); }}>
|
on:change={() => load()}>
|
||||||
<option value="">All</option>
|
<option value="">All</option>
|
||||||
<option value="EOL">EOL</option>
|
<option value="EOL">EOL</option>
|
||||||
<option value="SUPPORTED">Supported</option>
|
<option value="SUPPORTED">Supported</option>
|
||||||
@@ -224,14 +262,14 @@
|
|||||||
<span class="filter-tag" style="border-color: #d97706">
|
<span class="filter-tag" style="border-color: #d97706">
|
||||||
<span class="filter-tag-dot" style="background-color: #d97706"></span>
|
<span class="filter-tag-dot" style="background-color: #d97706"></span>
|
||||||
Score: {filterScore === 'low' ? 'Low (<50)' : 'Below Good (<80)'}
|
Score: {filterScore === 'low' ? 'Low (<50)' : 'Below Good (<80)'}
|
||||||
<button class="filter-tag-remove" on:click={() => { filterScore = ''; currentPage = 0; load(0); }}>×</button>
|
<button class="filter-tag-remove" on:click={() => { filterScore = ''; load(); }}>×</button>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if filterEol}
|
{#if filterEol}
|
||||||
<span class="filter-tag" style="border-color: {filterEol === 'EOL' ? '#dc2626' : filterEol === 'SUPPORTED' ? '#16a34a' : '#6b7280'}">
|
<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>
|
<span class="filter-tag-dot" style="background-color: {filterEol === 'EOL' ? '#dc2626' : filterEol === 'SUPPORTED' ? '#16a34a' : '#6b7280'}"></span>
|
||||||
OS: {eolLabel(filterEol)}
|
OS: {eolLabel(filterEol)}
|
||||||
<button class="filter-tag-remove" on:click={() => { filterEol = ''; currentPage = 0; load(0); }}>×</button>
|
<button class="filter-tag-remove" on:click={() => { filterEol = ''; load(); }}>×</button>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -245,12 +283,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-card">
|
<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">
|
<div class="table-container">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="table-msg">Loading…</div>
|
<div class="table-msg">Loading…</div>
|
||||||
@@ -274,8 +306,26 @@
|
|||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
{#each grouped as group (group.key)}
|
||||||
<tbody>
|
<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)}>
|
<tr on:click={() => openPanel(device)}>
|
||||||
<td class="mono-cell">{device.deviceAgentId ?? device.deviceId}</td>
|
<td class="mono-cell">{device.deviceAgentId ?? device.deviceId}</td>
|
||||||
<td class="os-cell">{device.osVersion ?? '—'}</td>
|
<td class="os-cell">{device.osVersion ?? '—'}</td>
|
||||||
@@ -313,7 +363,9 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
{/each}
|
||||||
</table>
|
</table>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -644,6 +696,26 @@
|
|||||||
}
|
}
|
||||||
.check-divider { height: 1px; background: #f3f4f6; margin: 4px 0; }
|
.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 */
|
/* Dark mode */
|
||||||
:global(.dark-mode) .content-frame { border-color: #374151; background: #1f2937; }
|
:global(.dark-mode) .content-frame { border-color: #374151; background: #1f2937; }
|
||||||
:global(.dark-mode) .filter-sidebar { background: #1f2937; border-right-color: #374151; }
|
: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.drift { background: #450a0a; color: #fca5a5; }
|
||||||
:global(.dark-mode) .img-badge.unknown { background: #1e293b; color: #94a3b8; }
|
: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 { background: #1e293b; }
|
||||||
:global(.dark-mode) .panel-footer { background: #162032; border-top-color: #334155; }
|
:global(.dark-mode) .panel-footer { background: #162032; border-top-color: #334155; }
|
||||||
:global(.dark-mode) .panel-score-row { background: #111827; }
|
:global(.dark-mode) .panel-score-row { background: #111827; }
|
||||||
|
|||||||
Reference in New Issue
Block a user