Merge origin/main into wilm: reconcile endpoint agent dev with release channel

# Conflicts:
#	plugins/endpoints/api/routes.py
This commit is contained in:
benjamin.baker
2026-08-30 08:46:04 -04:00
15 changed files with 1396 additions and 69 deletions

View File

@@ -397,3 +397,51 @@ def get_effective_policy(asset_id: int):
from ..services.policyresolver import effective_policy_for
effective = effective_policy_for(asset_id)
return success_response(effective.to_dict())
@computers_bp.route('/asset/<int:asset_id>/policypanel', methods=['GET'])
@require_permission('computers.view')
def policy_panel(asset_id: int):
"""Keyvalue panel: which policy governs this endpoint, and what it allows.
Provenance, not a control. It matters because the effective policy already
narrows the command allowlist, so it is the answer to "why is Start greyed
out on this PC" - which is otherwise invisible.
Returns an EMPTY field list rather than the feature-gate 404 when the
feature is off. The panel contract hides a card with no fields; an error
would render a broken card on every site that has not enabled policies,
which is most of them.
"""
if not policy_flag_enabled():
return success_response({'fields': []})
from ..services.policyresolver import effective_policy_for
effective = effective_policy_for(asset_id)
actions = effective.to_dict()
if not actions:
return success_response({'fields': []})
# One endpoint resolves through one winning business policy, but read the
# ids off the actions rather than assuming: a fold can draw from more than
# one configuration policy.
businesspolicyids = {a['businesspolicyid'] for a in actions.values()
if a.get('businesspolicyid')}
configpolicyids = {a['configurationpolicyid'] for a in actions.values()
if a.get('configurationpolicyid')}
names = []
if businesspolicyids:
names = [p.name for p in BusinessPolicy.query
.filter(BusinessPolicy.businesspolicyid.in_(businesspolicyids)).all()]
gated = sum(1 for a in actions.values() if a.get('mode') == ACTION_MODE_GATE)
desired = sum(1 for a in actions.values() if a.get('mode') == ACTION_MODE_DESIRED)
fields = []
if names:
fields.append({'label': 'Policy', 'value': ', '.join(sorted(names))})
fields.append({'label': 'Configuration policies', 'value': str(len(configpolicyids))})
fields.append({'label': 'Allowed actions', 'value': str(gated)})
fields.append({'label': 'Desired state', 'value': str(desired)})
return success_response({'fields': fields})

View File

@@ -2,9 +2,12 @@
<div class="detail-page">
<div class="page-header">
<h2>Computer Details</h2>
<!-- Record actions only. The two that act on the MACHINE - refresh and
restart - live in the triage band beside the state they change and
the machine they would take down with it. Restart sitting between
Print Label and Edit made the most consequential button on the page
look like a peer of printing a sticker. -->
<div class="header-actions">
<button v-if="canCommand" @click="refreshStatus" class="btn btn-secondary" :disabled="restarting || refreshing">{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}</button>
<button v-if="canRestart" @click="confirmRestart" class="btn btn-warning" :disabled="restarting">Restart Computer</button>
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
@@ -49,12 +52,41 @@
<span class="hero-detail-value">{{ computer.locationname }}</span>
</div>
</div>
<!-- The 6am band: is it alive, what stops without it, how do I reach
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. -->
<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"
:disabled="restarting">Restart Computer</button>
</template>
</AssetTriageBand>
</div>
</div>
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
<!-- Main Content Grid -->
<!-- Live state before reference data. The grid is a two-column FLOW
(column-count, with display:contents on the column wrappers), so a
card cannot be pinned to a side - only document order decides where
it lands. Putting the contributed panels first means endpoint status,
services and activity are what a responder meets, and Identity,
Hardware and the rest follow. Panels self-order among themselves by
their declared position, and self-hide when a site has no plugin
answering for this asset type. -->
<div class="content-grid">
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
<!-- Left Column -->
<div class="content-column">
<!-- Identity Section -->
@@ -125,20 +157,19 @@
</div>
</div>
<!-- Remote Access -->
<div class="section-card">
<h3 class="section-title">Remote Access</h3>
<div class="access-methods">
<template v-for="a in (computer.accessmethods || [])" :key="a.id">
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link">{{ a.name }}</a>
<span v-else class="access-link disabled" title="No hostname/IP set for this PC">{{ a.name }}</span>
</template>
<span v-if="!(computer.accessmethods || []).length" class="muted">None configured</span>
</div>
</div>
<!-- Remote Access lives in the triage band at the top of the page
now: it is what a responder clicks first, and rendering it twice
made the page longer without telling anyone anything. -->
<!-- Status / Check-in -->
<div class="section-card">
<!-- 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">
@@ -249,9 +280,6 @@
<!-- Custom Fields -->
<CustomFieldsSection :assetid="computer.assetid" />
<!-- Warranty -->
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
<!-- All relationships (controls, defaultprinter, ...) -->
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
@@ -285,6 +313,7 @@ import { withBase } from '@/utils/basePath'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import AssetTriageBand from '@/components/AssetTriageBand.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings'
@@ -301,6 +330,18 @@ const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
const computer = ref(null)
// Liveness for the triage band. Null when the endpoints plugin is absent, or
// when the viewer lacks endpoints.view - in either case the chip is simply not
// 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)
const restarting = ref(false)
@@ -317,6 +358,42 @@ const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() =
// relationships render via the shared AssetRelationships card
async function loadLiveness(assetid) {
if (!assetid) return
try {
const response = await endpointsApi.get(assetid)
const state = response.data.data || {}
// The SERVER decides reporting/quiet/never, because the quiet window is a
// site setting. Guessing a threshold here would disagree with the Fleet
// list about the same endpoint.
const labels = {
reporting: `Reporting, ${relativeAge(state.lastreportedat)}`,
quiet: `Quiet, last seen ${relativeAge(state.lastreportedat)}`,
never: 'Never reported'
}
const levels = { reporting: 'ok', quiet: 'warning', never: 'unknown' }
if (state.status) {
liveness.value = { level: levels[state.status], label: labels[state.status] }
}
} catch (error) {
// 404 = no agent on this PC, 403 = the viewer cannot read telemetry.
// Neither is a page error; the chip just does not appear.
liveness.value = null
}
}
function relativeAge(value) {
if (!value) return 'never'
const then = new Date(value)
if (isNaN(then)) return 'unknown'
const mins = Math.floor((Date.now() - then.getTime()) / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours} h ago`
return `${Math.floor(hours / 24)} d ago`
}
onMounted(async () => {
try {
const response = await computersApi.get(route.params.id)
@@ -329,6 +406,7 @@ onMounted(async () => {
installedApps.value = appsResponse.data.data || []
} catch (appError) {
}
loadLiveness(computer.value?.assetid)
// Warranties load via useWarrantyBadge (watches computer.assetid).
} catch (error) {
console.error('Error loading computer:', error)

View File

@@ -1967,6 +1967,29 @@ class ComputersPlugin(BasePlugin):
},
]
def get_asset_panels(self) -> List[Dict]:
"""Policy provenance on the PC detail page.
The effective policy already narrows which commands an endpoint will
accept, so without this the reason a control is unavailable is not
visible anywhere. Right column, near the services it governs: this is
provenance, not a 6am triage signal.
The route returns an empty field list when the policy feature is off,
so the card disappears on sites that have not enabled it rather than
rendering an error.
"""
return [
{
'id': 'computers-policy',
'title': 'Policy',
'assettypes': ['computer'],
'endpoint': '/api/computers/asset/{assetid}/policypanel',
'render': 'keyvalue',
'position': 40,
},
]
def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items."""
return [

View File

@@ -16,8 +16,9 @@ from flask_jwt_extended import current_user
from shopdb.api import (
db, Asset, Setting, success_response, error_response, paginated_response,
ErrorCodes, get_pagination_params, paginate_query, require_permission,
require_role, mint_service_token, list_service_tokens, revoke_service_tokens,
AuditLog, collector_authorized,
require_role, caller_holds, mint_service_token, list_service_tokens,
revoke_service_tokens,
AuditLog, collector_authorized, User,
)
from ..models import EndpointState, EndpointEvent, EndpointCommand
@@ -31,6 +32,22 @@ def _naive_utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
# A watched service or process reports a STATE STRING, not a boolean, and the
# vocabulary varies by source. Both the services panel and the fleet health
# rollup have to agree on what counts as running, so the rule lives here once -
# reading it wrong makes every service look stopped.
_RUNNING_STATES = ('running', 'started', 'up', 'ok', 'active')
def _service_running(entry):
raw = (entry.get('state') or entry.get('status') or '').strip().lower()
return raw in _RUNNING_STATES
def _service_name(entry):
return entry.get('name') or entry.get('service') or 'Unknown'
def _format_uptime(seconds):
"""Whole-seconds uptime -> 'Nd Nh Nm', or None."""
if seconds is None:
@@ -55,44 +72,247 @@ def _format_uptime(seconds):
# Endpoint state
# =============================================================================
@endpoints_bp.route('/', methods=['GET'], strict_slashes=False)
@require_permission('endpoints.view')
@endpoints_bp.route('', methods=['GET'])
@require_permission('endpoints.fleet')
def list_endpoints():
"""List reported endpoint states.
Registered as '' so the rule is '/api/endpoints', with no trailing slash.
CONTRIBUTING.md's API section spells endpoints that way ('/api/machines'),
and every other plugin's list route follows it - this was the only route in
331 paths that did not.
It was not cosmetic. A rule ending in a slash makes Flask answer the
unslashed URL with a 308 to an ABSOLUTE url. On dev that resolves and
nobody notices. Behind the IIS subpath it does not: with no ProxyFix,
Werkzeug builds that Location from the scheme and host it believes it has,
so it emitted http:// and dropped the /shopdb prefix. The browser refuses
to follow an https page to http, the list request failed, and the page
rendered "No endpoints found" - indistinguishable from a fleet with nothing
in it. The agent had been reporting correctly the whole time.
Joins through Computer so each row carries computerid and can deep-link
into the existing PC detail page (/pcs/{computerid}). Endpoint state only
exists for hostnames resolved to a Computer, so the join is not lossy.
Sorted WORST FIRST by default: never-reported, then quiet, then reporting.
It used to sort by lastreportedat descending, which floated the healthiest
endpoints to the top and buried the ones somebody needs to look at.
Each row also carries what the fleet view is actually for: which MACHINE
depends on this PC, its PC type, and a health rollup. On a shop floor a PC
matters because a machine stops without it, so that is the column an
operator reads first.
"""
from plugins.computers.models import Computer
from plugins.computers.models import Computer, ComputerType
page, per_page = get_pagination_params(request)
cutoff = _naive_utcnow() - timedelta(hours=_quiet_hours())
query = (db.session.query(EndpointState, Computer.computerid)
# Status as a sortable expression rather than a post-hoc label, so
# worst-first survives pagination. 0 never reported, 1 quiet, 2 reporting.
statusrank = db.case(
(EndpointState.lastreportedat.is_(None), 0),
(EndpointState.lastreportedat < cutoff, 1),
else_=2,
)
query = (db.session.query(EndpointState, Computer.computerid,
Computer.computertypeid, ComputerType.computertype,
Asset.assetnumber, statusrank.label('statusrank'))
.join(Asset, Asset.assetid == EndpointState.assetid)
.join(Computer, Computer.assetid == EndpointState.assetid)
.outerjoin(ComputerType,
ComputerType.computertypeid == Computer.computertypeid)
.filter(Asset.isactive.is_(True)))
if search := request.args.get('search'):
query = query.filter(EndpointState.hostname.ilike(f'%{search}%'))
# Search covers hostname, asset number and the machine number a PC serves.
# Hostname alone was too narrow: people look a PC up by the bay it runs.
if search := (request.args.get('search') or '').strip():
like = f'%{search}%'
machinehits = _assetids_serving_machine_like(like)
clauses = [EndpointState.hostname.ilike(like), Asset.assetnumber.ilike(like)]
if machinehits:
clauses.append(EndpointState.assetid.in_(machinehits))
query = query.filter(db.or_(*clauses))
query = query.order_by(EndpointState.lastreportedat.desc())
if status := request.args.get('status'):
wanted = {'never': 0, 'quiet': 1, 'reporting': 2}.get(status.lower())
if wanted is not None:
query = query.filter(statusrank == wanted)
if computertypeid := request.args.get('computertypeid'):
query = query.filter(Computer.computertypeid == computertypeid)
if locationid := request.args.get('locationid'):
query = query.filter(Asset.locationid == locationid)
if businessunitid := request.args.get('businessunitid'):
query = query.filter(Asset.businessunitid == businessunitid)
sort = (request.args.get('sort') or 'status').lower()
sorts = {
'status': (statusrank.asc(), EndpointState.hostname.asc()),
'hostname': (EndpointState.hostname.asc(),),
'lastreported': (EndpointState.lastreportedat.desc(),),
'uptime': (EndpointState.uptimeseconds.desc(),),
}
query = query.order_by(*sorts.get(sort, sorts['status']))
items, total = paginate_query(query, page, per_page)
# Batch the per-row extras for this PAGE only. Doing them inline would be
# one query per endpoint for machines and another for events.
assetids = [state.assetid for state, *_ in items]
machines = _machines_for_assets(assetids)
critical = _critical_counts_for_assets(assetids)
data = []
for state, computerid in items:
for state, computerid, computertypeid, computertype, assetnumber, rank in items:
row = state.to_dict()
row['computerid'] = computerid
row['assetnumber'] = assetnumber
row['computertypeid'] = computertypeid
row['computertype'] = computertype
row['uptimedisplay'] = _format_uptime(state.uptimeseconds)
row['status'] = ('never', 'quiet', 'reporting')[rank]
row['machines'] = machines.get(state.assetid, [])
row['health'] = _health_for(state, critical.get(state.assetid, 0))
data.append(row)
return paginated_response(data, page, per_page, total)
def _assetids_serving_machine_like(like):
"""Asset ids of PCs whose controlled machine matches a search term.
Checked in BOTH directions on purpose. The canonical edge is PC controls
machine, but production also holds reciprocal rows, so matching only
source->target silently drops half the fleet from a machine-number search.
"""
from shopdb.api import AssetRelationship, RelationshipType, AssetType
controls = (RelationshipType.query
.filter(db.func.lower(RelationshipType.relationshiptype) == 'controls')
.first())
if controls is None:
return []
# Only MACHINE assets. A PC also 'controls' printers and other kit, and
# matching any controlled asset put office locations in the machine column.
other = db.aliased(Asset)
othertype = db.aliased(AssetType)
forward = (db.session.query(AssetRelationship.sourceassetid)
.join(other, other.assetid == AssetRelationship.targetassetid)
.join(othertype, othertype.assettypeid == other.assettypeid)
.filter(AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
db.func.lower(othertype.assettype) == 'machine',
other.assetnumber.ilike(like)))
backward = (db.session.query(AssetRelationship.targetassetid)
.join(other, other.assetid == AssetRelationship.sourceassetid)
.join(othertype, othertype.assettypeid == other.assettypeid)
.filter(AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
db.func.lower(othertype.assettype) == 'machine',
other.assetnumber.ilike(like)))
return [r[0] for r in forward.union(backward).all()]
def _machines_for_assets(assetids):
"""{assetid: [{assetid, assetnumber, name}]} for the machines each PC serves.
Both edge directions again, deduplicated by machine asset id. One query for
the whole page rather than one per row.
"""
from shopdb.api import AssetRelationship, RelationshipType, AssetType
if not assetids:
return {}
controls = (RelationshipType.query
.filter(db.func.lower(RelationshipType.relationshiptype) == 'controls')
.first())
if controls is None:
return {}
other = db.aliased(Asset)
othertype = db.aliased(AssetType)
forward = (db.session.query(AssetRelationship.sourceassetid.label('pc'),
other.assetid, other.assetnumber, other.name)
.join(other, other.assetid == AssetRelationship.targetassetid)
.join(othertype, othertype.assettypeid == other.assettypeid)
.filter(AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
db.func.lower(othertype.assettype) == 'machine',
AssetRelationship.sourceassetid.in_(assetids)))
backward = (db.session.query(AssetRelationship.targetassetid.label('pc'),
other.assetid, other.assetnumber, other.name)
.join(other, other.assetid == AssetRelationship.sourceassetid)
.join(othertype, othertype.assettypeid == other.assettypeid)
.filter(AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
db.func.lower(othertype.assettype) == 'machine',
AssetRelationship.targetassetid.in_(assetids)))
out = {}
for pc, machineid, assetnumber, name in forward.union(backward).all():
seen = out.setdefault(pc, [])
if not any(m['assetid'] == machineid for m in seen):
seen.append({'assetid': machineid, 'assetnumber': assetnumber, 'name': name})
return out
def _critical_counts_for_assets(assetids):
"""{assetid: count} of critical events in the stability window."""
if not assetids:
return {}
cutoff = _naive_utcnow() - timedelta(hours=48)
rows = (db.session.query(EndpointEvent.assetid,
db.func.count(EndpointEvent.endpointeventid))
.filter(EndpointEvent.assetid.in_(assetids),
EndpointEvent.eventid.in_(CRITICAL_EVENT_IDS),
EndpointEvent.eventtime.isnot(None),
EndpointEvent.eventtime >= cutoff)
.group_by(EndpointEvent.assetid)
.all())
return dict(rows)
def _health_for(state, criticalcount):
"""Worst-of rollup: a stopped watched service, or a recent critical event.
Deliberately a PHRASE, not a score. "eDNC stopped" tells an operator what
to do; a percentage does not.
"""
if criticalcount:
return {'level': 'critical',
'label': f'{criticalcount} critical' if criticalcount > 1 else '1 critical'}
stopped = []
try:
for entry in json.loads(state.servicesjson or '[]'):
if not _service_running(entry):
stopped.append(_service_name(entry))
except (TypeError, ValueError):
# Malformed payload is not a health verdict - say nothing rather than
# claiming the endpoint is unhealthy.
return {'level': 'unknown', 'label': None}
if stopped:
first = stopped[0]
extra = f' +{len(stopped) - 1}' if len(stopped) > 1 else ''
return {'level': 'warning', 'label': f'{first} stopped{extra}'}
if state.lastreportedat is None:
return {'level': 'unknown', 'label': 'no agent'}
return {'level': 'ok', 'label': 'ok'}
@endpoints_bp.route('/<int:asset_id>', methods=['GET'])
@require_permission('endpoints.view')
def get_endpoint(asset_id: int):
"""Get the current state for one endpoint by asset id."""
"""Get the current state for one endpoint by asset id.
Carries the same reporting/quiet/never verdict the fleet list uses. The
quiet window is a site setting, so the SERVER decides it: a caller that
guessed a threshold in the browser would disagree with the fleet list on
the same endpoint the moment a site changed the setting.
"""
state = EndpointState.query.filter_by(assetid=asset_id).first()
if not state:
@@ -102,7 +322,18 @@ def get_endpoint(asset_id: int):
http_code=404,
)
return success_response(state.to_dict())
cutoff = _naive_utcnow() - timedelta(hours=_quiet_hours())
if state.lastreportedat is None:
status = 'never'
elif state.lastreportedat < cutoff:
status = 'quiet'
else:
status = 'reporting'
data = state.to_dict()
data['status'] = status
data['uptimedisplay'] = _format_uptime(state.uptimeseconds)
return success_response(data)
@endpoints_bp.route('/<int:asset_id>/events', methods=['GET'])
@@ -195,6 +426,8 @@ def panel_services(asset_id: int):
if not state or not state.servicesjson:
return success_response([])
maycommand = caller_holds('endpoints.command')
try:
services = json.loads(state.servicesjson)
except (TypeError, ValueError):
@@ -209,14 +442,18 @@ def panel_services(asset_id: int):
kind = entry.get('kind')
else:
name, raw, app, kind = str(entry), '', None, None
running = raw in ('running', 'started', 'up', 'ok', 'active')
running = _service_running(entry)
row = {
'name': name,
'kind': (kind or '').lower() or None,
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
'statecolor': 'green' if running else ('red' if raw else 'gray'),
}
targets = row_actions_for(app, kind, name)
# Only offer Start/Stop to a caller who could actually run it. The
# emission never consulted the caller, so a read-only viewer was shown
# controls on a shop-floor PC that 403 on click - wrong signal, and it
# gets worse the moment guests can read this panel.
targets = row_actions_for(app, kind, name) if maycommand else None
if targets:
row['action'] = {
'running': running,
@@ -253,6 +490,84 @@ def panel_recent_events(asset_id: int):
return success_response(rows)
@endpoints_bp.route('/asset/<int:asset_id>/activity', methods=['GET'])
@require_permission('endpoints.view')
def panel_activity(asset_id: int):
"""List panel: one chronological feed of what happened to this endpoint.
Curated Windows events and operator commands merged and sorted newest
first. They answer the same question - what happened to this box - and
reading them as two separate cards means interleaving them by eye.
Both sources are already keyed by assetid, so this is two indexed reads and
a merge sort, not a join. The core AuditLog is deliberately NOT a third
source: its rows carry the action as the entity name and the asset only
inside a details string, so pulling it in would mean parsing prose.
The requester's name is withheld from callers who cannot themselves issue
commands. A read-only viewer - a guest, on a site that grants this panel -
has no need for the roster of who restarted what, and the feed is just as
useful without it.
"""
naming = caller_holds('endpoints.command')
events = (EndpointEvent.query
.filter_by(assetid=asset_id)
.order_by(EndpointEvent.eventtime.desc())
.limit(15)
.all())
commands = (EndpointCommand.query
.filter_by(assetid=asset_id)
.order_by(EndpointCommand.createdat.desc())
.limit(15)
.all())
# requestedby holds a user ID, not a name. One batched lookup rather than a
# query per row, and only when the caller is allowed to see the names at all.
names = {}
if naming:
userids = {c.requestedby for c in commands if c.requestedby}
if userids:
names = {u.userid: (u.username or '')
for u in User.query.filter(User.userid.in_(userids)).all()}
rows = []
for event in events:
rows.append({
'title': event_title(event.eventid),
'severity': event_severity(event.eventid),
'detail': 'Windows event {}'.format(event.eventid),
'eventtime': event.eventtime,
})
for command in commands:
label = COMMAND_ALLOWLIST.get(command.action, {}).get('label') or command.action
target = ' on {}'.format(command.target) if command.target else ''
who = ''
if naming and command.requestedby:
username = names.get(command.requestedby)
if username:
who = ' by {}'.format(username)
rows.append({
'title': '{}{}'.format(label, target),
# A failed command is the one an operator needs to notice.
'severity': 'critical' if command.status == EndpointCommand.STATUS_FAILED else 'info',
'detail': '{}{}'.format((command.status or 'queued').title(), who),
'eventtime': command.createdat,
})
# Undated rows sort last rather than crashing the comparison.
rows.sort(key=lambda r: (r['eventtime'] is not None, r['eventtime']), reverse=True)
return success_response([{
'title': r['title'],
'severity': r['severity'],
'detail': r['detail'],
'eventtime': r['eventtime'].isoformat() + 'Z' if r['eventtime'] else None,
} for r in rows[:15]])
@endpoints_bp.route('/asset/<int:asset_id>/interfaces', methods=['GET'])
@require_permission('endpoints.view')
def panel_interfaces(asset_id: int):
@@ -299,7 +614,7 @@ def panel_interfaces(asset_id: int):
# =============================================================================
@endpoints_bp.route('/dashboard/summary', methods=['GET'])
@require_permission('endpoints.view')
@require_permission('endpoints.fleet')
def dashboard_summary():
"""High-level fleet counts for the endpoints dashboard."""
total = db.session.query(EndpointState).join(
@@ -317,16 +632,28 @@ def dashboard_summary():
EndpointState.lastreportedat >= cutoff,
).count()
# Counted separately from quiet. An endpoint that has NEVER reported is a
# different problem from one that reported and stopped: the first is an
# enrolment or key failure, the second is a machine that went down. Folding
# them together (quiet = total - reporting) hid the enrolment failures.
never = db.session.query(EndpointState).join(
Asset, Asset.assetid == EndpointState.assetid
).filter(
Asset.isactive.is_(True),
EndpointState.lastreportedat.is_(None),
).count()
return success_response({
'total': total,
'reporting': reporting,
'quiet': total - reporting,
'quiet': total - reporting - never,
'never': never,
'quiethours': hours,
})
@endpoints_bp.route('/dashboard/quiet', methods=['GET'])
@require_permission('endpoints.view')
@require_permission('endpoints.fleet')
def dashboard_quiet():
"""Endpoints that reported before, then went quiet past the window.
@@ -361,7 +688,7 @@ def dashboard_quiet():
@endpoints_bp.route('/dashboard/stability', methods=['GET'])
@require_permission('endpoints.view')
@require_permission('endpoints.fleet')
def dashboard_stability():
"""Endpoints with recent crash/shutdown/corruption events.

View File

@@ -4,30 +4,95 @@
<h2>Fleet</h2>
</div>
<!-- Summary strip. Wired to dashboard routes that already existed and
nothing called; each tile is a filter shortcut, not just a number. -->
<div v-if="summary" class="fleet-tiles">
<button class="fleet-tile" :class="{ on: status === '' && !problemsonly }" @click="clearFilters">
<span class="n">{{ summary.total }}</span><span class="l">Endpoints</span>
</button>
<button class="fleet-tile ok" :class="{ on: status === 'reporting' }" @click="setStatus('reporting')">
<span class="n">{{ summary.reporting }}</span><span class="l">Reporting</span>
</button>
<button class="fleet-tile warn" :class="{ on: status === 'quiet' }" @click="setStatus('quiet')">
<span class="n">{{ summary.quiet }}</span><span class="l">Quiet over {{ summary.quiethours }}h</span>
</button>
<button class="fleet-tile bad" :class="{ on: status === 'never' }" @click="setStatus('never')">
<span class="n">{{ summary.never }}</span><span class="l">Never reported</span>
</button>
<!-- Crash, shutdown and corruption events in the stability window. The
route existed and nothing called it. -->
<button class="fleet-tile bad" :class="{ on: problemsonly }" @click="toggleProblems">
<span class="n">{{ criticalcount }}</span><span class="l">With critical events</span>
</button>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search endpoints..."
placeholder="Search hostname, asset or machine number..."
@input="debouncedSearch"
/>
<select v-model="status" class="form-control fleet-filter" @change="applyFilters">
<option value="">All statuses</option>
<option value="reporting">Reporting</option>
<option value="quiet">Quiet</option>
<option value="never">Never reported</option>
</select>
<select v-model="computertypeid" class="form-control fleet-filter" @change="applyFilters">
<option value="">All PC types</option>
<option v-for="type in computertypes" :key="type.computertypeid" :value="type.computertypeid">
{{ type.computertype }}
</option>
</select>
<select v-model="locationid" class="form-control fleet-filter" @change="applyFilters">
<option value="">All locations</option>
<option v-for="place in locations" :key="place.locationid" :value="place.locationid">
{{ place.locationname }}
</option>
</select>
<select v-model="sort" class="form-control fleet-filter" @change="applyFilters">
<option value="status">Sort: worst first</option>
<option value="hostname">Sort: hostname</option>
<option value="lastreported">Sort: last reported</option>
<option value="uptime">Sort: uptime</option>
</select>
</div>
<!-- Actions live here rather than per row. A tenth column of buttons
pushed itself off the edge of a nowrap table, and selecting one row
costs a single click while giving one command path instead of two. -->
<div v-if="cancommand && selected.length" class="bulk-bar">
<span class="bulk-count">{{ selected.length }} selected</span>
<button class="btn btn-secondary btn-sm" :disabled="busy" @click="collectSelected">Collect now</button>
<button v-if="canrestart" class="btn btn-warning btn-sm" :disabled="busy" @click="restartSelected">Restart</button>
<button class="btn btn-secondary btn-sm" :disabled="busy" @click="selected = []">Clear</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="loadError" class="load-error">
{{ loadError }}
</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th v-if="cancommand" class="select-cell">
<input type="checkbox" :checked="allselected" @change="toggleAll" title="Select all on this page" />
</th>
<th>Status</th>
<th>Hostname</th>
<th>Machine</th>
<th>PC Type</th>
<th>Health</th>
<th>Logged-in User</th>
<th>Uptime</th>
<th>CPU</th>
<th>Memory</th>
<th>Last Reported</th>
</tr>
</thead>
@@ -39,15 +104,31 @@
:class="{ disabled: !item.computerid }"
@click="openPc(item)"
>
<td v-if="cancommand" class="select-cell" @click.stop>
<input type="checkbox" :value="item.assetid" v-model="selected" />
</td>
<td>
<span class="status-dot" :class="item.status"></span>{{ statusLabel(item.status) }}
</td>
<td>{{ item.hostname || '-' }}</td>
<td>
<!-- What stops if this PC is dead. On a shop floor that is
the first thing anyone needs to know. -->
<span v-if="!item.machines || !item.machines.length">-</span>
<span v-else>{{ item.machines.map(machineLabel).join(', ') }}</span>
</td>
<td>{{ item.computertype || '-' }}</td>
<td>
<span v-if="item.health && item.health.label"
class="health-pill" :class="item.health.level">{{ item.health.label }}</span>
<span v-else>-</span>
</td>
<td>{{ item.loggedinuser || '-' }}</td>
<td>{{ item.uptimedisplay || '-' }}</td>
<td>{{ formatPercent(item.cpupercent) }}</td>
<td>{{ formatPercent(item.memorypercent) }}</td>
<td>{{ formatTime(item.lastreportedat) }}</td>
<td>{{ formatRelative(item.lastreportedat) }}</td>
</tr>
<tr v-if="endpoints.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">
<td :colspan="cancommand ? 9 : 8" style="text-align: center; color: var(--text-light);">
No endpoints found
</td>
</tr>
@@ -69,25 +150,155 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { endpointsApi } from '@/api'
import { endpointsApi, computersApi, locationsApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const router = useRouter()
const endpoints = ref([])
const loading = ref(true)
// A failed request used to leave endpoints as [] and render "No endpoints
// found", which is the same thing a healthy empty fleet shows. A trailing-slash
// redirect broke this list in production while the agent reported correctly all
// along, and the page said only that there was nothing to see. An error must
// look like an error.
const loadError = ref('')
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadEndpoints })
const totalPages = ref(1)
const perPage = ref(20)
const status = ref('')
// Worst first by default. Sorting by last-reported floats the healthiest
// endpoints to the top and buries the ones somebody needs to act on.
const sort = ref('status')
const summary = ref(null)
const criticalcount = ref(0)
const computertypeid = ref('')
const locationid = ref('')
const problemsonly = ref(false)
const computertypes = ref([])
const locations = ref([])
const selected = ref([])
const busy = ref(false)
const authStore = useAuthStore()
const toast = useToast()
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
const cancommand = computed(() => authStore.hasPermission('endpoints.command'))
const canrestart = computed(() => authStore.hasPermission('endpoints.restart'))
const allselected = computed(() =>
endpoints.value.length > 0 && selected.value.length === endpoints.value.length)
function toggleAll(event) {
selected.value = event.target.checked ? endpoints.value.map(row => row.assetid) : []
}
// What a restart actually costs, in the words that matter on a shop floor. A
// dialog listing eight hostnames tells an operator nothing about what they are
// about to halt; the machines those PCs serve is the real consequence.
function machinesFor(rows) {
const names = []
rows.forEach(row => (row.machines || []).forEach(machine => {
const label = machineLabel(machine)
if (label && !names.includes(label)) names.push(label)
}))
return names
}
function rowsFor(assetids) {
return endpoints.value.filter(row => assetids.includes(row.assetid))
}
async function collectSelected() {
await runCommands(rowsFor(selected.value), 'collect_now', 'Collect now')
}
async function restartSelected() {
const rows = rowsFor(selected.value)
if (!confirmRestart(rows)) return
await runCommands(rows, 'system_restart', 'Restart')
}
function confirmRestart(rows) {
const machines = machinesFor(rows)
const hosts = rows.map(row => row.hostname).join(', ')
const consequence = machines.length
? `\n\nThis STOPS the machines these PCs serve:\n ${machines.join('\n ')}`
: '\n\nNo machine is recorded against these PCs.'
return confirm(
`Restart ${rows.length} computer(s)?\n\n${hosts}${consequence}\n\n`
+ 'They reboot immediately with no grace period. Unsaved work is lost.')
}
async function runCommands(rows, action, label) {
if (!rows.length) return
busy.value = true
try {
const results = await Promise.allSettled(
rows.map(row => endpointsApi.enqueueCommand(row.assetid, action)))
const queued = results.filter(r => r.status === 'fulfilled').length
// 409 means a command is ALREADY in flight for that endpoint - the queue
// permits one at a time. That is a skip, not a failure, and saying so
// stops it reading as a broken button.
const skipped = results.filter(
r => r.status === 'rejected' && r.reason?.response?.status === 409).length
const failed = results.length - queued - skipped
if (queued) toast.success(`${label} queued for ${queued} endpoint(s)`)
if (skipped) toast.info(`${skipped} skipped - a command is already in flight`)
if (failed) {
const first = results.find(
r => r.status === 'rejected' && r.reason?.response?.status !== 409)
toast.error(apiError(first?.reason, `${label} failed on ${failed} endpoint(s)`))
}
selected.value = []
} finally {
busy.value = false
}
}
let searchTimeout = null
onMounted(() => {
loadEndpoints()
loadSummary()
loadFilterOptions()
})
async function loadFilterOptions() {
// listAll, not list: perpage is clamped to 100 server-side, so a long type or
// location list would silently lose its tail in a dropdown.
try {
computertypes.value = await computersApi.types.listAll()
} catch (error) {
computertypes.value = []
}
try {
locations.value = await locationsApi.listAll()
} catch (error) {
locations.value = []
}
}
async function loadSummary() {
try {
const response = await endpointsApi.dashboardSummary()
summary.value = response.data.data || null
const stability = await endpointsApi.dashboardStability()
criticalcount.value = (stability.data.data || []).length
} catch (error) {
// The strip is a convenience, not the page. If it fails the table still
// works, so say nothing rather than blocking on it.
summary.value = null
}
}
async function loadEndpoints() {
loading.value = true
try {
@@ -96,12 +307,32 @@ async function loadEndpoints() {
perpage: perPage.value
}
if (search.value) params.search = search.value
if (status.value) params.status = status.value
if (sort.value) params.sort = sort.value
if (computertypeid.value) params.computertypeid = computertypeid.value
if (locationid.value) params.locationid = locationid.value
loadError.value = ''
const response = await endpointsApi.list(params)
endpoints.value = response.data.data || []
const rows = response.data.data || []
endpoints.value = problemsonly.value
? rows.filter(row => row.health && row.health.level !== 'ok')
: rows
// Selections do not survive a reload: the rows they referred to may not be
// on this page any more, and acting on an invisible selection is how bulk
// tools surprise people.
selected.value = []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading endpoints:', error)
endpoints.value = []
// Name the status. 403 and 404 are the two that get misread as "no
// endpoints": the first means the role lacks endpoints.view, the second
// that the request never reached the route at all.
const status = error.response?.status
loadError.value = status
? `Could not load endpoints (HTTP ${status}). This is a failed request, not an empty fleet.`
: 'Could not reach the server to load endpoints.'
} finally {
loading.value = false
}
@@ -115,14 +346,56 @@ function openPc(item) {
}
}
function formatPercent(value) {
if (value === null || value === undefined) return '-'
return `${Math.round(value)}%`
function statusLabel(value) {
return { reporting: 'Reporting', quiet: 'Quiet', never: 'Never' }[value] || '-'
}
function formatTime(value) {
if (!value) return '-'
return new Date(value).toLocaleString()
// Relative beats absolute here: "4m ago" answers "is this box alive" at a
// glance, where a timestamp makes the reader do the subtraction.
function formatRelative(value) {
if (!value) return 'never'
const then = new Date(value)
if (isNaN(then)) return '-'
const mins = Math.floor((Date.now() - then.getTime()) / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins} m ago`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours} h ago`
return `${Math.floor(hours / 24)} d ago`
}
function setStatus(value) {
status.value = value
problemsonly.value = false
applyFilters()
}
function clearFilters() {
status.value = ''
problemsonly.value = false
applyFilters()
}
// Client-side, unlike the other filters: "has a problem" is the health rollup,
// which is assembled per row after pagination rather than in the query. Sorting
// already puts trouble first, so this narrows the page rather than the fleet -
// worth knowing before someone reads the count as fleet-wide.
function toggleProblems() {
problemsonly.value = !problemsonly.value
status.value = ''
applyFilters()
}
function machineLabel(machine) {
if (machine.name && machine.assetnumber && machine.name !== machine.assetnumber) {
return `${machine.assetnumber} ${machine.name}`
}
return machine.assetnumber || machine.name || ''
}
function applyFilters() {
setPage(1)
loadEndpoints()
}
function debouncedSearch() {
@@ -150,4 +423,117 @@ function changePerPage(newPerPage) {
cursor: default;
opacity: 0.6;
}
.load-error {
padding: 1.5rem;
text-align: center;
color: var(--danger);
}
.fleet-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.fleet-tile {
display: flex;
flex-direction: column;
align-items: flex-start;
background: var(--bg-card);
border: 1px solid var(--border);
/* Matches the global .card radius. The tiles were 6px, which left Fleet
slightly rounder than every other page. */
border-radius: 0.25rem;
padding: 0.8rem 0.95rem;
cursor: pointer;
font: inherit;
/* A button does NOT inherit color the way it inherits font, so without this
the tile renders in the browser default black - invisible on the dark
theme's card. The coloured tiles set their own color and hid the bug; the
plain total did not. */
color: var(--text);
text-align: left;
}
.fleet-tile:hover,
.fleet-tile.on { border-color: var(--primary); }
.fleet-tile .n {
font-size: 1.6rem;
font-weight: 700;
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.fleet-tile .l {
font-size: 0.8rem;
color: var(--text-light);
}
.fleet-tile.ok .n { color: var(--success-dark); }
.fleet-tile.warn .n { color: var(--warning-dark); }
.fleet-tile.bad .n { color: var(--danger); }
/* Dark mode redefines --success and --warning to bright variants, but NOT the
-dark ones, which stay tuned for a white background. Left alone they render
dark green and dark amber on the dark card. Scoped to the stamped attribute
(the theme store always sets it) rather than prefers-color-scheme, which
would leak dark styling into light mode on a dark-OS machine. */
[data-theme="dark"] .fleet-tile.ok .n { color: var(--success); }
[data-theme="dark"] .fleet-tile.warn .n { color: var(--warning); }
.health-pill.ok { color: var(--success-dark); }
.health-pill.warning { color: var(--warning-dark); }
.health-pill.critical { color: var(--danger); }
[data-theme="dark"] .health-pill.ok { color: var(--success); }
[data-theme="dark"] .health-pill.warning { color: var(--warning); }
.fleet-filter { max-width: 200px; }
.bulk-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.9rem;
margin-bottom: 0.75rem;
background: var(--bg-card);
border: 1px solid var(--primary);
border-radius: 0.25rem;
}
.bulk-count { font-size: 0.9rem; margin-right: 0.25rem; }
.select-cell { width: 1%; }
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.45rem;
vertical-align: 1px;
background: var(--text-light);
}
.status-dot.reporting { background: var(--success); }
.status-dot.quiet { background: var(--warning); }
.status-dot.never { background: var(--text-light); opacity: 0.5; }
/* Health is a phrase, not a score: "eDNC stopped" tells an operator what to
do, where a percentage does not. */
.health-pill {
display: inline-block;
font-size: 0.78rem;
font-weight: 500;
padding: 0.1rem 0.5rem;
border-radius: 10px;
background: var(--bg);
color: var(--text-light);
}
</style>

View File

@@ -118,6 +118,22 @@ class EndpointsPlugin(BasePlugin):
"""
return [
('endpoints.view', 'View endpoint telemetry', 'endpoints'),
# The FLEET-WIDE list and dashboards, separate from endpoints.view,
# which covers reading ONE endpoint that the caller already reached
# through its PC page.
#
# They were one permission, and that made a reasonable policy
# impossible to express: a site that wants signed-out visitors to
# see live status on a PC page had to hand them the whole fleet,
# including every logged-in username on the floor, because the
# panels and the list were gated on the same string.
#
# It is deliberately NOT named '.view'. guest_grantable_permissions
# opens any '.view' or '.read' permission to the guest role, so this
# name is what keeps the fleet roster off the public surface while
# per-asset telemetry can still be granted.
('endpoints.fleet',
'View the fleet-wide endpoint list and dashboards', 'endpoints'),
# Issue an allowlisted command to an endpoint agent (for example the
# MTConnect toggle). Separate from endpoints.view because acting on a
# shop-floor PC is a heavier grant than reading its telemetry.
@@ -204,7 +220,7 @@ class EndpointsPlugin(BasePlugin):
'icon': 'activity',
'route': '/endpoints',
'position': 16,
'permission': 'endpoints.view',
'permission': 'endpoints.fleet',
'section': 'assets',
},
]
@@ -288,10 +304,16 @@ class EndpointsPlugin(BasePlugin):
'position': 13,
},
{
'id': 'endpoints-events',
'title': 'Recent events',
# One feed instead of two cards. Events and commands answer the
# same question - what happened to this box - and reading them
# separately meant interleaving two lists by eye. Sits high on
# the page because it is the first thing looked at when
# something has gone wrong, and NOT collapsed by default for
# the same reason.
'id': 'endpoints-activity',
'title': 'Activity',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/recentevents',
'endpoint': '/api/endpoints/asset/{assetid}/activity',
'render': 'list',
'collapsible': True,
'status': {
@@ -313,12 +335,12 @@ class EndpointsPlugin(BasePlugin):
},
},
'meta': [
{'key': 'detail'},
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
],
},
'empty': 'No recent events.',
'collapsedDefault': True,
'position': 90,
'empty': 'Nothing recorded for this endpoint yet.',
'position': 20,
},
{
'id': 'endpoints-interfaces',
@@ -355,7 +377,7 @@ class EndpointsPlugin(BasePlugin):
'endpoint': '/api/endpoints/dashboard/quiet',
'render': 'exceptions',
'severity': 'warning',
'permission': 'endpoints.view',
'permission': 'endpoints.fleet',
'empty': 'hide',
'position': 22,
'map': {
@@ -373,7 +395,7 @@ class EndpointsPlugin(BasePlugin):
'endpoint': '/api/endpoints/dashboard/stability',
'render': 'exceptions',
'severity': 'critical',
'permission': 'endpoints.view',
'permission': 'endpoints.fleet',
'empty': 'hide',
'position': 16,
'map': {