Merge origin/main into wilm: reconcile endpoint agent dev with release channel
# Conflicts: # plugins/endpoints/api/routes.py
This commit is contained in:
12
.env.example
12
.env.example
@@ -36,6 +36,18 @@ DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@db:3306/shopdb_flask?charset=utf8m
|
||||
# Wildcard '*' is rejected by ProductionConfig.validate().
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
# ---- Reverse proxy (optional, default 0 = no proxy) ----
|
||||
# Set to the number of proxies actually in front of this instance - 1 when IIS
|
||||
# or nginx serves it under a subpath such as /shopdb. Without it the app sees
|
||||
# the proxy's connection instead of the browser's (scheme http, the proxy's
|
||||
# host and IP), so any absolute URL it builds is wrong at the browser. That is
|
||||
# what made a redirect on the endpoints list unfollowable behind IIS.
|
||||
#
|
||||
# Leave it at 0 when nothing fronts the app. It makes X-Forwarded-* trusted,
|
||||
# and a client can send those headers itself - the login rate limit and the
|
||||
# account lockout are keyed on the client address.
|
||||
TRUSTED_PROXY_COUNT=0
|
||||
|
||||
# ---- JWT lifecycle (optional, defaults shown) ----
|
||||
JWT_ACCESS_TOKEN_EXPIRES=3600
|
||||
JWT_REFRESH_TOKEN_EXPIRES=2592000
|
||||
|
||||
@@ -10820,6 +10820,57 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/computers/asset/{asset_id}/policypanel": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugin-computers"
|
||||
],
|
||||
"summary": "Asset-detail panel: which policy governs this endpoint, how many configuration policies folded into it, and how many...",
|
||||
"description": "Asset-detail panel: which policy governs this endpoint, how many configuration policies folded into it, and how many actions it allows or drives to a desired state. Provenance rather than a control - the effective policy narrows the command allowlist, so this is the answer to why an action is unavailable on a given PC. Returns an empty field list (not the feature-gate 404) when the policy feature is off, so the card self-hides instead of erroring on every site that has not enabled it\n\n**Auth:** computers.view\n\n**Params:** path: asset_id\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/asset/1/policypanel\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/measuringtools/types": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -21733,13 +21784,13 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/endpoints/": {
|
||||
"/api/endpoints": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugin-endpoints"
|
||||
],
|
||||
"summary": "List endpoints reporting through the ShopDB agent, with last-seen and online state",
|
||||
"description": "List endpoints reporting through the ShopDB agent, with last-seen and online state\n\n**Auth:** endpoints.view\n\n**Params:** query: search (hostname substring)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/\n```",
|
||||
"description": "List endpoints reporting through the ShopDB agent, with last-seen and online state\n\n**Auth:** endpoints.view\n\n**Params:** query: search (hostname substring)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
@@ -22077,6 +22128,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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -16,10 +16,18 @@ api.interceptors.request.use(config => {
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
// Send both perpage and per_page for backend compatibility
|
||||
if (config.params?.perpage) {
|
||||
config.params.per_page = config.params.perpage
|
||||
}
|
||||
// `per_page` used to be sent alongside `perpage` for backend compatibility.
|
||||
// get_pagination_params reads `perpage` and only falls back to `per_page`, and
|
||||
// this client always sends `perpage`, so the duplicate never did anything -
|
||||
// it just made every list URL carry a redundant parameter.
|
||||
//
|
||||
// Dropping it also has a one-off benefit worth recording. The endpoints list
|
||||
// route used to redirect (308, PERMANENT and therefore cached by the browser
|
||||
// against the exact URL including its query string). Clients that hit the old
|
||||
// route hold that rewrite even after the server is fixed: the request never
|
||||
// leaves the machine, so nothing deployed can clear it. Changing the query
|
||||
// string changes the cache key, so those clients miss the poisoned entry and
|
||||
// reach the corrected route.
|
||||
return config
|
||||
})
|
||||
|
||||
@@ -148,6 +156,12 @@ export const machinesApi = {
|
||||
|
||||
// Endpoints API (plugin) - fleet telemetry read surface
|
||||
export const endpointsApi = {
|
||||
dashboardSummary() {
|
||||
return api.get('/endpoints/dashboard/summary')
|
||||
},
|
||||
dashboardStability() {
|
||||
return api.get('/endpoints/dashboard/stability')
|
||||
},
|
||||
list(params = {}) {
|
||||
return api.get('/endpoints', { params })
|
||||
},
|
||||
|
||||
205
frontend/src/components/AssetTriageBand.vue
Normal file
205
frontend/src/components/AssetTriageBand.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<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>
|
||||
|
||||
<!-- Actions that act on the THING, not on its record. A page fills this
|
||||
with its own verbs - restart a PC, calibrate a measuring tool - so the
|
||||
band stays generic while the button sits beside the state it changes
|
||||
and the machine it would take down with it. Record actions (edit,
|
||||
retire, print, back) stay in the page header where they belong. -->
|
||||
<div v-if="$slots.actions" class="triage-actions">
|
||||
<slot name="actions"></slot>
|
||||
</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, useSlots } 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 slots = useSlots()
|
||||
|
||||
const hasAnything = computed(() =>
|
||||
Boolean(slots.actions) ||
|
||||
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-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -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': {
|
||||
|
||||
@@ -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
|
||||
@@ -131,6 +138,28 @@ def create_app(config_name: str = None) -> Flask:
|
||||
if envname.startswith('COLLECTOR_API_KEY_') and envvalue:
|
||||
app.config[envname] = envvalue
|
||||
|
||||
# Behind a reverse proxy (IIS at /shopdb, nginx, a load balancer), the app
|
||||
# sees the PROXY's connection, not the browser's: scheme http even though
|
||||
# the user is on https, the proxy's host, the proxy's IP. Anything Flask
|
||||
# builds from that is wrong at the browser - an absolute redirect Location
|
||||
# above all, which is how a trailing-slash redirect on the endpoints list
|
||||
# sent an https page to an http URL the browser then refused to follow.
|
||||
#
|
||||
# OFF BY DEFAULT AND DELIBERATELY. ProxyFix makes the app TRUST
|
||||
# X-Forwarded-* headers, and a client can send those itself. Enabled with no
|
||||
# proxy in front, any caller can claim a different scheme or source IP -
|
||||
# which matters here because the login rate limit and the lockout are keyed
|
||||
# on the client address. Set TRUSTED_PROXY_COUNT to the number of proxies
|
||||
# actually in front of this instance (1 for a single IIS or nginx), and only
|
||||
# when one really is.
|
||||
proxycount = app.config.get('TRUSTED_PROXY_COUNT', 0)
|
||||
if proxycount:
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
app.wsgi_app = ProxyFix(
|
||||
app.wsgi_app,
|
||||
x_for=proxycount, x_proto=proxycount,
|
||||
x_host=proxycount, x_prefix=proxycount)
|
||||
|
||||
# Ensure instance folder exists
|
||||
os.makedirs(app.instance_path, exist_ok=True)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -49,6 +49,15 @@ class Config:
|
||||
seconds=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 2592000))
|
||||
)
|
||||
|
||||
# Number of reverse proxies in front of this instance. 0 (the default)
|
||||
# means none, and the app reads the connection it actually receives.
|
||||
# Set to 1 when IIS or nginx fronts it, so X-Forwarded-Proto/Host/Prefix are
|
||||
# honoured and a generated absolute URL matches what the browser asked for.
|
||||
# Do NOT set it without a proxy: it makes the app believe forwarded headers
|
||||
# a client can send itself, and the login rate limit and account lockout are
|
||||
# keyed on the client address.
|
||||
TRUSTED_PROXY_COUNT = int(os.environ.get('TRUSTED_PROXY_COUNT', 0))
|
||||
|
||||
CORS_ORIGINS = [
|
||||
origin.strip()
|
||||
for origin in os.environ.get('CORS_ORIGINS', 'http://localhost:5173').split(',')
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
44
tests/test_core/test_no_redirecting_routes.py
Normal file
44
tests/test_core/test_no_redirecting_routes.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""No API route may answer a redirect just because of a trailing slash.
|
||||
|
||||
A rule registered as '/api/thing/' does not merely accept that URL - Werkzeug
|
||||
answers '/api/thing' with a 308 to the slashed form, and the Location it builds
|
||||
is ABSOLUTE. That is fine on the dev server, where scheme and host are what the
|
||||
app thinks they are. It is not fine behind the IIS subpath: with no ProxyFix the
|
||||
app emits http:// and without the /shopdb prefix, the browser refuses to follow
|
||||
an https page to http, and the call fails.
|
||||
|
||||
What made it expensive is where the failure surfaced. The Fleet page caught the
|
||||
error, logged it to the console and rendered "No endpoints found", which reads
|
||||
exactly like a fleet with nothing in it. The agent had been reporting correctly
|
||||
for hours. Every other route in the plugin worked, because every other route is
|
||||
registered without a trailing slash - '/api/endpoints/' was the only one in 331
|
||||
paths, and it was the only thing broken.
|
||||
|
||||
So the rule is: register API routes WITHOUT a trailing slash, or pass
|
||||
strict_slashes=False so both spellings match directly. Either way no client can
|
||||
be handed a redirect it cannot follow.
|
||||
"""
|
||||
|
||||
|
||||
def test_no_api_route_redirects_on_a_trailing_slash(app):
|
||||
# Takes the shared `app` fixture rather than calling create_app itself.
|
||||
# Building a second application inside a test rebinds SQLAlchemy and leaves
|
||||
# the NEXT test module without tables - it passes alone and fails only in a
|
||||
# full run, as "no such table: settings" three modules later. This file
|
||||
# caused exactly that once already.
|
||||
offenders = sorted(
|
||||
rule.rule for rule in app.url_map.iter_rules()
|
||||
if rule.rule.startswith('/api')
|
||||
and rule.rule.endswith('/')
|
||||
and rule.rule != '/api/'
|
||||
and rule.strict_slashes
|
||||
)
|
||||
|
||||
assert not offenders, (
|
||||
'%d API route(s) are registered with a trailing slash AND strict '
|
||||
'slashes, so Flask answers the unslashed URL with a 308 to an absolute '
|
||||
'URL. Behind a reverse proxy that redirect is unfollowable and the call '
|
||||
'fails, usually looking like an empty list rather than an error.\n'
|
||||
'Register the route without the trailing slash, or pass '
|
||||
'strict_slashes=False:\n %s'
|
||||
% (len(offenders), '\n '.join(offenders)))
|
||||
Reference in New Issue
Block a user