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:
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -7,6 +7,8 @@ by CI (`.github/workflows/ci.yml`) - suggestions that break them fail the build.
|
||||
## General Guidelines
|
||||
- Finish the in-flight task before starting new workstreams.
|
||||
- When asked to "take notes" on a future idea, capture it in the notes file only - do not implement it, and do not expand on it at length in replies.
|
||||
- Use a temp file for multi-line git commit messages instead of heredoc syntax: `git commit -F <file>`.
|
||||
- Avoid using `powershell.exe -File` for launching scripts, as it fails if the script file is missing or quarantined.
|
||||
|
||||
## Naming (LOCKED - the hook rejects violations)
|
||||
|
||||
|
||||
@@ -19,6 +19,15 @@ export function formatValue(value, spec = {}) {
|
||||
const raw = String(value)
|
||||
return new Date(raw + (raw.length === 10 ? 'T00:00:00' : '')).toLocaleDateString()
|
||||
}
|
||||
// Timestamps arrive as UTC ISO ("2026-09-04T16:13:29Z") because the wire
|
||||
// format is UTC by contract. Render in the viewer's local zone: an operator
|
||||
// comparing "last reported" against the clock on the wall cannot do the
|
||||
// offset in their head, and the Z suffix invites reading it as local.
|
||||
if (spec.format === 'datetime') {
|
||||
const parsed = new Date(String(value))
|
||||
if (isNaN(parsed)) return value
|
||||
return parsed.toLocaleString()
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -8,6 +8,14 @@ let settingsCache = null
|
||||
let inFlight = null
|
||||
let retryAfter = 0
|
||||
|
||||
// Whether the last resolved load actually produced settings. A caller cannot
|
||||
// tell that from the value alone: a failed load and a site that simply has not
|
||||
// configured a key both read as empty, and those want opposite handling -
|
||||
// hide the feature vs try again.
|
||||
export function settingsLoadFailed() {
|
||||
return settingsCache === null
|
||||
}
|
||||
|
||||
// A failed load is cached briefly, not for the page session. A transient blip
|
||||
// used to blank every settings-driven link until the operator reloaded, and
|
||||
// they have no reason to know that would fix it. A short window still bounds a
|
||||
@@ -151,10 +159,14 @@ export async function getServicenowUrls() {
|
||||
// setting instead of waiting for a release.
|
||||
//
|
||||
// Empty is the default and means "no link" - the id still renders as text.
|
||||
//
|
||||
// `loaded` distinguishes a real answer from a failed fetch. Both return empty
|
||||
// strings, but only one of them means "this site has no portal links".
|
||||
export async function getEntraDeviceUrls() {
|
||||
const entraUrl = await getSetting('entra_device_url', '')
|
||||
const intuneUrl = await getSetting('intune_device_url', '')
|
||||
return { entraUrl, intuneUrl }
|
||||
const lapsUrl = await getSetting('laps_device_url', '')
|
||||
return { entraUrl, intuneUrl, lapsUrl, loaded: !settingsLoadFailed() }
|
||||
}
|
||||
|
||||
// Printer hostname template. {ip} is replaced with the dash-separated IP.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -507,21 +507,26 @@ def panel_status(asset_id: int):
|
||||
if not state:
|
||||
return success_response({'fields': []})
|
||||
|
||||
def field(label, value, mono=False):
|
||||
return {'label': label, 'value': value, 'mono': mono}
|
||||
def field(label, value, mono=False, fmt=None):
|
||||
entry = {'label': label, 'value': value, 'mono': mono}
|
||||
if fmt:
|
||||
entry['format'] = fmt
|
||||
return entry
|
||||
|
||||
fields = [
|
||||
field('Logged in user', state.loggedinuser),
|
||||
field('Uptime', _format_uptime(state.uptimeseconds)),
|
||||
field('Idle', _format_uptime(state.idleseconds)),
|
||||
field('Last boot',
|
||||
state.lastboottime.isoformat() + 'Z' if state.lastboottime else None),
|
||||
state.lastboottime.isoformat() + 'Z' if state.lastboottime else None,
|
||||
fmt='datetime'),
|
||||
field('CPU %', state.cpupercent),
|
||||
field('Memory %', state.memorypercent),
|
||||
field('Agent', state.agentversion, mono=True),
|
||||
field('Tray', state.trayversion, mono=True),
|
||||
field('Last reported',
|
||||
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None),
|
||||
state.lastreportedat.isoformat() + 'Z' if state.lastreportedat else None,
|
||||
fmt='datetime'),
|
||||
]
|
||||
fields = [f for f in fields if f['value'] not in (None, '')]
|
||||
|
||||
@@ -1424,15 +1429,6 @@ _IPHISTORY_COLUMNS = [
|
||||
{'key': 'lastchanged', 'label': 'Last changed'},
|
||||
]
|
||||
|
||||
_IPCONFIG_COLUMNS = [
|
||||
{'key': 'ipaddress', 'label': 'IP address'},
|
||||
{'key': 'subnetmask', 'label': 'Subnet mask'},
|
||||
{'key': 'gateway', 'label': 'Gateway'},
|
||||
{'key': 'dns1', 'label': 'DNS 1'},
|
||||
{'key': 'dns2', 'label': 'DNS 2'},
|
||||
{'key': 'assignment', 'label': 'Assignment'},
|
||||
]
|
||||
|
||||
|
||||
def _isostamp(value):
|
||||
"""ISO-8601 with a Z suffix, matching every other timestamp this file emits.
|
||||
@@ -1513,37 +1509,6 @@ def panel_iphistory(asset_id: int):
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/ipconfig', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_ipconfig(asset_id: int):
|
||||
"""Table panel: subnet, gateway and DNS per recorded address.
|
||||
|
||||
Split from IP history rather than folded into it: history answers "what has
|
||||
this box been", configuration answers "how is this address set up", and one
|
||||
table trying to do both would be eleven columns wide. Rows with no
|
||||
configuration recorded are dropped - an address with no subnet, gateway or
|
||||
DNS contributes nothing here and already appears in the history card.
|
||||
"""
|
||||
rows = []
|
||||
for comm in _communication_rows(asset_id):
|
||||
address = (comm.ipaddress or '').strip()
|
||||
subnet = (comm.subnetmask or '').strip()
|
||||
gateway = (comm.gateway or '').strip()
|
||||
dns1 = (comm.dns1 or '').strip()
|
||||
dns2 = (comm.dns2 or '').strip()
|
||||
if not address or not (subnet or gateway or dns1 or dns2):
|
||||
continue
|
||||
rows.append({
|
||||
'ipaddress': address,
|
||||
'subnetmask': subnet or '-',
|
||||
'gateway': gateway or '-',
|
||||
'dns1': dns1 or '-',
|
||||
'dns2': dns2 or '-',
|
||||
'assignment': 'DHCP' if comm.isdhcp else 'Static',
|
||||
})
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
@endpoints_bp.route('/asset/<int:asset_id>/applications', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
def panel_applications(asset_id: int):
|
||||
|
||||
@@ -545,30 +545,6 @@ class EndpointsPlugin(BasePlugin):
|
||||
'position': 16.5,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
# The other half of the old Network card: how each address is
|
||||
# configured. Split from history because one table carrying
|
||||
# both would be eleven columns wide, and the two answer
|
||||
# different questions - what has this box been, versus how is
|
||||
# this address set up.
|
||||
'id': 'endpoints-ipconfig',
|
||||
'title': 'IP Configuration',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/endpoints/asset/{assetid}/ipconfig',
|
||||
'render': 'table',
|
||||
'collapsible': True,
|
||||
'columns': [
|
||||
{'key': 'ipaddress', 'label': 'IP address', 'mono': True},
|
||||
{'key': 'subnetmask', 'label': 'Subnet mask', 'mono': True},
|
||||
{'key': 'gateway', 'label': 'Gateway', 'mono': True},
|
||||
{'key': 'dns1', 'label': 'DNS 1', 'mono': True},
|
||||
{'key': 'dns2', 'label': 'DNS 2', 'mono': True},
|
||||
{'key': 'assignment', 'label': 'Assignment'},
|
||||
],
|
||||
'empty': 'No addressing configuration on record.',
|
||||
'position': 16.6,
|
||||
'group': 'monitors',
|
||||
},
|
||||
{
|
||||
# Installed-software (SBOM) inventory. Raw discovered list for
|
||||
# now: no managed-application linkage is surfaced yet.
|
||||
|
||||
@@ -1320,6 +1320,21 @@ def build_default_settings():
|
||||
'DeviceSettingsMenuBlade/~/overview/aadDeviceId/{deviceid}'
|
||||
)
|
||||
},
|
||||
{
|
||||
# ADR-015-OK: the DEFAULT is empty, so no cloud is assumed. Same
|
||||
# reasoning as entra_device_url - the LAPS blade lives in the same
|
||||
# tenant-specific portal, so the host cannot be a product fact.
|
||||
'key': 'laps_device_url',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'integrations',
|
||||
'description': (
|
||||
'Azure LAPS (local admin password) URL template. {objectid} is '
|
||||
'the device object id. Example (US Gov): https://portal.azure.us/'
|
||||
'?feature.msaljs=true#view/Microsoft_AAD_Devices/'
|
||||
'DeviceDetailsMenuBlade/~/LocalAdminPassword/objectId/{objectid}'
|
||||
)
|
||||
},
|
||||
{
|
||||
'key': 'servicenow_ticket_prefixes',
|
||||
'value': 'GEINC,GECHG,GERIT,GESCT',
|
||||
|
||||
@@ -15,7 +15,6 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from plugins.endpoints.api.routes import (
|
||||
_IPCONFIG_COLUMNS,
|
||||
_IPHISTORY_COLUMNS,
|
||||
_iphistory_status,
|
||||
_isostamp,
|
||||
@@ -95,15 +94,6 @@ def test_the_second_date_is_not_labelled_last_seen():
|
||||
assert labels['firstseen'] == 'First seen'
|
||||
|
||||
|
||||
def test_the_two_cards_do_not_duplicate_columns_beyond_the_address():
|
||||
# They are split so neither is eleven columns wide. The address is the
|
||||
# deliberate join between them; anything else repeated means the split
|
||||
# drifted back together.
|
||||
history = {col['key'] for col in _IPHISTORY_COLUMNS}
|
||||
config = {col['key'] for col in _IPCONFIG_COLUMNS}
|
||||
assert history & config == {'ipaddress'}
|
||||
|
||||
|
||||
def test_the_history_ordering_compiles_on_mysql():
|
||||
# Regression: the first cut ordered with .nullslast(), which is valid
|
||||
# ANSI/Postgres but blows up on MySQL with a 1064 syntax error. The test
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_endpoints_asset_panels_are_computer_scoped():
|
||||
"""Every asset panel targets computer assets only, with an endpoint."""
|
||||
plugin = EndpointsPlugin()
|
||||
panels = plugin.get_asset_panels()
|
||||
assert len(panels) == 15
|
||||
assert len(panels) == 14
|
||||
for panel in panels:
|
||||
assert panel['assettypes'] == ['computer']
|
||||
assert panel['endpoint'].startswith('/api/endpoints/')
|
||||
|
||||
Reference in New Issue
Block a user