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().
|
# Wildcard '*' is rejected by ProductionConfig.validate().
|
||||||
CORS_ORIGINS=http://localhost:5173
|
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 lifecycle (optional, defaults shown) ----
|
||||||
JWT_ACCESS_TOKEN_EXPIRES=3600
|
JWT_ACCESS_TOKEN_EXPIRES=3600
|
||||||
JWT_REFRESH_TOKEN_EXPIRES=2592000
|
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": {
|
"/api/measuringtools/types": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -21733,13 +21784,13 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/api/endpoints/": {
|
"/api/endpoints": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"plugin-endpoints"
|
"plugin-endpoints"
|
||||||
],
|
],
|
||||||
"summary": "List endpoints reporting through the ShopDB agent, with last-seen and online state",
|
"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": [
|
"security": [
|
||||||
{
|
{
|
||||||
"bearerAuth": []
|
"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": {
|
"/api/endpoints/asset/{asset_id}/interfaces": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -16,10 +16,18 @@ api.interceptors.request.use(config => {
|
|||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
// Send both perpage and per_page for backend compatibility
|
// `per_page` used to be sent alongside `perpage` for backend compatibility.
|
||||||
if (config.params?.perpage) {
|
// get_pagination_params reads `perpage` and only falls back to `per_page`, and
|
||||||
config.params.per_page = config.params.perpage
|
// 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
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -148,6 +156,12 @@ export const machinesApi = {
|
|||||||
|
|
||||||
// Endpoints API (plugin) - fleet telemetry read surface
|
// Endpoints API (plugin) - fleet telemetry read surface
|
||||||
export const endpointsApi = {
|
export const endpointsApi = {
|
||||||
|
dashboardSummary() {
|
||||||
|
return api.get('/endpoints/dashboard/summary')
|
||||||
|
},
|
||||||
|
dashboardStability() {
|
||||||
|
return api.get('/endpoints/dashboard/stability')
|
||||||
|
},
|
||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
return api.get('/endpoints', { 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
|
from ..services.policyresolver import effective_policy_for
|
||||||
effective = effective_policy_for(asset_id)
|
effective = effective_policy_for(asset_id)
|
||||||
return success_response(effective.to_dict())
|
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="detail-page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Computer Details</h2>
|
<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">
|
<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="`/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>
|
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||||
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
|
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||||
@@ -49,12 +52,41 @@
|
|||||||
<span class="hero-detail-value">{{ computer.locationname }}</span>
|
<span class="hero-detail-value">{{ computer.locationname }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- The 6am band: is it alive, what stops without it, how do I reach
|
||||||
|
it. "Serves machine" used to sit in the relationships card near
|
||||||
|
the bottom of the page, which is the wrong place for the single
|
||||||
|
most useful fact about a shop-floor PC. -->
|
||||||
|
<AssetTriageBand
|
||||||
|
:assetid="computer.assetid"
|
||||||
|
:liveness="liveness"
|
||||||
|
:accessmethods="computer.accessmethods || []"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||||
<!-- Main Content Grid -->
|
<!-- Main Content Grid -->
|
||||||
|
<!-- Live state before reference data. The grid is a two-column FLOW
|
||||||
|
(column-count, with display:contents on the column wrappers), so a
|
||||||
|
card cannot be pinned to a side - only document order decides where
|
||||||
|
it lands. Putting the contributed panels first means endpoint status,
|
||||||
|
services and activity are what a responder meets, and Identity,
|
||||||
|
Hardware and the rest follow. Panels self-order among themselves by
|
||||||
|
their declared position, and self-hide when a site has no plugin
|
||||||
|
answering for this asset type. -->
|
||||||
<div class="content-grid">
|
<div class="content-grid">
|
||||||
|
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
|
||||||
|
|
||||||
<!-- Left Column -->
|
<!-- Left Column -->
|
||||||
<div class="content-column">
|
<div class="content-column">
|
||||||
<!-- Identity Section -->
|
<!-- Identity Section -->
|
||||||
@@ -125,20 +157,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Remote Access -->
|
<!-- Remote Access lives in the triage band at the top of the page
|
||||||
<div class="section-card">
|
now: it is what a responder clicks first, and rendering it twice
|
||||||
<h3 class="section-title">Remote Access</h3>
|
made the page longer without telling anyone anything. -->
|
||||||
<div class="access-methods">
|
|
||||||
<template v-for="a in (computer.accessmethods || [])" :key="a.id">
|
|
||||||
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link">{{ a.name }}</a>
|
|
||||||
<span v-else class="access-link disabled" title="No hostname/IP set for this PC">{{ a.name }}</span>
|
|
||||||
</template>
|
|
||||||
<span v-if="!(computer.accessmethods || []).length" class="muted">None configured</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status / Check-in -->
|
<!-- Status / Check-in, from the COMPUTER RECORD (the asset-report
|
||||||
<div class="section-card">
|
collector), which is a different source from the endpoints
|
||||||
|
agent's live telemetry panel. Shown only when that record holds
|
||||||
|
something: on a PC with an agent these fields sit empty while
|
||||||
|
the endpoint panel is fully populated, so an always-on card
|
||||||
|
reported "Last Reported: Never" next to live telemetry saying
|
||||||
|
otherwise. Two sources disagreeing in public is worse than one
|
||||||
|
source missing. -->
|
||||||
|
<div v-if="hasRecordCheckin" class="section-card">
|
||||||
<h3 class="section-title">Status</h3>
|
<h3 class="section-title">Status</h3>
|
||||||
<div class="info-list">
|
<div class="info-list">
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
@@ -249,9 +280,6 @@
|
|||||||
<!-- Custom Fields -->
|
<!-- Custom Fields -->
|
||||||
<CustomFieldsSection :assetid="computer.assetid" />
|
<CustomFieldsSection :assetid="computer.assetid" />
|
||||||
|
|
||||||
<!-- Warranty -->
|
|
||||||
<PluginAssetPanels ref="assetPanels" :assetid="computer.assetid" />
|
|
||||||
|
|
||||||
<!-- All relationships (controls, defaultprinter, ...) -->
|
<!-- All relationships (controls, defaultprinter, ...) -->
|
||||||
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
||||||
|
|
||||||
@@ -285,6 +313,7 @@ import { withBase } from '@/utils/basePath'
|
|||||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
||||||
|
import AssetTriageBand from '@/components/AssetTriageBand.vue'
|
||||||
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||||
@@ -301,6 +330,18 @@ const { isEnabled } = useIdentifierFlags()
|
|||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const computer = ref(null)
|
const computer = ref(null)
|
||||||
|
// Liveness for the triage band. Null when the endpoints plugin is absent, or
|
||||||
|
// when the viewer lacks endpoints.view - in either case the chip is simply not
|
||||||
|
// shown, which is better than rendering "unknown" and implying a fault.
|
||||||
|
const liveness = ref(null)
|
||||||
|
|
||||||
|
// The computer record's own check-in fields, as opposed to live agent
|
||||||
|
// telemetry. Empty on an agent-managed PC, populated where the asset-report
|
||||||
|
// script runs instead.
|
||||||
|
const hasRecordCheckin = computed(() => {
|
||||||
|
const record = computer.value?.computer || {}
|
||||||
|
return Boolean(record.loggedinuser || record.lastreporteddate || record.lastboottime)
|
||||||
|
})
|
||||||
const installedApps = ref([])
|
const installedApps = ref([])
|
||||||
const assetPanels = ref(null)
|
const assetPanels = ref(null)
|
||||||
const restarting = ref(false)
|
const restarting = ref(false)
|
||||||
@@ -317,6 +358,42 @@ const { heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() =
|
|||||||
|
|
||||||
// relationships render via the shared AssetRelationships card
|
// relationships render via the shared AssetRelationships card
|
||||||
|
|
||||||
|
async function loadLiveness(assetid) {
|
||||||
|
if (!assetid) return
|
||||||
|
try {
|
||||||
|
const response = await endpointsApi.get(assetid)
|
||||||
|
const state = response.data.data || {}
|
||||||
|
// The SERVER decides reporting/quiet/never, because the quiet window is a
|
||||||
|
// site setting. Guessing a threshold here would disagree with the Fleet
|
||||||
|
// list about the same endpoint.
|
||||||
|
const labels = {
|
||||||
|
reporting: `Reporting, ${relativeAge(state.lastreportedat)}`,
|
||||||
|
quiet: `Quiet, last seen ${relativeAge(state.lastreportedat)}`,
|
||||||
|
never: 'Never reported'
|
||||||
|
}
|
||||||
|
const levels = { reporting: 'ok', quiet: 'warning', never: 'unknown' }
|
||||||
|
if (state.status) {
|
||||||
|
liveness.value = { level: levels[state.status], label: labels[state.status] }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 404 = no agent on this PC, 403 = the viewer cannot read telemetry.
|
||||||
|
// Neither is a page error; the chip just does not appear.
|
||||||
|
liveness.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativeAge(value) {
|
||||||
|
if (!value) return 'never'
|
||||||
|
const then = new Date(value)
|
||||||
|
if (isNaN(then)) return 'unknown'
|
||||||
|
const mins = Math.floor((Date.now() - then.getTime()) / 60000)
|
||||||
|
if (mins < 1) return 'just now'
|
||||||
|
if (mins < 60) return `${mins} m ago`
|
||||||
|
const hours = Math.floor(mins / 60)
|
||||||
|
if (hours < 24) return `${hours} h ago`
|
||||||
|
return `${Math.floor(hours / 24)} d ago`
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await computersApi.get(route.params.id)
|
const response = await computersApi.get(route.params.id)
|
||||||
@@ -329,6 +406,7 @@ onMounted(async () => {
|
|||||||
installedApps.value = appsResponse.data.data || []
|
installedApps.value = appsResponse.data.data || []
|
||||||
} catch (appError) {
|
} catch (appError) {
|
||||||
}
|
}
|
||||||
|
loadLiveness(computer.value?.assetid)
|
||||||
// Warranties load via useWarrantyBadge (watches computer.assetid).
|
// Warranties load via useWarrantyBadge (watches computer.assetid).
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading computer:', error)
|
console.error('Error loading computer:', error)
|
||||||
|
|||||||
@@ -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]:
|
def get_navigation_items(self) -> List[Dict]:
|
||||||
"""Return navigation menu items."""
|
"""Return navigation menu items."""
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ from flask_jwt_extended import current_user
|
|||||||
from shopdb.api import (
|
from shopdb.api import (
|
||||||
db, Asset, Setting, success_response, error_response, paginated_response,
|
db, Asset, Setting, success_response, error_response, paginated_response,
|
||||||
ErrorCodes, get_pagination_params, paginate_query, require_permission,
|
ErrorCodes, get_pagination_params, paginate_query, require_permission,
|
||||||
require_role, mint_service_token, list_service_tokens, revoke_service_tokens,
|
require_role, caller_holds, mint_service_token, list_service_tokens,
|
||||||
AuditLog, collector_authorized,
|
revoke_service_tokens,
|
||||||
|
AuditLog, collector_authorized, User,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..models import EndpointState, EndpointEvent, EndpointCommand
|
from ..models import EndpointState, EndpointEvent, EndpointCommand
|
||||||
@@ -31,6 +32,22 @@ def _naive_utcnow():
|
|||||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
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):
|
def _format_uptime(seconds):
|
||||||
"""Whole-seconds uptime -> 'Nd Nh Nm', or None."""
|
"""Whole-seconds uptime -> 'Nd Nh Nm', or None."""
|
||||||
if seconds is None:
|
if seconds is None:
|
||||||
@@ -55,44 +72,247 @@ def _format_uptime(seconds):
|
|||||||
# Endpoint state
|
# Endpoint state
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@endpoints_bp.route('/', methods=['GET'], strict_slashes=False)
|
@endpoints_bp.route('', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.fleet')
|
||||||
def list_endpoints():
|
def list_endpoints():
|
||||||
"""List reported endpoint states.
|
"""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
|
Joins through Computer so each row carries computerid and can deep-link
|
||||||
into the existing PC detail page (/pcs/{computerid}). Endpoint state only
|
into the existing PC detail page (/pcs/{computerid}). Endpoint state only
|
||||||
exists for hostnames resolved to a Computer, so the join is not lossy.
|
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)
|
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(Asset, Asset.assetid == EndpointState.assetid)
|
||||||
.join(Computer, Computer.assetid == EndpointState.assetid)
|
.join(Computer, Computer.assetid == EndpointState.assetid)
|
||||||
|
.outerjoin(ComputerType,
|
||||||
|
ComputerType.computertypeid == Computer.computertypeid)
|
||||||
.filter(Asset.isactive.is_(True)))
|
.filter(Asset.isactive.is_(True)))
|
||||||
|
|
||||||
if search := request.args.get('search'):
|
# Search covers hostname, asset number and the machine number a PC serves.
|
||||||
query = query.filter(EndpointState.hostname.ilike(f'%{search}%'))
|
# 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)
|
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 = []
|
data = []
|
||||||
for state, computerid in items:
|
for state, computerid, computertypeid, computertype, assetnumber, rank in items:
|
||||||
row = state.to_dict()
|
row = state.to_dict()
|
||||||
row['computerid'] = computerid
|
row['computerid'] = computerid
|
||||||
|
row['assetnumber'] = assetnumber
|
||||||
|
row['computertypeid'] = computertypeid
|
||||||
|
row['computertype'] = computertype
|
||||||
row['uptimedisplay'] = _format_uptime(state.uptimeseconds)
|
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)
|
data.append(row)
|
||||||
|
|
||||||
return paginated_response(data, page, per_page, total)
|
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'])
|
@endpoints_bp.route('/<int:asset_id>', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.view')
|
||||||
def get_endpoint(asset_id: int):
|
def get_endpoint(asset_id: int):
|
||||||
"""Get the current state for one endpoint by asset id."""
|
"""Get the current state for one endpoint by asset id.
|
||||||
|
|
||||||
|
Carries the same reporting/quiet/never verdict the fleet list uses. The
|
||||||
|
quiet window is a site setting, so the SERVER decides it: a caller that
|
||||||
|
guessed a threshold in the browser would disagree with the fleet list on
|
||||||
|
the same endpoint the moment a site changed the setting.
|
||||||
|
"""
|
||||||
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
state = EndpointState.query.filter_by(assetid=asset_id).first()
|
||||||
|
|
||||||
if not state:
|
if not state:
|
||||||
@@ -102,7 +322,18 @@ def get_endpoint(asset_id: int):
|
|||||||
http_code=404,
|
http_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
return success_response(state.to_dict())
|
cutoff = _naive_utcnow() - timedelta(hours=_quiet_hours())
|
||||||
|
if state.lastreportedat is None:
|
||||||
|
status = 'never'
|
||||||
|
elif state.lastreportedat < cutoff:
|
||||||
|
status = 'quiet'
|
||||||
|
else:
|
||||||
|
status = 'reporting'
|
||||||
|
|
||||||
|
data = state.to_dict()
|
||||||
|
data['status'] = status
|
||||||
|
data['uptimedisplay'] = _format_uptime(state.uptimeseconds)
|
||||||
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
@endpoints_bp.route('/<int:asset_id>/events', methods=['GET'])
|
@endpoints_bp.route('/<int:asset_id>/events', methods=['GET'])
|
||||||
@@ -195,6 +426,8 @@ def panel_services(asset_id: int):
|
|||||||
if not state or not state.servicesjson:
|
if not state or not state.servicesjson:
|
||||||
return success_response([])
|
return success_response([])
|
||||||
|
|
||||||
|
maycommand = caller_holds('endpoints.command')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
services = json.loads(state.servicesjson)
|
services = json.loads(state.servicesjson)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -209,14 +442,18 @@ def panel_services(asset_id: int):
|
|||||||
kind = entry.get('kind')
|
kind = entry.get('kind')
|
||||||
else:
|
else:
|
||||||
name, raw, app, kind = str(entry), '', None, None
|
name, raw, app, kind = str(entry), '', None, None
|
||||||
running = raw in ('running', 'started', 'up', 'ok', 'active')
|
running = _service_running(entry)
|
||||||
row = {
|
row = {
|
||||||
'name': name,
|
'name': name,
|
||||||
'kind': (kind or '').lower() or None,
|
'kind': (kind or '').lower() or None,
|
||||||
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
|
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
|
||||||
'statecolor': 'green' if running else ('red' if raw else 'gray'),
|
'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:
|
if targets:
|
||||||
row['action'] = {
|
row['action'] = {
|
||||||
'running': running,
|
'running': running,
|
||||||
@@ -253,6 +490,84 @@ def panel_recent_events(asset_id: int):
|
|||||||
return success_response(rows)
|
return success_response(rows)
|
||||||
|
|
||||||
|
|
||||||
|
@endpoints_bp.route('/asset/<int:asset_id>/activity', methods=['GET'])
|
||||||
|
@require_permission('endpoints.view')
|
||||||
|
def panel_activity(asset_id: int):
|
||||||
|
"""List panel: one chronological feed of what happened to this endpoint.
|
||||||
|
|
||||||
|
Curated Windows events and operator commands merged and sorted newest
|
||||||
|
first. They answer the same question - what happened to this box - and
|
||||||
|
reading them as two separate cards means interleaving them by eye.
|
||||||
|
|
||||||
|
Both sources are already keyed by assetid, so this is two indexed reads and
|
||||||
|
a merge sort, not a join. The core AuditLog is deliberately NOT a third
|
||||||
|
source: its rows carry the action as the entity name and the asset only
|
||||||
|
inside a details string, so pulling it in would mean parsing prose.
|
||||||
|
|
||||||
|
The requester's name is withheld from callers who cannot themselves issue
|
||||||
|
commands. A read-only viewer - a guest, on a site that grants this panel -
|
||||||
|
has no need for the roster of who restarted what, and the feed is just as
|
||||||
|
useful without it.
|
||||||
|
"""
|
||||||
|
naming = caller_holds('endpoints.command')
|
||||||
|
|
||||||
|
events = (EndpointEvent.query
|
||||||
|
.filter_by(assetid=asset_id)
|
||||||
|
.order_by(EndpointEvent.eventtime.desc())
|
||||||
|
.limit(15)
|
||||||
|
.all())
|
||||||
|
|
||||||
|
commands = (EndpointCommand.query
|
||||||
|
.filter_by(assetid=asset_id)
|
||||||
|
.order_by(EndpointCommand.createdat.desc())
|
||||||
|
.limit(15)
|
||||||
|
.all())
|
||||||
|
|
||||||
|
# requestedby holds a user ID, not a name. One batched lookup rather than a
|
||||||
|
# query per row, and only when the caller is allowed to see the names at all.
|
||||||
|
names = {}
|
||||||
|
if naming:
|
||||||
|
userids = {c.requestedby for c in commands if c.requestedby}
|
||||||
|
if userids:
|
||||||
|
names = {u.userid: (u.username or '')
|
||||||
|
for u in User.query.filter(User.userid.in_(userids)).all()}
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for event in events:
|
||||||
|
rows.append({
|
||||||
|
'title': event_title(event.eventid),
|
||||||
|
'severity': event_severity(event.eventid),
|
||||||
|
'detail': 'Windows event {}'.format(event.eventid),
|
||||||
|
'eventtime': event.eventtime,
|
||||||
|
})
|
||||||
|
|
||||||
|
for command in commands:
|
||||||
|
label = COMMAND_ALLOWLIST.get(command.action, {}).get('label') or command.action
|
||||||
|
target = ' on {}'.format(command.target) if command.target else ''
|
||||||
|
who = ''
|
||||||
|
if naming and command.requestedby:
|
||||||
|
username = names.get(command.requestedby)
|
||||||
|
if username:
|
||||||
|
who = ' by {}'.format(username)
|
||||||
|
rows.append({
|
||||||
|
'title': '{}{}'.format(label, target),
|
||||||
|
# A failed command is the one an operator needs to notice.
|
||||||
|
'severity': 'critical' if command.status == EndpointCommand.STATUS_FAILED else 'info',
|
||||||
|
'detail': '{}{}'.format((command.status or 'queued').title(), who),
|
||||||
|
'eventtime': command.createdat,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Undated rows sort last rather than crashing the comparison.
|
||||||
|
rows.sort(key=lambda r: (r['eventtime'] is not None, r['eventtime']), reverse=True)
|
||||||
|
|
||||||
|
return success_response([{
|
||||||
|
'title': r['title'],
|
||||||
|
'severity': r['severity'],
|
||||||
|
'detail': r['detail'],
|
||||||
|
'eventtime': r['eventtime'].isoformat() + 'Z' if r['eventtime'] else None,
|
||||||
|
} for r in rows[:15]])
|
||||||
|
|
||||||
|
|
||||||
@endpoints_bp.route('/asset/<int:asset_id>/interfaces', methods=['GET'])
|
@endpoints_bp.route('/asset/<int:asset_id>/interfaces', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.view')
|
||||||
def panel_interfaces(asset_id: int):
|
def panel_interfaces(asset_id: int):
|
||||||
@@ -299,7 +614,7 @@ def panel_interfaces(asset_id: int):
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@endpoints_bp.route('/dashboard/summary', methods=['GET'])
|
@endpoints_bp.route('/dashboard/summary', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.fleet')
|
||||||
def dashboard_summary():
|
def dashboard_summary():
|
||||||
"""High-level fleet counts for the endpoints dashboard."""
|
"""High-level fleet counts for the endpoints dashboard."""
|
||||||
total = db.session.query(EndpointState).join(
|
total = db.session.query(EndpointState).join(
|
||||||
@@ -317,16 +632,28 @@ def dashboard_summary():
|
|||||||
EndpointState.lastreportedat >= cutoff,
|
EndpointState.lastreportedat >= cutoff,
|
||||||
).count()
|
).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({
|
return success_response({
|
||||||
'total': total,
|
'total': total,
|
||||||
'reporting': reporting,
|
'reporting': reporting,
|
||||||
'quiet': total - reporting,
|
'quiet': total - reporting - never,
|
||||||
|
'never': never,
|
||||||
'quiethours': hours,
|
'quiethours': hours,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@endpoints_bp.route('/dashboard/quiet', methods=['GET'])
|
@endpoints_bp.route('/dashboard/quiet', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.fleet')
|
||||||
def dashboard_quiet():
|
def dashboard_quiet():
|
||||||
"""Endpoints that reported before, then went quiet past the window.
|
"""Endpoints that reported before, then went quiet past the window.
|
||||||
|
|
||||||
@@ -361,7 +688,7 @@ def dashboard_quiet():
|
|||||||
|
|
||||||
|
|
||||||
@endpoints_bp.route('/dashboard/stability', methods=['GET'])
|
@endpoints_bp.route('/dashboard/stability', methods=['GET'])
|
||||||
@require_permission('endpoints.view')
|
@require_permission('endpoints.fleet')
|
||||||
def dashboard_stability():
|
def dashboard_stability():
|
||||||
"""Endpoints with recent crash/shutdown/corruption events.
|
"""Endpoints with recent crash/shutdown/corruption events.
|
||||||
|
|
||||||
|
|||||||
@@ -4,30 +4,95 @@
|
|||||||
<h2>Fleet</h2>
|
<h2>Fleet</h2>
|
||||||
</div>
|
</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 -->
|
<!-- Filters -->
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input
|
<input
|
||||||
v-model="search"
|
v-model="search"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Search endpoints..."
|
placeholder="Search hostname, asset or machine number..."
|
||||||
@input="debouncedSearch"
|
@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>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
|
||||||
|
<div v-else-if="loadError" class="load-error">
|
||||||
|
{{ loadError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>Hostname</th>
|
||||||
|
<th>Machine</th>
|
||||||
|
<th>PC Type</th>
|
||||||
|
<th>Health</th>
|
||||||
<th>Logged-in User</th>
|
<th>Logged-in User</th>
|
||||||
<th>Uptime</th>
|
<th>Uptime</th>
|
||||||
<th>CPU</th>
|
|
||||||
<th>Memory</th>
|
|
||||||
<th>Last Reported</th>
|
<th>Last Reported</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -39,15 +104,31 @@
|
|||||||
:class="{ disabled: !item.computerid }"
|
:class="{ disabled: !item.computerid }"
|
||||||
@click="openPc(item)"
|
@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>{{ 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.loggedinuser || '-' }}</td>
|
||||||
<td>{{ item.uptimedisplay || '-' }}</td>
|
<td>{{ item.uptimedisplay || '-' }}</td>
|
||||||
<td>{{ formatPercent(item.cpupercent) }}</td>
|
<td>{{ formatRelative(item.lastreportedat) }}</td>
|
||||||
<td>{{ formatPercent(item.memorypercent) }}</td>
|
|
||||||
<td>{{ formatTime(item.lastreportedat) }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="endpoints.length === 0">
|
<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
|
No endpoints found
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -69,25 +150,155 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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 PaginationBar from '@/components/PaginationBar.vue'
|
||||||
import { useListQuery } from '@/composables/listQuery'
|
import { useListQuery } from '@/composables/listQuery'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const endpoints = ref([])
|
const endpoints = ref([])
|
||||||
const loading = ref(true)
|
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 { page, search, setPage, setSearch } = useListQuery({ onChange: loadEndpoints })
|
||||||
const totalPages = ref(1)
|
const totalPages = ref(1)
|
||||||
const perPage = ref(20)
|
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
|
let searchTimeout = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadEndpoints()
|
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() {
|
async function loadEndpoints() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -96,12 +307,32 @@ async function loadEndpoints() {
|
|||||||
perpage: perPage.value
|
perpage: perPage.value
|
||||||
}
|
}
|
||||||
if (search.value) params.search = search.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)
|
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
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading endpoints:', 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 {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -115,14 +346,56 @@ function openPc(item) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPercent(value) {
|
function statusLabel(value) {
|
||||||
if (value === null || value === undefined) return '-'
|
return { reporting: 'Reporting', quiet: 'Quiet', never: 'Never' }[value] || '-'
|
||||||
return `${Math.round(value)}%`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(value) {
|
// Relative beats absolute here: "4m ago" answers "is this box alive" at a
|
||||||
if (!value) return '-'
|
// glance, where a timestamp makes the reader do the subtraction.
|
||||||
return new Date(value).toLocaleString()
|
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() {
|
function debouncedSearch() {
|
||||||
@@ -150,4 +423,117 @@ function changePerPage(newPerPage) {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.6;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -118,6 +118,22 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
('endpoints.view', 'View endpoint telemetry', 'endpoints'),
|
('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
|
# Issue an allowlisted command to an endpoint agent (for example the
|
||||||
# MTConnect toggle). Separate from endpoints.view because acting on a
|
# MTConnect toggle). Separate from endpoints.view because acting on a
|
||||||
# shop-floor PC is a heavier grant than reading its telemetry.
|
# shop-floor PC is a heavier grant than reading its telemetry.
|
||||||
@@ -204,7 +220,7 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'icon': 'activity',
|
'icon': 'activity',
|
||||||
'route': '/endpoints',
|
'route': '/endpoints',
|
||||||
'position': 16,
|
'position': 16,
|
||||||
'permission': 'endpoints.view',
|
'permission': 'endpoints.fleet',
|
||||||
'section': 'assets',
|
'section': 'assets',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -288,10 +304,16 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'position': 13,
|
'position': 13,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'id': 'endpoints-events',
|
# One feed instead of two cards. Events and commands answer the
|
||||||
'title': 'Recent events',
|
# same question - what happened to this box - and reading them
|
||||||
|
# separately meant interleaving two lists by eye. Sits high on
|
||||||
|
# the page because it is the first thing looked at when
|
||||||
|
# something has gone wrong, and NOT collapsed by default for
|
||||||
|
# the same reason.
|
||||||
|
'id': 'endpoints-activity',
|
||||||
|
'title': 'Activity',
|
||||||
'assettypes': ['computer'],
|
'assettypes': ['computer'],
|
||||||
'endpoint': '/api/endpoints/asset/{assetid}/recentevents',
|
'endpoint': '/api/endpoints/asset/{assetid}/activity',
|
||||||
'render': 'list',
|
'render': 'list',
|
||||||
'collapsible': True,
|
'collapsible': True,
|
||||||
'status': {
|
'status': {
|
||||||
@@ -313,12 +335,12 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
'meta': [
|
'meta': [
|
||||||
|
{'key': 'detail'},
|
||||||
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
|
{'key': 'eventtime', 'label': 'At', 'format': 'date'},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
'empty': 'No recent events.',
|
'empty': 'Nothing recorded for this endpoint yet.',
|
||||||
'collapsedDefault': True,
|
'position': 20,
|
||||||
'position': 90,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'id': 'endpoints-interfaces',
|
'id': 'endpoints-interfaces',
|
||||||
@@ -355,7 +377,7 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'endpoint': '/api/endpoints/dashboard/quiet',
|
'endpoint': '/api/endpoints/dashboard/quiet',
|
||||||
'render': 'exceptions',
|
'render': 'exceptions',
|
||||||
'severity': 'warning',
|
'severity': 'warning',
|
||||||
'permission': 'endpoints.view',
|
'permission': 'endpoints.fleet',
|
||||||
'empty': 'hide',
|
'empty': 'hide',
|
||||||
'position': 22,
|
'position': 22,
|
||||||
'map': {
|
'map': {
|
||||||
@@ -373,7 +395,7 @@ class EndpointsPlugin(BasePlugin):
|
|||||||
'endpoint': '/api/endpoints/dashboard/stability',
|
'endpoint': '/api/endpoints/dashboard/stability',
|
||||||
'render': 'exceptions',
|
'render': 'exceptions',
|
||||||
'severity': 'critical',
|
'severity': 'critical',
|
||||||
'permission': 'endpoints.view',
|
'permission': 'endpoints.fleet',
|
||||||
'empty': 'hide',
|
'empty': 'hide',
|
||||||
'position': 16,
|
'position': 16,
|
||||||
'map': {
|
'map': {
|
||||||
|
|||||||
@@ -81,7 +81,14 @@ from .plugins import plugin_manager
|
|||||||
# endpoints command claim/result long-poll callbacks) on the same per-plugin
|
# endpoints command claim/result long-poll callbacks) on the same per-plugin
|
||||||
# collector key + managed-token check the ingest route uses, without importing
|
# collector key + managed-token check the ingest route uses, without importing
|
||||||
# core collector internals. Additive name on the surface, minor bump.
|
# 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
|
# Product release version (see ADR-007). The product version and the
|
||||||
# plugin-contract version above are distinct series with independent
|
# 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:
|
if envname.startswith('COLLECTOR_API_KEY_') and envvalue:
|
||||||
app.config[envname] = 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
|
# Ensure instance folder exists
|
||||||
os.makedirs(app.instance_path, exist_ok=True)
|
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
|
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||||
|
|
||||||
# Authorization decorators for gating plugin write routes
|
# 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,
|
# Service-token authorization for unattended plugin endpoints (collector,
|
||||||
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token
|
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token
|
||||||
@@ -331,6 +332,7 @@ __all__ = [
|
|||||||
# Authorization decorators
|
# Authorization decorators
|
||||||
'require_permission',
|
'require_permission',
|
||||||
'require_any_permission',
|
'require_any_permission',
|
||||||
|
'caller_holds',
|
||||||
'require_role',
|
'require_role',
|
||||||
'service_token_authorized',
|
'service_token_authorized',
|
||||||
'authorized_service_token',
|
'authorized_service_token',
|
||||||
|
|||||||
@@ -49,6 +49,15 @@ class Config:
|
|||||||
seconds=int(os.environ.get('JWT_REFRESH_TOKEN_EXPIRES', 2592000))
|
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 = [
|
CORS_ORIGINS = [
|
||||||
origin.strip()
|
origin.strip()
|
||||||
for origin in os.environ.get('CORS_ORIGINS', 'http://localhost:5173').split(',')
|
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)
|
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):
|
def require_permission(permission_name: str):
|
||||||
"""Gate a route behind a single permission. Admin role bypasses.
|
"""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