Fleet console: triage band, activity feed, live state first
This commit is contained in:
@@ -22077,6 +22077,57 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/endpoints/asset/{asset_id}/activity": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"plugin-endpoints"
|
||||||
|
],
|
||||||
|
"summary": "Asset-detail panel: one chronological feed merging curated Windows events with operator commands, newest first, capped...",
|
||||||
|
"description": "Asset-detail panel: one chronological feed merging curated Windows events with operator commands, newest first, capped at 15. Both are keyed by assetid, so this is two indexed reads and a merge rather than a join. A failed command is marked critical. The requester name is withheld from callers who do not themselves hold endpoints.command, so a read-only viewer gets the history without the roster of who restarted what\n\n**Auth:** endpoints.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/asset/1/activity\n```",
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or invalid credentials."
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Authenticated, but not permitted."
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "No such record."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "asset_id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/endpoints/asset/{asset_id}/interfaces": {
|
"/api/endpoints/asset/{asset_id}/interfaces": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
185
frontend/src/components/AssetTriageBand.vue
Normal file
185
frontend/src/components/AssetTriageBand.vue
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="hasAnything" class="triage-band">
|
||||||
|
<div v-if="liveness" class="triage-item">
|
||||||
|
<span class="triage-label">Status</span>
|
||||||
|
<span class="triage-chip" :class="liveness.level">
|
||||||
|
<span class="triage-dot"></span>{{ liveness.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="machines.length" class="triage-item">
|
||||||
|
<span class="triage-label">{{ machines.length === 1 ? 'Serves machine' : 'Serves machines' }}</span>
|
||||||
|
<span class="triage-values">
|
||||||
|
<router-link
|
||||||
|
v-for="machine in machines"
|
||||||
|
:key="machine.assetid"
|
||||||
|
class="triage-chip link"
|
||||||
|
:to="`/assets/${machine.assetid}`"
|
||||||
|
>{{ machine.assetnumber || machine.name }}</router-link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="accessmethods && accessmethods.length" class="triage-item">
|
||||||
|
<span class="triage-label">Remote access</span>
|
||||||
|
<span class="triage-values">
|
||||||
|
<a
|
||||||
|
v-for="method in accessmethods"
|
||||||
|
:key="method.name || method.protocol"
|
||||||
|
class="triage-chip link"
|
||||||
|
:class="{ disabled: !method.link }"
|
||||||
|
:href="method.link || undefined"
|
||||||
|
>{{ method.name || method.protocol }}</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="locationname" class="triage-item">
|
||||||
|
<span class="triage-label">Location</span>
|
||||||
|
<span class="triage-value">{{ locationname }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* The triage band: what somebody needs at 6am with a machine down.
|
||||||
|
*
|
||||||
|
* Core-owned and shared on purpose. The five asset detail pages each rebuild
|
||||||
|
* their own hero and grid, which is how they drifted apart; anything new should
|
||||||
|
* be written once so machines, printers and network devices can adopt it
|
||||||
|
* without a fifth copy.
|
||||||
|
*
|
||||||
|
* "Serves machine" is the reason this exists. On a shop floor a PC matters
|
||||||
|
* because a machine stops without it, and that fact was buried in the generic
|
||||||
|
* relationships card near the bottom of the page.
|
||||||
|
*/
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assetid: { type: Number, required: true },
|
||||||
|
// Optional because liveness is contributed, not core: only a plugin that
|
||||||
|
// collects telemetry can answer it, and a site may not have one installed.
|
||||||
|
// Shape: { level: 'ok'|'warning'|'critical'|'unknown', label: 'Reporting, 4m ago' }
|
||||||
|
liveness: { type: Object, default: null },
|
||||||
|
accessmethods: { type: Array, default: () => [] },
|
||||||
|
locationname: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const machines = ref([])
|
||||||
|
|
||||||
|
const hasAnything = computed(() =>
|
||||||
|
Boolean(props.liveness) ||
|
||||||
|
machines.value.length > 0 ||
|
||||||
|
(props.accessmethods && props.accessmethods.length > 0) ||
|
||||||
|
Boolean(props.locationname)
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(loadMachines)
|
||||||
|
watch(() => props.assetid, loadMachines)
|
||||||
|
|
||||||
|
async function loadMachines() {
|
||||||
|
machines.value = []
|
||||||
|
if (!props.assetid) return
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/assets/${props.assetid}/relationships`)
|
||||||
|
const data = response.data.data || {}
|
||||||
|
const found = []
|
||||||
|
// BOTH directions. The canonical edge is PC controls machine, but
|
||||||
|
// production also holds reciprocal rows, so reading one direction shows
|
||||||
|
// the link on half the pages and not the other half.
|
||||||
|
for (const edge of data.outgoing || []) collect(edge, edge.targetasset, found)
|
||||||
|
for (const edge of data.incoming || []) collect(edge, edge.sourceasset, found)
|
||||||
|
machines.value = found
|
||||||
|
} catch (error) {
|
||||||
|
// A band that cannot load its links simply does not show them. It sits
|
||||||
|
// above the fold on a page whose other cards are fine.
|
||||||
|
machines.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect(edge, other, found) {
|
||||||
|
if (!other) return
|
||||||
|
if ((edge.relationshiptypename || '').toLowerCase() !== 'controls') return
|
||||||
|
if ((other.assettypename || '').toLowerCase() !== 'machine') return
|
||||||
|
if (found.some(m => m.assetid === other.assetid)) return
|
||||||
|
found.push({ assetid: other.assetid, assetnumber: other.assetnumber, name: other.name })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.triage-band {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.25rem 2rem;
|
||||||
|
padding: 0.9rem 0 0;
|
||||||
|
margin-top: 0.9rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-values {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-value { font-size: 0.95rem; }
|
||||||
|
|
||||||
|
.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);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triage-chip.link { border-color: var(--primary); color: var(--primary); }
|
||||||
|
.triage-chip.link:hover { background: var(--bg-card); }
|
||||||
|
|
||||||
|
.triage-chip.disabled {
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-light);
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* Dark mode redefines --success and --warning to bright variants but leaves
|
||||||
|
the -dark ones tuned for a white background, so they read as dark green and
|
||||||
|
dark amber on the dark card. Scoped to the stamped attribute, matching
|
||||||
|
style.css: a bare prefers-color-scheme query leaks into light mode on a
|
||||||
|
dark-OS machine. */
|
||||||
|
[data-theme="dark"] .triage-chip.ok { color: var(--success); }
|
||||||
|
[data-theme="dark"] .triage-chip.warning { color: var(--warning); }
|
||||||
|
</style>
|
||||||
@@ -49,12 +49,32 @@
|
|||||||
<span class="hero-detail-value">{{ computer.locationname }}</span>
|
<span class="hero-detail-value">{{ computer.locationname }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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 || []"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||||
<!-- Main Content Grid -->
|
<!-- 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">
|
<div class="content-grid">
|
||||||
|
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
|
||||||
|
|
||||||
<!-- Left Column -->
|
<!-- Left Column -->
|
||||||
<div class="content-column">
|
<div class="content-column">
|
||||||
<!-- Identity Section -->
|
<!-- Identity Section -->
|
||||||
@@ -125,20 +145,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Remote Access -->
|
<!-- Remote Access lives in the triage band at the top of the page
|
||||||
<div class="section-card">
|
now: it is what a responder clicks first, and rendering it twice
|
||||||
<h3 class="section-title">Remote Access</h3>
|
made the page longer without telling anyone anything. -->
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Status / Check-in -->
|
<!-- Status / Check-in, from the COMPUTER RECORD (the asset-report
|
||||||
<div class="section-card">
|
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>
|
<h3 class="section-title">Status</h3>
|
||||||
<div class="info-list">
|
<div class="info-list">
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
@@ -249,9 +268,6 @@
|
|||||||
<!-- Custom Fields -->
|
<!-- Custom Fields -->
|
||||||
<CustomFieldsSection :assetid="computer.assetid" />
|
<CustomFieldsSection :assetid="computer.assetid" />
|
||||||
|
|
||||||
<!-- Warranty -->
|
|
||||||
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
|
|
||||||
|
|
||||||
<!-- All relationships (controls, defaultprinter, ...) -->
|
<!-- All relationships (controls, defaultprinter, ...) -->
|
||||||
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
||||||
|
|
||||||
@@ -285,6 +301,7 @@ import { withBase } from '@/utils/basePath'
|
|||||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
||||||
|
import AssetTriageBand from '@/components/AssetTriageBand.vue'
|
||||||
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||||
@@ -301,6 +318,18 @@ const { isEnabled } = useIdentifierFlags()
|
|||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const computer = ref(null)
|
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 installedApps = ref([])
|
||||||
const assetPanels = ref(null)
|
const assetPanels = ref(null)
|
||||||
const restarting = ref(false)
|
const restarting = ref(false)
|
||||||
@@ -317,6 +346,42 @@ const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() =
|
|||||||
|
|
||||||
// relationships render via the shared AssetRelationships card
|
// 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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await computersApi.get(route.params.id)
|
const response = await computersApi.get(route.params.id)
|
||||||
@@ -329,6 +394,7 @@ onMounted(async () => {
|
|||||||
installedApps.value = appsResponse.data.data || []
|
installedApps.value = appsResponse.data.data || []
|
||||||
} catch (appError) {
|
} catch (appError) {
|
||||||
}
|
}
|
||||||
|
loadLiveness(computer.value?.assetid)
|
||||||
// Warranties load via useWarrantyBadge (watches computer.assetid).
|
// Warranties load via useWarrantyBadge (watches computer.assetid).
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading computer:', error)
|
console.error('Error loading computer:', error)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from shopdb.api import (
|
|||||||
ErrorCodes, get_pagination_params, paginate_query, require_permission,
|
ErrorCodes, get_pagination_params, paginate_query, require_permission,
|
||||||
require_role, caller_holds, mint_service_token, list_service_tokens,
|
require_role, caller_holds, mint_service_token, list_service_tokens,
|
||||||
revoke_service_tokens,
|
revoke_service_tokens,
|
||||||
AuditLog, collector_authorized,
|
AuditLog, collector_authorized, User,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..models import EndpointState, EndpointEvent, EndpointCommand
|
from ..models import EndpointState, EndpointEvent, EndpointCommand
|
||||||
@@ -306,7 +306,13 @@ def _health_for(state, criticalcount):
|
|||||||
@endpoints_bp.route('/<int:asset_id>', methods=['GET'])
|
@endpoints_bp.route('/<int:asset_id>', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.view')
|
||||||
def get_endpoint(asset_id: int):
|
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()
|
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||||
|
|
||||||
if not state:
|
if not state:
|
||||||
@@ -316,7 +322,18 @@ def get_endpoint(asset_id: int):
|
|||||||
http_code=404,
|
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'])
|
@endpoints_bp.route('/<int:asset_id>/events', methods=['GET'])
|
||||||
@@ -473,6 +490,84 @@ def panel_recent_events(asset_id: int):
|
|||||||
return success_response(rows)
|
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'])
|
@endpoints_bp.route('/asset/<int:asset_id>/interfaces', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.view')
|
||||||
def panel_interfaces(asset_id: int):
|
def panel_interfaces(asset_id: int):
|
||||||
|
|||||||
@@ -274,6 +274,11 @@ function changePerPage(newPerPage) {
|
|||||||
padding: 0.8rem 0.95rem;
|
padding: 0.8rem 0.95rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
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;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +301,21 @@ function changePerPage(newPerPage) {
|
|||||||
.fleet-tile.warn .n { color: var(--warning-dark); }
|
.fleet-tile.warn .n { color: var(--warning-dark); }
|
||||||
.fleet-tile.bad .n { color: var(--danger); }
|
.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; }
|
.fleet-filter { max-width: 200px; }
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
@@ -324,7 +344,4 @@ function changePerPage(newPerPage) {
|
|||||||
color: var(--text-light);
|
color: var(--text-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.health-pill.ok { color: var(--success-dark); }
|
|
||||||
.health-pill.warning { color: var(--warning-dark); }
|
|
||||||
.health-pill.critical { color: var(--danger); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -289,10 +289,16 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'position': 13,
|
'position': 13,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'id': 'endpoints-events',
|
# One feed instead of two cards. Events and commands answer the
|
||||||
'title': 'Recent events',
|
# 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'],
|
'assettypes': ['computer'],
|
||||||
'endpoint': '/api/endpoints/asset/{assetid}/recentevents',
|
'endpoint': '/api/endpoints/asset/{assetid}/activity',
|
||||||
'render': 'list',
|
'render': 'list',
|
||||||
'collapsible': True,
|
'collapsible': True,
|
||||||
'status': {
|
'status': {
|
||||||
@@ -314,12 +320,12 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
'meta': [
|
'meta': [
|
||||||
|
{'key': 'detail'},
|
||||||
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
|
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'empty': 'No recent events.',
|
'empty': 'Nothing recorded for this endpoint yet.',
|
||||||
'collapsedDefault': True,
|
'position': 20,
|
||||||
'position': 90,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'id': 'endpoints-interfaces',
|
'id': 'endpoints-interfaces',
|
||||||
|
|||||||
Reference in New Issue
Block a user