Fleet list rebuild and the endpoints permission split
This commit is contained in:
@@ -156,6 +156,9 @@ export const machinesApi = {
|
||||
|
||||
// Endpoints API (plugin) - fleet telemetry read surface
|
||||
export const endpointsApi = {
|
||||
dashboardSummary() {
|
||||
return api.get('/endpoints/dashboard/summary')
|
||||
},
|
||||
list(params = {}) {
|
||||
return api.get('/endpoints', { params })
|
||||
},
|
||||
|
||||
@@ -16,7 +16,8 @@ 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,
|
||||
require_role, caller_holds, mint_service_token, list_service_tokens,
|
||||
revoke_service_tokens,
|
||||
AuditLog, collector_authorized,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
@@ -56,7 +73,7 @@ def _format_uptime(seconds):
|
||||
# =============================================================================
|
||||
|
||||
@endpoints_bp.route('', methods=['GET'])
|
||||
@require_permission('endpoints.view')
|
||||
@require_permission('endpoints.fleet')
|
||||
def list_endpoints():
|
||||
"""List reported endpoint states.
|
||||
|
||||
@@ -77,32 +94,215 @@ def list_endpoints():
|
||||
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):
|
||||
@@ -209,6 +409,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):
|
||||
@@ -223,14 +425,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,
|
||||
@@ -313,7 +519,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(
|
||||
@@ -331,16 +537,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.
|
||||
|
||||
@@ -375,7 +593,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,15 +4,44 @@
|
||||
<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 === '' }" @click="setStatus('')">
|
||||
<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>
|
||||
</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="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>
|
||||
|
||||
<div class="card">
|
||||
@@ -27,11 +56,13 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
@@ -43,15 +74,28 @@
|
||||
:class="{ disabled: !item.computerid }"
|
||||
@click="openPc(item)"
|
||||
>
|
||||
<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(m => m.assetnumber).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="8" style="text-align: center; color: var(--text-light);">
|
||||
No endpoints found
|
||||
</td>
|
||||
</tr>
|
||||
@@ -91,13 +135,30 @@ 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)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadEndpoints()
|
||||
loadSummary()
|
||||
})
|
||||
|
||||
async function loadSummary() {
|
||||
try {
|
||||
const response = await endpointsApi.dashboardSummary()
|
||||
summary.value = response.data.data || null
|
||||
} 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 {
|
||||
@@ -106,6 +167,8 @@ 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
|
||||
|
||||
loadError.value = ''
|
||||
const response = await endpointsApi.list(params)
|
||||
@@ -134,14 +197,32 @@ 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
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
setPage(1)
|
||||
loadEndpoints()
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
@@ -175,4 +256,75 @@ function changePerPage(newPerPage) {
|
||||
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);
|
||||
border-radius: 6px;
|
||||
padding: 0.8rem 0.95rem;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
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); }
|
||||
|
||||
.fleet-filter { max-width: 200px; }
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.health-pill.ok { color: var(--success-dark); }
|
||||
.health-pill.warning { color: var(--warning-dark); }
|
||||
.health-pill.critical { color: var(--danger); }
|
||||
</style>
|
||||
|
||||
@@ -103,6 +103,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.
|
||||
@@ -189,7 +205,7 @@ class EndpointsPlugin(BasePlugin):
|
||||
'icon': 'activity',
|
||||
'route': '/endpoints',
|
||||
'position': 16,
|
||||
'permission': 'endpoints.view',
|
||||
'permission': 'endpoints.fleet',
|
||||
'section': 'assets',
|
||||
},
|
||||
]
|
||||
@@ -340,7 +356,7 @@ class EndpointsPlugin(BasePlugin):
|
||||
'endpoint': '/api/endpoints/dashboard/quiet',
|
||||
'render': 'exceptions',
|
||||
'severity': 'warning',
|
||||
'permission': 'endpoints.view',
|
||||
'permission': 'endpoints.fleet',
|
||||
'empty': 'hide',
|
||||
'position': 22,
|
||||
'map': {
|
||||
@@ -358,7 +374,7 @@ class EndpointsPlugin(BasePlugin):
|
||||
'endpoint': '/api/endpoints/dashboard/stability',
|
||||
'render': 'exceptions',
|
||||
'severity': 'critical',
|
||||
'permission': 'endpoints.view',
|
||||
'permission': 'endpoints.fleet',
|
||||
'empty': 'hide',
|
||||
'position': 16,
|
||||
'map': {
|
||||
|
||||
@@ -81,7 +81,14 @@ from .plugins import plugin_manager
|
||||
# endpoints command claim/result long-poll callbacks) on the same per-plugin
|
||||
# collector key + managed-token check the ingest route uses, without importing
|
||||
# core collector internals. Additive name on the surface, minor bump.
|
||||
__contract_version__ = '0.24.0'
|
||||
# 0.25.0: added caller_holds(permission) to shopdb.api so a route can ask
|
||||
# whether THIS caller holds a permission without refusing the request. It is for
|
||||
# a surface that serves everyone but should only OFFER what the caller can use:
|
||||
# the endpoints services panel emitted Start/Stop to every reader because the
|
||||
# emission never consulted the caller, so a read-only viewer was shown controls
|
||||
# over a shop-floor PC that 403 on click. Guests are included, since a signed-out
|
||||
# visitor holds whatever the guest role was granted. Additive name, minor bump.
|
||||
__contract_version__ = '0.25.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
|
||||
@@ -68,7 +68,8 @@ from shopdb.utils.responses import (
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
|
||||
# Authorization decorators for gating plugin write routes
|
||||
from shopdb.utils.authz import require_permission, require_any_permission, require_role
|
||||
from shopdb.utils.authz import (require_permission, require_any_permission,
|
||||
require_role, caller_holds)
|
||||
|
||||
# Service-token authorization for unattended plugin endpoints (collector,
|
||||
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token
|
||||
@@ -331,6 +332,7 @@ __all__ = [
|
||||
# Authorization decorators
|
||||
'require_permission',
|
||||
'require_any_permission',
|
||||
'caller_holds',
|
||||
'require_role',
|
||||
'service_token_authorized',
|
||||
'authorized_service_token',
|
||||
|
||||
@@ -86,6 +86,32 @@ def guestholds(permission_name: str) -> bool:
|
||||
return any(p.name == permission_name for p in role.permissions)
|
||||
|
||||
|
||||
def caller_holds(permission_name: str) -> bool:
|
||||
"""Whether THIS caller holds a permission, without refusing the request.
|
||||
|
||||
require_permission answers the same question by returning 401/403. This
|
||||
answers it in the body, for the case where a route serves everyone but
|
||||
should only OFFER something to the callers who can use it.
|
||||
|
||||
The endpoints services panel is the example that prompted it: it emitted
|
||||
Start/Stop buttons for every reader, because the emission never consulted
|
||||
the caller. Clicking them 403s, so nothing was exploitable, but showing a
|
||||
read-only viewer controls over a shop-floor PC is the wrong signal - and it
|
||||
gets worse the moment a site grants that panel to guests.
|
||||
|
||||
Guests are included on purpose: a signed-out visitor holds whatever the
|
||||
guest role was granted, so the answer must come from the same place
|
||||
require_permission gets it.
|
||||
"""
|
||||
user = currentuser()
|
||||
if user is None:
|
||||
return guestholds(permission_name)
|
||||
try:
|
||||
return bool(user.haspermission(permission_name))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def require_permission(permission_name: str):
|
||||
"""Gate a route behind a single permission. Admin role bypasses.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user