Merge origin/main into wilm: reconcile endpoint agent dev with release channel
# Conflicts: # plugins/endpoints/api/routes.py
This commit is contained in:
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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': {
|
||||
|
||||
Reference in New Issue
Block a user