Merge the wilm work, act on the five-agent review, verify the launch-path policy on Windows
This commit is contained in:
@@ -143,21 +143,205 @@
|
||||
<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">×</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)"
|
||||
>‹</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)"
|
||||
>›</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import api from '../api'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import {
|
||||
toApiPath, rows, panelVisible, mapTitle, mapBadge, mapMeta, manageLink,
|
||||
keyvalueFields, tableColumns, cell, badges, tabSections, rollupStatus,
|
||||
rowAction, facets, facetedRows,
|
||||
rowAction, facets, facetedRows, formatValue,
|
||||
} 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.
|
||||
@@ -339,6 +523,8 @@ async function waitForFreshTelemetry(baseline, { attempts = 15, delayMs = 1000 }
|
||||
// Fetch the declared panels for this asset, then each panel's data endpoint.
|
||||
async function load() {
|
||||
panels.value = []
|
||||
revokeReportImageUrls()
|
||||
closeReport()
|
||||
if (!props.assetid) return
|
||||
let declared
|
||||
try {
|
||||
@@ -447,13 +633,13 @@ defineExpose({ refresh: load, populatedGroups })
|
||||
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. column-span
|
||||
is the multi-column layout's own answer, so the short fact cards stay two-up
|
||||
around it.
|
||||
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 { column-span: all; }
|
||||
.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); }
|
||||
@@ -476,4 +662,116 @@ defineExpose({ refresh: load, populatedGroups })
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -33,6 +33,7 @@ export function rows(panel) {
|
||||
if (Array.isArray(data)) list = data
|
||||
else if (data && Array.isArray(data.rows)) list = data.rows
|
||||
else if (data && Array.isArray(data.items)) list = data.items
|
||||
else if (data && Array.isArray(data.reports)) list = data.reports
|
||||
else return []
|
||||
// Optional panel-level row filter so two panels can share one endpoint feed
|
||||
// and each show a slice of it (for example Tracked Services vs Tracked
|
||||
@@ -258,5 +259,6 @@ export function panelVisible(panel) {
|
||||
if (panel.render === 'keyvalue') return keyvalueFields(panel).length > 0
|
||||
if (panel.render === 'table') return rows(panel).length > 0 || !!panel.empty
|
||||
if (panel.render === 'badge') return badges(panel).length > 0
|
||||
if (panel.render === 'operatorreports') return rows(panel).length > 0 || !!panel.empty
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user