Import the wilm endpoint-monitor and channel-distribution work

Everything on origin/wilm that is not on the published main, applied as one
change because the published history has rewritten hashes: per-commit
cherry-pick conflicts against our main even though the squashed diff applies
clean.

Contents: endpoint monitor catalog + policy attachment + warn-only drift
reconciler, operator issue reports from the tray, agent launch-path allowlist,
and the GHCR channel-image distribution path for dev workstations.

NOT READY FOR main. Open items:
  - 10 new routes are absent from docs/api-inventory.json, so
    test_api_inventory_parity fails. That gate SKIPS on the published mirror
    (the test ships, its data file is scrubbed), which is why wilm never saw it.
  - dev-bootstrap.ps1 and Start-ShopDBDev.ps1 hardcode the ge-aero GitHub org.
  - plugins/endpoints/plugin.py carries a whitespace-only re-indent.

Scope: product
This commit is contained in:
cproudlock
2026-09-02 10:05:44 -04:00
parent 1480b30e8c
commit 27b2817fd7
76 changed files with 5930 additions and 173 deletions

View File

@@ -178,6 +178,25 @@ export const endpointsApi = {
},
telemetryMeta(assetId) {
return api.get(`/endpoints/asset/${assetId}/telemetrymeta`)
},
// Monitor catalog: reusable process/service definitions. Shipped rows are
// product-owned and read-only; local rows are user-built on the Settings page.
monitors: {
list() {
return api.get('/endpoints/monitors')
},
create(data) {
return api.post('/endpoints/monitors', data)
},
update(monitorid, data) {
return api.put(`/endpoints/monitors/${monitorid}`, data)
},
remove(monitorid) {
return api.delete(`/endpoints/monitors/${monitorid}`)
},
resolve(pctype) {
return api.get('/endpoints/monitors/resolve', { params: { pctype } })
}
}
}

View File

@@ -1068,17 +1068,28 @@ td.actions .btn + .btn {
}
/* Content Grid */
/* Balanced two-column card flow. Uses CSS multicol (not a hand-assigned
grid) so cards distribute by height and the columns stay even no matter
how many cards land on either side. display:contents flattens the two
.content-column wrappers so their cards flow directly into the columns,
which keeps the existing markup unchanged. */
/* Two-column card layout on an explicit CSS grid rather than multicol.
Multicol was abandoned because a child using column-span: all (the full
width Network Interfaces panel) overlaps the shorter column above it when
the two columns have unequal heights - a known multicol spanning bug. Grid
has no such flaw: short fact cards sit two-up and a full-width panel spans
both tracks cleanly via .pap-span. display:contents flattens the two
.content-column wrappers so their cards become direct grid items, which
keeps the existing markup unchanged. */
.content-grid {
column-count: 2;
column-gap: 25px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 25px;
align-items: start;
margin-bottom: 25px;
}
/* Grid supplies the gap; the per-card margin was a multicol-only crutch and
would otherwise double the vertical spacing. */
.content-grid > .section-card {
margin-bottom: 0;
}
.content-column {
display: contents;
}
@@ -1522,7 +1533,7 @@ td.actions .btn + .btn {
}
.content-grid {
column-count: 1;
grid-template-columns: 1fr;
}
.audit-footer {

View File

@@ -143,21 +143,170 @@
<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"
class="pap-lightbox"
role="dialog"
aria-modal="true"
tabindex="0"
@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 } from 'vue'
import { ref, computed, watch, onUnmounted } 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' })
}
function reportImageUrl(panel, report) {
const key = report.operatorreportid
if (reportImageUrls.value[key]) return reportImageUrls.value[key]
const base = panel.imagebase || '/api/endpoints/operatorreports'
const path = toApiPath(base + '/' + key + '/image', props.assetid)
api.get(path, { responseType: 'blob' })
.then((response) => {
reportImageUrls.value = {
...reportImageUrls.value,
[key]: URL.createObjectURL(response.data),
}
})
.catch(() => {})
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 = {}
}
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
})
function openReport(panel, index) {
lightbox.value = { panel, index }
}
function closeReport() {
lightbox.value = { panel: null, index: 0 }
}
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 +488,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 +598,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 +627,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-alt, #1b1b1b);
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>

View File

@@ -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
}

View File

@@ -37,6 +37,14 @@ export async function getFacilityName() {
return getSetting('facility_name', 'ShopDB')
}
// Environment channel label (e.g. 'DEV') injected by the backend from
// SHOPDB_ENV_LABEL. Empty on stable/main deployments, which renders no badge.
// Drives the red DEV tag in the sidebar so a bleeding-edge container is
// obviously distinct from a stable one running side by side.
export async function getEnvLabel() {
return getSetting('env_label', '')
}
// Main site logo (sidebar, login, dashboard header). Fallback = shipped GE mark.
export async function getSiteLogo() {
return withBase(await getSetting('site_logo', '/ge-aerospace-logo.svg'))

View File

@@ -4,6 +4,9 @@
<div class="sidebar-header">
<img :src="siteLogo" alt="Site logo" class="sidebar-logo" />
<h1>{{ facilityName }}</h1>
<!-- Only present when the container sets SHOPDB_ENV_LABEL (edge/dev
channel). Stable leaves it empty, so nothing renders. --><span
v-if="envLabel" class="env-badge">{{ envLabel }}</span>
<!-- Under the site name, where the classic site put it. -->
<div class="fiscal-week">
<span class="fiscal-week-label">Fiscal Week</span>
@@ -122,7 +125,7 @@ import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme'
import { dashboardApi, notificationsApi } from '../api'
import { fiscalWeek } from '@/utils/fiscalWeek'
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
import { getFacilityName, getSiteLogo, getServicenowUrls, getEnvLabel } from '../utils/siteSettings'
import { withBase } from '../utils/basePath'
const router = useRouter()
@@ -155,6 +158,7 @@ const cansearch = ref(false)
const navItems = ref([])
const activeNotifications = ref([])
const facilityName = ref('ShopDB')
const envLabel = ref('')
// A tab left open overnight would otherwise show last week's number until
// somebody reloaded. Recomputed on a timer rather than only at mount.
@@ -286,6 +290,7 @@ onMounted(async () => {
fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000)
getFacilityName().then(name => { facilityName.value = name })
getEnvLabel().then(label => { envLabel.value = label })
getSiteLogo().then(logo => { siteLogo.value = logo })
getServicenowUrls().then(config => { servicenowConfig.value = config })
@@ -352,6 +357,21 @@ onUnmounted(() => {
</script>
<style scoped>
/* Bleeding-edge channel badge. Loud red on purpose so a dev container is never
mistaken for stable when the two run side by side. Only rendered when the
backend reports a non-empty env_label (SHOPDB_ENV_LABEL). */
.env-badge {
display: inline-block;
margin-top: 0.35rem;
padding: 0.1rem 0.5rem;
border-radius: 3px;
background: #c62828;
color: #fff;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
/* Fiscal week, under the site name, where the classic site put it. The number
carries the weight; the label is small because it never changes. */
.fiscal-week {