Files
shopdb-flask/frontend/src/components/PluginAssetPanels.vue
benjamin.baker e00bb44d6b merge storage and disk health into one card with two sections
Volume capacity and physical drive health were two adjacent cards, and the operator reading a full disk is usually the operator reading a dying disk in the same minute. They are now one Storage card with two sections. Visual grouping only - the two tables share no key, storage being per volume and health per physical device, and one drive can back several volumes or none the operator sees. Nothing is reconciled across the sections; this is not the mapped-drive merge where two sources described the same drive and the worse status won. The tabs render type was the only sections-in-one-card mechanism and emitted label/value fields with no table path, so it now takes table sections too. That is the renderer every plugin uses, so both behaviour changes in it are mutation-tested. Two things only surfaced on contact: the empty-section filter had to grow a table branch, because a table section declares columns up front and dropping it when it has no rows takes its tab with it and reads as no disks rather than nothing reported; and full-width span was keyed off the declared render type, but a tabs panel holding a six-column table needs the same span and that is only knowable from fetched data. The diskhealth route stays. It no longer backs a panel, but it is a stable read for callers outside the renderer and removing it for a UI-only change would break them.
2026-09-05 07:05:30 -04:00

870 lines
33 KiB
Vue

<template>
<!-- Generic renderer for ADR-010 get_asset_panels (Path A). A plugin declares
a panel as JSON (title, endpoint, render mode, field map); this renders it
on any asset detail page, so a plugin adds detail-page UI with no Vue. -->
<template v-for="panel in groupedPanels" :key="panel.id">
<div v-if="panelVisible(panel)" class="section-card" :class="{ 'pap-span': spansFullWidth(panel) }">
<!-- collapsible panels get a clickable header with a caret and, when the
panel declares a status rollup, a green/yellow/red dot for the worst
row (a stopped service or a warning/critical event). --><h3
v-if="panel.collapsible"
class="section-title pap-header"
role="button"
tabindex="0"
:aria-expanded="!isCollapsed(panel)"
@click="toggleCollapse(panel)"
@keydown.enter.prevent="toggleCollapse(panel)"
@keydown.space.prevent="toggleCollapse(panel)"
>
<span class="pap-caret" :class="{ collapsed: isCollapsed(panel) }" aria-hidden="true">&#9662;</span>
<span class="pap-header-title">{{ panel.title }}</span>
<span
v-if="rollupStatus(panel)"
class="pap-status-dot"
:style="{ backgroundColor: rollupStatus(panel).color }"
:title="rollupStatus(panel).level"
></span>
</h3>
<h3 v-else class="section-title">{{ panel.title }}</h3>
<div v-show="!panel.collapsible || !isCollapsed(panel)" class="pap-body">
<!-- list: items each with a title, optional status badge, meta lines -->
<div v-if="panel.render === 'list'" class="pap-list">
<!-- Filter chips for any list panel that shows a health dot. Client
side over rows already fetched, so the counts cannot disagree with
what is on screen. -->
<div v-if="facets(panel).length > 1" class="pap-facets">
<button
v-for="facet in facets(panel)"
:key="facet.value"
type="button"
class="pap-facet"
:class="{ on: isFacetOn(panel, facet.value) }"
@click="toggleFacet(panel, facet.value)"
>{{ facet.label }} <span class="pap-facet-count">{{ facet.count }}</span></button>
<button v-if="facetSelection[panel.id] && facetSelection[panel.id].length"
type="button" class="pap-facet pap-facet-clear"
@click="facetSelection[panel.id] = []">Clear</button>
</div>
<template v-if="visibleRows(panel).length">
<div v-for="(item, index) in visibleRows(panel)" :key="index" class="pap-item">
<div class="pap-item-top">
<span class="pap-title">{{ mapTitle(panel, item) }}</span>
<!-- On a row carrying a body the severity badge IS the expander:
it already says what the row is, and a second control beside
it only repeated that. Rows with no body keep the plain
non-interactive badge. -->
<button
v-if="mapBadge(panel, item) && mapBody(panel, item)"
type="button"
class="status-badge pap-badge-toggle"
:style="colorStyle(mapBadge(panel, item).color)"
:aria-expanded="isBodyOpen(panel, index) ? 'true' : 'false'"
:title="isBodyOpen(panel, index) ? 'Hide details' : 'Show details'"
@click="toggleBody(panel, index)"
>{{ mapBadge(panel, item).label }}</button>
<span v-else-if="mapBadge(panel, item)" class="status-badge"
:style="colorStyle(mapBadge(panel, item).color)">
{{ mapBadge(panel, item).label }}
</span>
<button
v-if="rowAction(panel, item)"
type="button"
class="btn btn-sm pap-row-action"
:disabled="actionBusy[panel.id]"
@click="runRowAction(panel, item)"
>{{ rowAction(panel, item).toggleLabel }}</button>
</div>
<div v-if="mapMeta(panel, item).length" class="pap-meta">
<span v-for="(m, mi) in mapMeta(panel, item)" :key="mi"
:class="{ mono: m.mono }">{{ m.text }}</span>
</div>
<div v-if="isBodyOpen(panel, index) && mapBody(panel, item)" class="pap-body">
{{ mapBody(panel, item) }}
</div>
</div>
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
{{ panel.manage.label || 'Manage' }}
</router-link>
<div v-if="actionError[panel.id]" class="pap-action-error">
{{ actionError[panel.id] }}
</div>
</template>
<div v-else-if="rows(panel).length" class="pap-empty">
Nothing matches that filter. {{ rows(panel).length }} row(s) hidden.
</div>
<div v-else class="pap-empty">
<span class="muted">{{ panel.empty || 'Nothing to show.' }}</span>
<router-link v-if="panel.manage" :to="manageLink(panel, assetid)" class="pap-manage">
{{ panel.manage.emptylabel || panel.manage.label || 'Add' }}
</router-link>
</div>
</div>
<!-- tabs: several label/value sections, one visible at a time. Keeps a
panel with many fields to the height of its largest section - a
30-row card cannot be split by the two-column multicol layout and
drags one column far past the other. -->
<div v-else-if="panel.render === 'tabs'" class="pap-tabs">
<div class="pap-tabstrip" role="tablist">
<button
v-for="(section, si) in tabSections(panel)"
:key="si"
type="button"
role="tab"
class="pap-tab"
:class="{ on: si === activeTab(panel) }"
:aria-selected="si === activeTab(panel)"
@click="setTab(panel, si)"
>{{ section.label }}</button>
</div>
<!-- A section carrying columns renders as a table; otherwise it is the
original label/value grid. Both shapes coexist in one panel. -->
<div
v-if="((tabSections(panel)[activeTab(panel)] || {}).columns || []).length"
class="pap-table-wrap"
>
<table class="pap-table">
<thead>
<tr>
<th
v-for="col in (tabSections(panel)[activeTab(panel)] || {}).columns"
:key="col.key"
>{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, ri) in (tabSections(panel)[activeTab(panel)] || {}).rows || []"
:key="ri"
>
<td
v-for="col in (tabSections(panel)[activeTab(panel)] || {}).columns"
:key="col.key"
>{{ cell(row, col) }}</td>
</tr>
</tbody>
</table>
<div
v-if="!((tabSections(panel)[activeTab(panel)] || {}).rows || []).length"
class="pap-empty"
>{{ (tabSections(panel)[activeTab(panel)] || {}).empty || 'Nothing reported.' }}</div>
</div>
<div v-else class="pap-kv">
<div
v-for="(field, fi) in (tabSections(panel)[activeTab(panel)] || {}).fields || []"
:key="fi"
class="pap-kv-row"
>
<span class="pap-kv-label">{{ field.label }}</span>
<span class="pap-kv-value" :class="{ mono: field.mono }">{{ field.value }}</span>
</div>
</div>
</div>
<!-- keyvalue: a label/value grid -->
<div v-else-if="panel.render === 'keyvalue'" class="pap-kv">
<div v-for="(field, fi) in keyvalueFields(panel)" :key="fi" class="pap-kv-row">
<span class="pap-kv-label">{{ field.label }}</span>
<span class="pap-kv-value" :class="{ mono: field.mono }">{{ field.value }}</span>
</div>
</div>
<!-- table: columns declared by the panel (or inferred from row keys) -->
<div v-else-if="panel.render === 'table'" class="pap-table-wrap">
<table class="pap-table">
<thead>
<tr><th v-for="col in tableColumns(panel)" :key="col.key">{{ col.label }}</th></tr>
</thead>
<tbody>
<tr v-for="(row, ri) in rows(panel)" :key="ri">
<td v-for="col in tableColumns(panel)" :key="col.key">{{ cell(row, col) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- badge: a row of colored badges -->
<div v-else-if="panel.render === 'badge'" class="pap-badges">
<span v-for="(b, bi) in badges(panel)" :key="bi" class="status-badge"
:style="colorStyle(b.color)">{{ b.label }}</span>
</div>
<!-- operatorreports: a thumbnail grid of operator-submitted reports; a
card expands to a lightbox with the full screenshot and note, and
left/right arrows cycle through the reports. -->
<div v-else-if="panel.render === 'operatorreports'" class="pap-reports">
<template v-if="rows(panel).length">
<div class="pap-report-grid">
<button
v-for="(report, ri) in rows(panel)"
:key="report.operatorreportid"
type="button"
class="pap-report-card"
@click="openReport(panel, ri)"
>
<div class="pap-report-thumb">
<img
v-if="report.hasimage"
:src="reportImageUrl(panel, report)"
:alt="'Screenshot for report ' + report.operatorreportid"
loading="lazy"
/>
<span v-else class="pap-report-noimage">No screenshot</span>
</div>
<div class="pap-report-preview">
<span class="pap-report-text">{{ report.details || '(no note)' }}</span>
<span class="pap-report-when">{{ formatDate(report.createdat) }}</span>
</div>
</button>
</div>
</template>
<div v-else class="pap-empty">
<span class="muted">{{ panel.empty || 'No operator reports.' }}</span>
</div>
</div>
</div>
</div>
</template>
<!-- Lightbox: full screenshot and note for the open report, with left/right
cycling. Rendered once outside the panel loop so the overlay covers the
page rather than a single card. -->
<div
v-if="lightbox.panel"
ref="lightboxEl"
class="pap-lightbox"
role="dialog"
aria-modal="true"
tabindex="-1"
@click.self="closeReport"
@keydown.esc="closeReport"
@keydown.left="cycleReport(-1)"
@keydown.right="cycleReport(1)"
>
<button type="button" class="pap-lb-close" aria-label="Close" @click="closeReport">&times;</button>
<button
type="button"
class="pap-lb-nav pap-lb-prev"
aria-label="Previous report"
:disabled="rows(lightbox.panel).length < 2"
@click.stop="cycleReport(-1)"
>&#8249;</button>
<div class="pap-lb-body">
<div class="pap-lb-image">
<img
v-if="openReportRow && openReportRow.hasimage"
:src="reportImageUrl(lightbox.panel, openReportRow)"
:alt="'Screenshot for report ' + openReportRow.operatorreportid"
/>
<span v-else class="pap-report-noimage">No screenshot</span>
</div>
<div v-if="openReportRow" class="pap-lb-details">
<div class="pap-lb-meta">
<span>{{ openReportRow.reason }}</span>
<span>{{ formatDate(openReportRow.createdat) }}</span>
<span v-if="openReportRow.reportedhostname" class="mono">{{ openReportRow.reportedhostname }}</span>
</div>
<p class="pap-lb-note">{{ openReportRow.details || '(no note)' }}</p>
<div class="pap-lb-count">
{{ lightbox.index + 1 }} / {{ rows(lightbox.panel).length }}
</div>
</div>
</div>
<button
type="button"
class="pap-lb-nav pap-lb-next"
aria-label="Next report"
:disabled="rows(lightbox.panel).length < 2"
@click.stop="cycleReport(1)"
>&#8250;</button>
</div>
</template>
<script setup>
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import api from '../api'
import { colorStyle } from '@/utils/colorStyle'
import {
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, mapBody, manageLink,
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
rowAction, facets, facetedRows, formatValue, spansFullWidth,
} from './pluginAssetPanels'
// Operator report screenshots are served by a JWT-gated route, so a bare
// <img src> (which sends no Authorization header) would 401. Fetch each image
// as a blob through the api instance and hand the <img> an object URL instead.
// Cache by report id so cycling the lightbox does not refetch, and revoke on
// unmount so the blobs are not leaked.
const reportImageUrls = ref({})
function formatDate(value) {
return formatValue(value, { format: 'date' })
}
// Called from the template, so it runs on EVERY render. Without the seen-set it
// re-fired the GET for a report whose fetch was still in flight, and because
// each resolution reassigns reportImageUrls the whole grid re-rendered and
// re-fired the rest - N thumbnails cost up to N-squared requests on first load.
// Two blobs resolving for one key also orphaned the first object URL, which
// revokeReportImageUrls could not free because only the surviving one was in
// the map. A failure was never recorded either, so a broken image retried for
// the life of the page.
//
// requestedReportImages tracks "we have asked", separately from the map of
// resolved URLs, and is cleared alongside it.
const requestedReportImages = new Set()
function reportImageUrl(panel, report) {
const key = report.operatorreportid
if (reportImageUrls.value[key]) return reportImageUrls.value[key]
if (requestedReportImages.has(key)) return ''
requestedReportImages.add(key)
const base = panel.imagebase || '/api/endpoints/operatorreports'
const path = toApiPath(base + '/' + key + '/image', props.assetid)
api.get(path, { responseType: 'blob' })
.then((response) => {
const url = URL.createObjectURL(response.data)
// Another render may have resolved first; keep one URL per key and free
// the loser rather than orphaning it.
if (reportImageUrls.value[key]) {
try { URL.revokeObjectURL(url) } catch { /* already revoked */ }
return
}
reportImageUrls.value = { ...reportImageUrls.value, [key]: url }
})
.catch(() => { /* stays in requestedReportImages: do not retry every render */ })
return ''
}
// Release every cached object URL. Called on reload and unmount so a long-lived
// detail page does not leak blob handles as the operator revisits PCs.
function revokeReportImageUrls() {
Object.values(reportImageUrls.value).forEach((url) => {
try { URL.revokeObjectURL(url) } catch { /* already revoked */ }
})
reportImageUrls.value = {}
requestedReportImages.clear()
}
onUnmounted(revokeReportImageUrls)
// The open report lightbox: which panel and which row index. A null panel
// means closed.
const lightbox = ref({ panel: null, index: 0 })
const openReportRow = computed(() => {
if (!lightbox.value.panel) return null
return rows(lightbox.value.panel)[lightbox.value.index] || null
})
// The overlay owns Escape and the arrow keys, and a keydown handler on a div
// only fires while that div has focus. Nothing moved focus to it, so every
// keyboard binding on the lightbox was dead until the user happened to click
// the backdrop. Focus it once it has rendered, and hand focus back to the
// thumbnail that opened it on close, so keyboard users are not dropped at the
// top of the document.
const lightboxEl = ref(null)
let reopenFocus = null
function openReport(panel, index) {
reopenFocus = typeof document !== 'undefined' ? document.activeElement : null
lightbox.value = { panel, index }
nextTick(() => lightboxEl.value?.focus())
}
function closeReport() {
lightbox.value = { panel: null, index: 0 }
const returnTo = reopenFocus
reopenFocus = null
nextTick(() => returnTo?.focus?.())
}
function cycleReport(delta) {
if (!lightbox.value.panel) return
const total = rows(lightbox.value.panel).length
if (total < 2) return
const next = (lightbox.value.index + delta + total) % total
lightbox.value = { ...lightbox.value, index: next }
}
// Which filter chips are active, per panel id. Deliberately NOT reset when the
// data refreshes: a viewer who filtered to criticals and then hit refresh means
// to still be looking at criticals.
const facetSelection = ref({})
function isFacetOn(panel, value) {
return (facetSelection.value[panel.id] || []).includes(value)
}
function toggleFacet(panel, value) {
const current = facetSelection.value[panel.id] || []
facetSelection.value = {
...facetSelection.value,
[panel.id]: current.includes(value)
? current.filter((v) => v !== value)
: [...current, value],
}
}
function visibleRows(panel) {
return facetedRows(panel, facetSelection.value[panel.id])
}
// Which row bodies are expanded, keyed by panel id and row index. Closed on
// every panel refresh: the row at index 3 after a refresh is not necessarily
// the row that was opened, so keeping it open would expand an unrelated event.
const bodyOpen = ref({})
function bodyKey(panel, index) {
return `${panel.id}:${index}`
}
function isBodyOpen(panel, index) {
return !!bodyOpen.value[bodyKey(panel, index)]
}
function toggleBody(panel, index) {
const key = bodyKey(panel, index)
bodyOpen.value = { ...bodyOpen.value, [key]: !bodyOpen.value[key] }
}
const props = defineProps({
assetid: { type: [Number, String], default: null },
// Which detail-page tab to render. Null renders every panel, which is what
// the detail pages that have not adopted tabs still do - so this stays
// additive and no existing page changes behaviour.
group: { type: String, default: null },
})
const panels = ref([])
// The groups this build renders. A panel in any other group would be fetched,
// held, and shown on no tab at all.
//
// The server normalises group before we see it, so a version-matched pair
// never lands here. A MISMATCHED pair does: deploy a new frontend against a
// server still serving an older vocabulary and every panel in a group that
// has since been renamed or removed silently disappears. That is what happened
// to tracked services when it moved out of 'actions' - the backend still said
// 'actions', this build had no such tab, and the card was simply gone with
// nothing logged. The same applies to the 'metrics' to 'monitors' rename.
// Fall back the same way the server does rather than drop it.
const RENDERED_GROUPS = ['details', 'monitors', 'serviceprocess', 'software', 'activity']
function groupOf(panel) {
return RENDERED_GROUPS.includes(panel.group) ? panel.group : 'details'
}
const groupedPanels = computed(() =>
props.group ? panels.value.filter((p) => groupOf(p) === props.group) : panels.value
)
// Which groups actually have a panel with data, so a parent can hide a tab
// rather than offering an empty one.
const populatedGroups = computed(() => {
const seen = new Set()
panels.value.forEach((p) => { if (panelVisible(p)) seen.add(groupOf(p)) })
return seen
})
// Which tab each 'tabs' panel is showing, keyed by panel id. Kept here rather
// than on the panel object because panels are replaced wholesale on refetch.
const tabs = ref({})
function activeTab(panel) { return tabs.value[panel.id] || 0 }
function setTab(panel, index) { tabs.value = { ...tabs.value, [panel.id]: index } }
// Collapsed state per panel id, kept here for the same reason as tabs: panels
// are replaced wholesale on refetch. A panel may declare collapsedDefault to
// start collapsed (for example a low-priority events card); once the user
// toggles it, their explicit choice (stored by id) wins.
const collapsed = ref({})
function isCollapsed(panel) {
const state = collapsed.value[panel.id]
if (state === undefined) return !!panel.collapsedDefault
return !!state
}
function toggleCollapse(panel) {
collapsed.value = { ...collapsed.value, [panel.id]: !isCollapsed(panel) }
}
// Per-panel action state: busy guards a double-click while an enqueue POST is
// in flight, and the error string surfaces a rejected enqueue (bad action, or
// a 409 when a command is already queued). Keyed by panel id for the same
// reason as tabs/collapsed: panels are replaced wholesale on refetch.
const actionBusy = ref({})
const actionError = ref({})
// Enqueue the inline row toggle's command (Start/Stop for an actionable
// services/processes row, e.g. MTConnect), then reload so the row reflects the
// new state. The agent long-polls, runs it, posts the result, and wakes an
// immediate telemetry re-collect, so the settled run state lands on the next
// panel refresh rather than after the full cadence.
async function runRowAction(panel, item) {
if (actionBusy.value[panel.id]) return
const info = rowAction(panel, item)
if (!info || !info.endpoint || !info.toggle) return
actionBusy.value = { ...actionBusy.value, [panel.id]: true }
actionError.value = { ...actionError.value, [panel.id]: '' }
try {
const body = { action: info.toggle }
if (info.target) body.target = info.target
// Baseline telemetry freshness before queuing so we can tell a genuinely
// new snapshot from the stale cached one after the command settles.
const baseline = await currentTelemetryStamp()
const response = await api.post(toApiPath(info.endpoint, props.assetid), body)
const commandId = response?.data?.data?.endpointcommandid
// The POST only queues the command (201). The agent runs it asynchronously,
// so poll for the terminal result and surface the agent's own message when
// it fails (e.g. a bad launch path), rather than reporting a false success.
const settled = await waitForCommandResult(commandId)
if (settled && settled.status === 'failed') {
actionError.value = {
...actionError.value,
[panel.id]: settled.resultmessage || 'The endpoint agent could not run the command.',
}
} else if (settled && settled.status === 'succeeded') {
// The command ran, but the fresh run state only lands when the agent's
// follow-up telemetry post arrives. Wait for lastreportedat to advance so
// the reloaded row reflects reality instead of the pre-command snapshot.
await waitForFreshTelemetry(baseline)
}
await load()
} catch (err) {
const message = err?.response?.data?.message || 'Command failed to queue.'
actionError.value = { ...actionError.value, [panel.id]: message }
} finally {
actionBusy.value = { ...actionBusy.value, [panel.id]: false }
}
}
// Poll the endpoint's recent commands until the given command reaches a terminal
// state (succeeded/failed) or the attempt budget runs out. Returns the command
// row or null. Uses the same commands endpoint the operator view reads.
async function waitForCommandResult(commandId, { attempts = 15, delayMs = 1000 } = {}) {
if (!commandId || !props.assetid) return null
for (let i = 0; i < attempts; i += 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
try {
const response = await api.get(`/endpoints/${props.assetid}/commands`)
const command = (response?.data?.data || []).find(
(row) => row.endpointcommandid === commandId)
if (command && (command.status === 'succeeded' || command.status === 'failed')) {
return command
}
} catch {
// Transient poll failure: keep trying until the attempt budget runs out.
}
}
return null
}
// Read the endpoint's current lastreportedat (ISO string or null) so a row
// action can baseline freshness before it forces a state change.
async function currentTelemetryStamp() {
if (!props.assetid) return null
try {
const response = await api.get(`/endpoints/asset/${props.assetid}/telemetrymeta`)
return response?.data?.data?.lastreportedat || null
} catch {
return null
}
}
// Poll lastreportedat until it advances past the baseline (proof the agent has
// posted a fresh snapshot reflecting the command) or the attempt budget runs
// out. Bounded so an idle/offline agent never hangs the row button; the caller
// reloads regardless once this returns.
async function waitForFreshTelemetry(baseline, { attempts = 15, delayMs = 1000 } = {}) {
const baseMs = baseline ? Date.parse(baseline) : 0
for (let i = 0; i < attempts; i += 1) {
await new Promise((resolve) => setTimeout(resolve, delayMs))
const stamp = await currentTelemetryStamp()
if (stamp && Date.parse(stamp) > baseMs) return true
}
return false
}
// Fetch the declared panels for this asset, then each panel's data endpoint.
async function load() {
panels.value = []
revokeReportImageUrls()
closeReport()
bodyOpen.value = {}
if (!props.assetid) return
let declared
try {
const response = await api.get('/pluginui/asset-panels', {
params: { assetid: props.assetid },
})
declared = response.data.data || []
} catch (err) {
return
}
const withData = await Promise.all(
declared.map(async (panel) => {
try {
const response = await api.get(toApiPath(panel.endpoint, props.assetid))
return { ...panel, _data: response.data.data }
} catch (err) {
return { ...panel, _data: null }
}
})
)
panels.value = withData
}
watch(() => props.assetid, load, { immediate: true })
// Let a parent detail page (for example PCDetail's header Refresh button)
// trigger a panel reload without duplicating the fetch plumbing.
defineExpose({ refresh: load, populatedGroups })
</script>
<style scoped>
.pap-list { display: flex; flex-direction: column; gap: 0.75rem; }
.pap-item { padding: 0.6rem 0.75rem; background: var(--bg); border-radius: 6px; }
.pap-item-top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.pap-title { font-weight: 600; color: var(--text); }
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.pap-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
/* The badge doubles as the expander, so it needs to look pressable without
becoming a second style of badge: same pill, just a pointer and a hover
lift. margin-left:auto pins it right even on a row that also has an
action button, where space-between alone would centre it. */
.pap-badge-toggle {
margin-left: auto;
border: none;
cursor: pointer;
font-family: inherit;
line-height: inherit;
}
.pap-badge-toggle:hover { filter: brightness(1.1); }
.pap-badge-toggle:focus-visible { outline: 2px solid var(--primary, #4181ff); outline-offset: 2px; }
/* pre-wrap, not normal: the message keeps the line breaks the event was
written with, which is what makes a multi-line fault readable. */
.pap-body {
margin-top: 0.5rem;
padding: 0.5rem 0.6rem;
background: var(--surface-2, rgba(127, 127, 127, 0.08));
border-radius: 4px;
font-size: 0.82rem;
color: var(--text-light);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.pap-empty { display: flex; align-items: center; gap: 0.6rem; }
.pap-facets {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.6rem;
}
.pap-facet {
font: inherit;
font-size: 0.78rem;
line-height: 1.6;
padding: 0.05rem 0.5rem;
border: 1px solid var(--border);
border-radius: 10px;
background: transparent;
color: var(--text-light);
cursor: pointer;
}
.pap-facet:hover { border-color: var(--primary); }
.pap-facet.on { border-color: var(--primary); color: var(--primary); }
.pap-facet-clear { border-style: dashed; }
/* The count is the point: a red dot over a list of info rows is confusing
until the chip says "Critical 2" and one click shows them. */
.pap-facet-count { font-variant-numeric: tabular-nums; opacity: 0.75; }
.pap-manage { font-size: 0.82rem; }
/* Two aligned columns rather than space-between: a ragged right edge is hard
to scan once a panel carries more than a handful of rows, and a long value
(a UNC path) otherwise pushes its label away from the others. */
.pap-kv { display: grid; grid-template-columns: minmax(7rem, auto) 1fr; gap: 0.35rem 1rem; align-items: baseline; }
.pap-kv-row { display: contents; }
.pap-kv-label { color: var(--text-light); }
.pap-kv-value { color: var(--text); font-weight: 500; overflow-wrap: anywhere; }
/* Tab strip: a quiet segmented control, not browser-default buttons. The
active tab is marked by an underline AND weight, so it does not rely on
colour alone. */
.pap-tabstrip {
display: flex;
flex-wrap: wrap;
gap: 0.15rem;
margin-bottom: 0.85rem;
border-bottom: 1px solid var(--border);
}
.pap-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
padding: 0.35rem 0.7rem;
color: var(--text-light);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
white-space: nowrap;
}
.pap-tab:hover { color: var(--text); }
.pap-tab.on {
color: var(--primary);
border-bottom-color: var(--primary);
font-weight: 700;
}
/* A table panel SPANS BOTH COLUMNS rather than scrolling inside one.
Network interfaces is the case that forced this: adapter, address, MAC,
speed and state at half page width either squeezed every cell to three
lines or handed the reader a horizontal scrollbar, and a scrollbar for
data that simply needs room is a layout failure, not a feature. The parent
.content-grid is a CSS grid, so grid-column: 1 / -1 makes the panel span
every track while the short fact cards stay two-up around it.
The wrapper keeps overflow-x as a floor for the genuinely too-wide case on
a narrow viewport, where nothing else can help. */
.pap-span { grid-column: 1 / -1; }
.pap-table-wrap { overflow-x: auto; max-width: 100%; }
.pap-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.pap-table th, .pap-table td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--border); }
.pap-badges { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.pap-row-action { margin-left: 0.5rem; flex: 0 0 auto; }
.pap-action-error { color: var(--danger, #f5365c); font-size: 0.82rem; }
.pap-header {
display: flex; align-items: center; gap: 0.5rem;
cursor: pointer; user-select: none;
}
.pap-header:hover .pap-header-title { color: var(--primary); }
.pap-header-title { flex: 1 1 auto; }
.pap-caret {
font-size: 0.7rem; color: var(--text-light);
transition: transform 0.15s ease;
}
.pap-caret.collapsed { transform: rotate(-90deg); }
.pap-status-dot {
flex: 0 0 auto; width: 0.7rem; height: 0.7rem; border-radius: 50%;
}
.mono { font-family: monospace; }
.muted { color: var(--text-light); }
/* Operator reports: a responsive thumbnail grid, each card a screenshot over a
one-line note preview. Clicking a card opens the lightbox. */
.pap-report-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: 0.75rem;
}
.pap-report-card {
display: flex;
flex-direction: column;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
cursor: pointer;
overflow: hidden;
text-align: left;
}
.pap-report-card:hover { border-color: var(--primary); }
.pap-report-thumb {
aspect-ratio: 16 / 10;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
overflow: hidden;
}
.pap-report-thumb img { width: 100%; height: 100%; object-fit: cover; }
.pap-report-noimage { font-size: 0.78rem; color: var(--text-light); }
.pap-report-preview {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.45rem 0.55rem;
}
.pap-report-text {
font-size: 0.82rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pap-report-when { font-size: 0.72rem; color: var(--text-light); }
/* Lightbox: a page-covering overlay with the full screenshot, the note, and
left/right cycling controls. */
.pap-lightbox {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
background: rgba(0, 0, 0, 0.8);
}
.pap-lb-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 90vw;
max-height: 90vh;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
overflow: auto;
}
.pap-lb-image {
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.pap-lb-image img { max-width: 100%; max-height: 70vh; object-fit: contain; }
.pap-lb-details { display: flex; flex-direction: column; gap: 0.4rem; }
.pap-lb-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.8rem;
color: var(--text-light);
}
.pap-lb-note { margin: 0; color: var(--text); white-space: pre-wrap; overflow-wrap: anywhere; }
.pap-lb-count { font-size: 0.78rem; color: var(--text-light); font-variant-numeric: tabular-nums; }
.pap-lb-close {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
color: #fff;
font-size: 1.8rem;
line-height: 1;
cursor: pointer;
}
.pap-lb-nav {
flex: 0 0 auto;
background: rgba(255, 255, 255, 0.1);
border: none;
color: #fff;
font-size: 2rem;
line-height: 1;
padding: 0.5rem 0.9rem;
border-radius: 6px;
cursor: pointer;
}
.pap-lb-nav:hover:not(:disabled) { background: rgba(255, 255, 255, 0.25); }
.pap-lb-nav:disabled { opacity: 0.3; cursor: default; }
</style>