Merge origin/main into wilm: reconcile endpoint agent dev with release channel

# Conflicts:
#	plugins/endpoints/api/routes.py
This commit is contained in:
benjamin.baker
2026-08-30 08:46:04 -04:00
15 changed files with 1396 additions and 69 deletions

View File

@@ -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

View File

@@ -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": [

View File

@@ -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 })
},

View 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>

View File

@@ -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})

View File

@@ -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)

View File

@@ -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 [

View File

@@ -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.

View File

@@ -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>

View File

@@ -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': {

View File

@@ -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)

View File

@@ -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',

View File

@@ -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(',')

View File

@@ -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.

View 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)))