PC Detail: flow Details cards, drop the IP Configuration card

Details tab card layout
-----------------------
The Details tab sat on .content-grid, a two-column CSS grid. Grid packs
items into implicit rows, so every row is as tall as its tallest card and
align-items:start leaves the shorter one with dead space beneath it:
Warranty is short, Endpoint Status is tall, and Policy had to wait for the
taller of the two rather than sliding up. That read as a staggered layout.

Switched that one tab to multicol via a .details-flow modifier, which has no
rows at all - each card starts where the previous one ended, so the holes
close while cards still size to their content.

Multicol is deliberately NOT applied to .content-grid globally. The note by
that rule in style.css records why it was abandoned there: a column-span:all
child (.pap-span, used by table panels) overlaps the shorter column when the
two are unequal. That cannot happen on this tab - every panel in the details
group is a keyvalue or list render, and spansFullWidth() only returns true
for table (or a tabs panel holding one), so nothing in the subtree spans.
Monitors and Service/Process DO carry table panels and stay on grid.

The modifier is scoped to PCDetail, so the nine other detail views on
.content-grid are untouched.

Chosen over JS masonry on purpose. Masonry packs perfectly but has to
measure every card and re-measure on resize, on panel collapse and on every
async panel load - and these panels fetch after paint, so it would mean
continuous forced reflow on a page that already polls. This is pure CSS:
no measurement, no observer, no runtime cost. The tradeoff accepted is that
column bottoms can end at slightly different heights.

Cards carry break-inside: avoid so none fragments across the column break,
and the 25px spacing returns as a margin because multicol has no gap between
stacked items the way grid does.

IP Configuration card
---------------------
Removed: the data has no purpose on the page anymore. Taken out at the root
rather than hidden - the panel declaration, the /asset/<id>/ipconfig route
and the _IPCONFIG_COLUMNS constant all go, since a route with no consumer is
dead surface that still answers requests.

test_the_two_cards_do_not_duplicate_columns_beyond_the_address existed only
to police the history/config column split; with one card gone it guards
nothing, so it and its import go with it. The panel-count assertion in
test_endpoints_asset_panels_are_computer_scoped moves 15 -> 14, which is the
test doing its job and catching the removal.

IP History is unchanged and still carries the address list.

Not addressed here: three tzset() tests in test_endpoints_plugin.py fail on
Windows because time.tzset() is Unix-only. Verified pre-existing against
unmodified code; they pass in the Linux container.
This commit is contained in:
benjamin.baker
2026-09-05 15:26:11 -04:00
parent 86985efcf2
commit ba38e4d39d
9 changed files with 298 additions and 138 deletions

View File

@@ -39,6 +39,7 @@
long for a narrow column and it wrapped into a tall ragged
stack. Full width in the normal flow costs one short row. -->
<div class="hero-primary">
<div class="hero-headline">
<div class="hero-identity">
<div class="hero-title">
<h1>{{ computer.assetnumber }}</h1>
@@ -70,6 +71,27 @@
</div>
</div>
<!-- Liveness and the endpoint actions, pulled out of the triage
band into the header corner. They are the two things looked at
first on a PC that is misbehaving, and at the bottom of the
hero they sat below the fold on a laptop. The band keeps the
reference facts (serves machine, remote access, location). -->
<div class="hero-endpoint">
<div v-if="liveness" class="hero-status">
<span class="hero-detail-label hero-status-label">Status</span>
<span class="triage-chip" :class="liveness.level">
<span class="triage-dot"></span>{{ liveness.label }}
</span>
</div>
<div v-if="canCommand" class="hero-endpoint-actions">
<button @click="refreshStatus" class="btn btn-secondary btn-sm"
:disabled="restarting || refreshing">
{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}
</button>
</div>
</div>
</div>
<!-- Cloud identity. Laid out as a horizontal row in the same
label-over-value language as hero-details, not as a narrow
right rail: a 36-character GUID cannot wrap sanely into a
@@ -96,12 +118,13 @@
</span>
<span v-if="computer.computer?.azuredeviceid" class="hero-portal-idrow">
<span class="hero-portal-id mono">{{ computer.computer.azuredeviceid }}</span>
<button
<button
type="button"
class="copy-btn"
title="Copy Device ID"
aria-label="Copy Device ID"
@click="copyId(computer.computer.azuredeviceid, 'Device ID')"
>Copy</button>
><Copy :size="14" aria-hidden="true" /></button>
</span>
<span v-else class="hero-portal-missing">Please provide DeviceID</span>
</div>
@@ -115,15 +138,24 @@
rel="noopener noreferrer"
class="portal-link"
>Entra</a>
<a
v-if="lapsDeviceLink"
:href="lapsDeviceLink"
target="_blank"
rel="noopener noreferrer"
class="portal-link"
title="Local administrator password (LAPS)"
>Azure LAPS</a>
</span>
<span v-if="computer.computer?.azureobjectid" class="hero-portal-idrow">
<span class="hero-portal-id mono">{{ computer.computer.azureobjectid }}</span>
<button
<button
type="button"
class="copy-btn"
title="Copy Object ID"
aria-label="Copy Object ID"
@click="copyId(computer.computer.azureobjectid, 'Object ID')"
>Copy</button>
><Copy :size="14" aria-hidden="true" /></button>
</span>
<span v-else class="hero-portal-missing">Please provide ObjectID</span>
</div>
@@ -138,17 +170,16 @@
it. "Serves machine" used to sit in the relationships card near
the bottom of the page, which is the wrong place for the single
most useful fact about a shop-floor PC. -->
<!-- Liveness and Refresh Status moved to .hero-endpoint in the header
corner. Restart stays here: it is destructive and takes the
machine down with it, so it belongs beside the "serves machine"
fact that says what stops, not next to a read-only refresh. -->
<AssetTriageBand
:assetid="computer.assetid"
:liveness="liveness"
:accessmethods="computer.accessmethods || []"
>
<template #actions>
<button v-if="canCommand" @click="refreshStatus" class="btn btn-secondary btn-sm"
:disabled="restarting || refreshing">
{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}
</button>
<button v-if="canRestart" @click="confirmRestart" class="btn btn-warning btn-sm"
<template #actions v-if="canRestart">
<button @click="confirmRestart" class="btn btn-warning btn-sm"
:disabled="restarting">Restart Computer</button>
</template>
</AssetTriageBand>
@@ -181,7 +212,7 @@
/>
</div>
<div v-show="activegroup === 'details'" class="content-grid">
<div v-show="activegroup === 'details'" class="content-grid details-flow">
<PluginAssetPanels
ref="assetPanels"
:assetid="computer.assetid"
@@ -271,31 +302,13 @@
now: it is what a responder clicks first, and rendering it twice
made the page longer without telling anyone anything. -->
<!-- Status / Check-in, from the COMPUTER RECORD (the asset-report
collector), which is a different source from the endpoints
agent's live telemetry panel. Shown only when that record holds
something: on a PC with an agent these fields sit empty while
the endpoint panel is fully populated, so an always-on card
reported "Last Reported: Never" next to live telemetry saying
otherwise. Two sources disagreeing in public is worse than one
source missing. -->
<div v-if="hasRecordCheckin" class="section-card">
<h3 class="section-title">Status</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Logged In User</span>
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Reported</span>
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Boot</span>
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
</div>
</div>
</div>
<!-- The record-checkin Status card lived here (Logged In User, Last
Reported, Last Boot), sourced from the asset-report collector
rather than the agent. It restated what the Endpoint status panel
already shows from live telemetry, and the two sources phrase the
same facts differently, so the page appeared to disagree with
itself. The panel is the better source: it is populated by the
agent and carries uptime, versions and drift alongside. -->
<!-- The Entra Device Info card lived here. Its links moved to the
header's right-hand block, which was dead space at every width,
@@ -425,7 +438,7 @@
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router'
import { computersApi, applicationsApi, endpointsApi } from '@/api'
@@ -438,6 +451,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetDetailTabs from '@/components/AssetDetailTabs.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { Copy } from 'lucide-vue-next'
import { getEntraDeviceUrls } from '@/utils/siteSettings'
import { setRecordTitle } from '@/composables/pageTitle'
import { useToast } from '@/composables/toast'
@@ -465,13 +479,6 @@ const computer = ref(null)
// shown, which is better than rendering "unknown" and implying a fault.
const liveness = ref(null)
// The computer record's own check-in fields, as opposed to live agent
// telemetry. Empty on an agent-managed PC, populated where the asset-report
// script runs instead.
const hasRecordCheckin = computed(() => {
const record = computer.value?.computer || {}
return Boolean(record.loggedinuser || record.lastreporteddate || record.lastboottime)
})
const installedApps = ref([])
const assetPanels = ref(null)
@@ -485,12 +492,46 @@ const activegroup = ref('details')
// blade paths, so guessing one would send people to a dead page.
const entraUrlTemplate = ref('')
const intuneUrlTemplate = ref('')
const lapsUrlTemplate = ref('')
// The templates arrive from an async settings fetch. Until that settles the
// block stays hidden: rendering the ids first and popping the Intune/Entra
// buttons in a moment later reads as the buttons being missing, which is
// exactly what it looked like before a manual refresh warmed the cache.
const portalTemplatesLoaded = ref(false)
// The settings util negative-caches a failed load for 30s, so an immediate
// retry would just re-read the same empty result. Wait past that window.
const PORTAL_RETRY_MS = 31000
let portalRetryTimer = null
async function loadPortalTemplates({ allowRetry = true } = {}) {
try {
const { entraUrl, intuneUrl, lapsUrl, loaded } = await getEntraDeviceUrls()
if (!loaded && allowRetry) {
// Settings did not load. Leave the block hidden rather than paint a
// wrong "not configured" state, and try again once.
portalRetryTimer = setTimeout(() => {
portalRetryTimer = null
loadPortalTemplates({ allowRetry: false })
}, PORTAL_RETRY_MS)
return
}
entraUrlTemplate.value = entraUrl
intuneUrlTemplate.value = intuneUrl
lapsUrlTemplate.value = lapsUrl
} catch (err) {
// Ignore: templates stay empty and the block falls back to whatever else
// it has rather than hiding forever.
} finally {
// Only settle once the answer is trustworthy, or after the retry is spent.
if (!portalRetryTimer) portalTemplatesLoaded.value = true
}
}
onUnmounted(() => {
if (portalRetryTimer) clearTimeout(portalRetryTimer)
})
function fillTemplate(template, token, value) {
if (!template || !value) return null
return template.split(token).join(encodeURIComponent(value))
@@ -499,6 +540,9 @@ const entraDeviceLink = computed(() => fillTemplate(
entraUrlTemplate.value, '{objectid}', computer.value?.computer?.azureobjectid))
const intuneDeviceLink = computed(() => fillTemplate(
intuneUrlTemplate.value, '{deviceid}', computer.value?.computer?.azuredeviceid))
// LAPS is a blade on the same device as Entra, so it keys off the OBJECT id.
const lapsDeviceLink = computed(() => fillTemplate(
lapsUrlTemplate.value, '{objectid}', computer.value?.computer?.azureobjectid))
// clipboard.writeText needs a secure context: it is undefined over plain HTTP
// and rejects when the tab is not focused. Say copy failed and leave the id
@@ -521,7 +565,7 @@ async function copyId(value, label) {
// get. Where templates exist, a PC missing an id shows which one is missing -
// that is the actionable case the empty state was requested for.
const hasCloudIdentity = computed(() => portalTemplatesLoaded.value && Boolean(
entraUrlTemplate.value || intuneUrlTemplate.value
entraUrlTemplate.value || intuneUrlTemplate.value || lapsUrlTemplate.value
|| computer.value?.computer?.azuredevicecategory))
// Only offer a tab that has something in it. An empty tab is worse than an
@@ -612,14 +656,12 @@ function relativeAge(value) {
onMounted(async () => {
// Non-critical: a site that has not configured these just gets no link, so a
// failure here must not stop the page loading.
getEntraDeviceUrls().then(({ entraUrl, intuneUrl }) => {
entraUrlTemplate.value = entraUrl
intuneUrlTemplate.value = intuneUrl
}).catch(() => {}).finally(() => {
// Settled either way. On failure the templates stay empty and the block
// falls back to whatever else it has, rather than hiding forever.
portalTemplatesLoaded.value = true
})
//
// A failed settings fetch is negative-cached for 30s and resolves EMPTY, which
// is indistinguishable from "not configured" by value alone - the block would
// hide itself and only appear on a later visit that happened to land after the
// window. Retry once past that window instead of showing a wrong empty state.
loadPortalTemplates()
try {
const response = await computersApi.get(route.params.id)
@@ -675,6 +717,12 @@ async function refreshStatus() {
? await waitForFreshTelemetry(baseline)
: false
await assetPanels.value?.refresh()
// The liveness chip reads its own endpoint, so refreshing the panels alone
// leaves it showing the age it was rendered with at page load - the button
// looked like it had done nothing. Re-read it whatever the outcome: a
// timeout or failure still moves the chip toward quiet, which is the
// honest reading.
await loadLiveness(computer.value?.assetid)
if (outcome === 'timeout') {
toast.info('Refresh queued; the endpoint agent may be offline or busy')
} else if (outcome === 'failed') {
@@ -815,6 +863,19 @@ async function confirmRetire() {
flex-direction: column;
gap: 1rem;
}
/* Identity on the left, liveness and endpoint actions pinned to the right.
.hero-primary is a COLUMN (identity, then the portals row beneath), so the
corner block needs its own row here rather than margin-left:auto against
the column, which would just stack it under the identity. */
.hero-headline {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.hero-headline > .hero-identity { flex: 1 1 auto; }
@media (max-width: 768px) {
.hero-headline { flex-wrap: wrap; }
}
.hero-identity {
display: flex;
flex-direction: column;
@@ -871,16 +932,146 @@ async function confirmRetire() {
}
.copy-btn {
flex: 0 0 auto;
padding: 0.1rem 0.4rem;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.2rem;
line-height: 0;
color: var(--text-light);
background: none;
border: 1px solid var(--border);
border-radius: 0.25rem;
cursor: pointer;
}
/* Liveness + endpoint actions in the header corner. Pinned to the top right of
.hero-primary, which is the flex row holding the identity block. */
.hero-endpoint {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.75rem;
margin-left: auto;
flex: 0 0 auto;
padding-left: 1.5rem;
}
.hero-status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.45rem;
}
/* A touch larger than the shared hero label (10px in style.css). Scoped to
this one rather than bumping .hero-detail-label, which PC Type / OS /
Location also use and which is sized to sit quietly under the identity. */
.hero-status-label {
font-size: 12px;
}
.hero-endpoint-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
}
/* The chip styles live in AssetTriageBand's scoped block, so they do not reach
this copy of the markup. Kept in step with that component deliberately. */
.triage-chip {
display: inline-flex;
align-items: center;
font-size: 0.9rem;
padding: 0.15rem 0.6rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
white-space: nowrap;
}
.triage-dot {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.4rem;
background: var(--text-light);
}
.triage-chip.ok .triage-dot { background: var(--success); }
.triage-chip.warning .triage-dot { background: var(--warning); }
.triage-chip.critical .triage-dot { background: var(--danger); }
.triage-chip.ok { color: var(--success-dark); }
.triage-chip.warning { color: var(--warning-dark); }
.triage-chip.critical { color: var(--danger); }
[data-theme="dark"] .triage-chip.ok { color: var(--success); }
[data-theme="dark"] .triage-chip.warning { color: var(--warning); }
/* The Details tab flows its cards instead of sitting on grid rows.
Grid pairs items into implicit rows, so a row is as tall as its tallest
card and align-items:start leaves the shorter one with dead space beneath
it - Warranty is short, Endpoint Status is tall, and Policy has to wait for
the taller of the two instead of sliding up. Multicol has no rows, so each
card starts where the previous one ended and the holes close.
Multicol was rejected for .content-grid globally because a column-span:all
child (.pap-span, used by table panels) overlaps the shorter column - see
the note by .content-grid in style.css. That cannot happen here: this tab's
panels are all keyvalue/list renders and spansFullWidth() only returns true
for table (or a tabs panel holding one), so nothing in this subtree spans.
Monitors and Service/Process DO carry table panels and stay on grid.
Pure CSS, so there is no measuring, no observer and no reflow cost - the
JS-masonry option was declined for exactly that reason.
The tradeoff multicol brings is reading order: cards run down the left
column then down the right, so DOM order still matches visual order here
(it is the same top-to-bottom-per-column flow), and column-count keeps
focus order intact because no item is visually reordered across columns. */
.content-grid.details-flow {
display: block;
column-count: 2;
column-gap: 25px;
}
/* display:contents on .content-column would leave the wrappers invisible to
multicol as well, but the cards inside them need the break rule, so target
the cards themselves wherever they sit. :deep() because the plugin panels
are rendered by PluginAssetPanels and carry that component's scope id. */
.content-grid.details-flow :deep(.section-card) {
break-inside: avoid;
/* A card split across the column break would show its header in one column
and its rows in the other. */
-webkit-column-break-inside: avoid;
page-break-inside: avoid;
/* Multicol has no gap between stacked items the way grid does, so the
spacing that grid's gap supplied has to come back as a margin. */
margin-bottom: 25px;
}
/* One column below the hero breakpoint, matching .content-grid's own
single-column collapse so the two tabs behave the same on a phone. */
@media (max-width: 768px) {
.content-grid.details-flow {
column-count: 1;
}
}
/* Restart is the heaviest action on the page and sat visually equal to the
reference chips beside it. ~25% up from .btn-sm (10px / 6px 15px) so the
weight matches the consequence. Scoped with :deep() because the button is
passed into AssetTriageBand's slot, so the band's scope id lands on it. */
:deep(.triage-actions .btn-warning) {
font-size: 12.5px;
padding: 7.5px 19px;
}
/* Below the hero's two-column breakpoint the corner block would strand the
buttons against the right edge of a narrow screen. */
@media (max-width: 768px) {
.hero-endpoint {
margin-left: 0;
align-items: flex-start;
width: 100%;
}
.hero-status { align-items: flex-start; }
.hero-endpoint-actions { justify-content: flex-start; }
}
.copy-btn:hover {
color: var(--primary);
border-color: var(--primary);