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

@@ -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):