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:
@@ -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 } })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">×</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 } 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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user