@@ -249,9 +268,6 @@
-
-
-
@@ -285,6 +301,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 +318,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 +346,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 +394,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)
diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py
index 68c8b69a..8c388e50 100644
--- a/plugins/endpoints/api/routes.py
+++ b/plugins/endpoints/api/routes.py
@@ -18,7 +18,7 @@ from shopdb.api import (
ErrorCodes, get_pagination_params, paginate_query, require_permission,
require_role, caller_holds, mint_service_token, list_service_tokens,
revoke_service_tokens,
- AuditLog, collector_authorized,
+ AuditLog, collector_authorized, User,
)
from ..models import EndpointState, EndpointEvent, EndpointCommand
@@ -306,7 +306,13 @@ def _health_for(state, criticalcount):
@endpoints_bp.route('/
', 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:
@@ -316,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('//events', methods=['GET'])
@@ -473,6 +490,84 @@ def panel_recent_events(asset_id: int):
return success_response(rows)
+@endpoints_bp.route('/asset//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//interfaces', methods=['GET'])
@require_permission('endpoints.view')
def panel_interfaces(asset_id: int):
diff --git a/plugins/endpoints/frontend/views/EndpointsList.vue b/plugins/endpoints/frontend/views/EndpointsList.vue
index c2dc9cfe..6cf87bf1 100644
--- a/plugins/endpoints/frontend/views/EndpointsList.vue
+++ b/plugins/endpoints/frontend/views/EndpointsList.vue
@@ -274,6 +274,11 @@ function changePerPage(newPerPage) {
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;
}
@@ -296,6 +301,21 @@ function changePerPage(newPerPage) {
.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; }
.status-dot {
@@ -324,7 +344,4 @@ function changePerPage(newPerPage) {
color: var(--text-light);
}
-.health-pill.ok { color: var(--success-dark); }
-.health-pill.warning { color: var(--warning-dark); }
-.health-pill.critical { color: var(--danger); }
diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py
index 348c157b..944d3a05 100644
--- a/plugins/endpoints/plugin.py
+++ b/plugins/endpoints/plugin.py
@@ -289,10 +289,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': {
@@ -314,12 +320,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',