Fleet console gap closures: critical tile, filters, policy card, action placement
This commit is contained in:
@@ -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": [
|
||||||
|
|||||||
@@ -159,6 +159,9 @@ export const endpointsApi = {
|
|||||||
dashboardSummary() {
|
dashboardSummary() {
|
||||||
return api.get('/endpoints/dashboard/summary')
|
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 })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -36,6 +36,15 @@
|
|||||||
<span class="triage-label">Location</span>
|
<span class="triage-label">Location</span>
|
||||||
<span class="triage-value">{{ locationname }}</span>
|
<span class="triage-value">{{ locationname }}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -52,7 +61,7 @@
|
|||||||
* because a machine stops without it, and that fact was buried in the generic
|
* because a machine stops without it, and that fact was buried in the generic
|
||||||
* relationships card near the bottom of the page.
|
* relationships card near the bottom of the page.
|
||||||
*/
|
*/
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch, useSlots } from 'vue'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -67,7 +76,10 @@ const props = defineProps({
|
|||||||
|
|
||||||
const machines = ref([])
|
const machines = ref([])
|
||||||
|
|
||||||
|
const slots = useSlots()
|
||||||
|
|
||||||
const hasAnything = computed(() =>
|
const hasAnything = computed(() =>
|
||||||
|
Boolean(slots.actions) ||
|
||||||
Boolean(props.liveness) ||
|
Boolean(props.liveness) ||
|
||||||
machines.value.length > 0 ||
|
machines.value.length > 0 ||
|
||||||
(props.accessmethods && props.accessmethods.length > 0) ||
|
(props.accessmethods && props.accessmethods.length > 0) ||
|
||||||
@@ -122,6 +134,14 @@ function collect(edge, other, found) {
|
|||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.triage-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.triage-label {
|
.triage-label {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -58,7 +61,16 @@
|
|||||||
:assetid="computer.assetid"
|
:assetid="computer.assetid"
|
||||||
:liveness="liveness"
|
:liveness="liveness"
|
||||||
:accessmethods="computer.accessmethods || []"
|
: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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<!-- Summary strip. Wired to dashboard routes that already existed and
|
<!-- Summary strip. Wired to dashboard routes that already existed and
|
||||||
nothing called; each tile is a filter shortcut, not just a number. -->
|
nothing called; each tile is a filter shortcut, not just a number. -->
|
||||||
<div v-if="summary" class="fleet-tiles">
|
<div v-if="summary" class="fleet-tiles">
|
||||||
<button class="fleet-tile" :class="{ on: status === '' }" @click="setStatus('')">
|
<button class="fleet-tile" :class="{ on: status === '' && !problemsonly }" @click="clearFilters">
|
||||||
<span class="n">{{ summary.total }}</span><span class="l">Endpoints</span>
|
<span class="n">{{ summary.total }}</span><span class="l">Endpoints</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="fleet-tile ok" :class="{ on: status === 'reporting' }" @click="setStatus('reporting')">
|
<button class="fleet-tile ok" :class="{ on: status === 'reporting' }" @click="setStatus('reporting')">
|
||||||
@@ -19,6 +19,11 @@
|
|||||||
<button class="fleet-tile bad" :class="{ on: status === 'never' }" @click="setStatus('never')">
|
<button class="fleet-tile bad" :class="{ on: status === 'never' }" @click="setStatus('never')">
|
||||||
<span class="n">{{ summary.never }}</span><span class="l">Never reported</span>
|
<span class="n">{{ summary.never }}</span><span class="l">Never reported</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
@@ -36,6 +41,18 @@
|
|||||||
<option value="quiet">Quiet</option>
|
<option value="quiet">Quiet</option>
|
||||||
<option value="never">Never reported</option>
|
<option value="never">Never reported</option>
|
||||||
</select>
|
</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">
|
<select v-model="sort" class="form-control fleet-filter" @change="applyFilters">
|
||||||
<option value="status">Sort: worst first</option>
|
<option value="status">Sort: worst first</option>
|
||||||
<option value="hostname">Sort: hostname</option>
|
<option value="hostname">Sort: hostname</option>
|
||||||
@@ -82,7 +99,7 @@
|
|||||||
<!-- What stops if this PC is dead. On a shop floor that is
|
<!-- What stops if this PC is dead. On a shop floor that is
|
||||||
the first thing anyone needs to know. -->
|
the first thing anyone needs to know. -->
|
||||||
<span v-if="!item.machines || !item.machines.length">-</span>
|
<span v-if="!item.machines || !item.machines.length">-</span>
|
||||||
<span v-else>{{ item.machines.map(m => m.assetnumber).join(', ') }}</span>
|
<span v-else>{{ item.machines.map(machineLabel).join(', ') }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ item.computertype || '-' }}</td>
|
<td>{{ item.computertype || '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -119,7 +136,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { endpointsApi } from '@/api'
|
import { endpointsApi, computersApi, locationsApi } from '@/api'
|
||||||
import PaginationBar from '@/components/PaginationBar.vue'
|
import PaginationBar from '@/components/PaginationBar.vue'
|
||||||
import { useListQuery } from '@/composables/listQuery'
|
import { useListQuery } from '@/composables/listQuery'
|
||||||
|
|
||||||
@@ -140,18 +157,42 @@ const status = ref('')
|
|||||||
// endpoints to the top and buries the ones somebody needs to act on.
|
// endpoints to the top and buries the ones somebody needs to act on.
|
||||||
const sort = ref('status')
|
const sort = ref('status')
|
||||||
const summary = ref(null)
|
const summary = ref(null)
|
||||||
|
const criticalcount = ref(0)
|
||||||
|
const computertypeid = ref('')
|
||||||
|
const locationid = ref('')
|
||||||
|
const problemsonly = ref(false)
|
||||||
|
const computertypes = ref([])
|
||||||
|
const locations = ref([])
|
||||||
|
|
||||||
let searchTimeout = null
|
let searchTimeout = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadEndpoints()
|
loadEndpoints()
|
||||||
loadSummary()
|
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() {
|
async function loadSummary() {
|
||||||
try {
|
try {
|
||||||
const response = await endpointsApi.dashboardSummary()
|
const response = await endpointsApi.dashboardSummary()
|
||||||
summary.value = response.data.data || null
|
summary.value = response.data.data || null
|
||||||
|
const stability = await endpointsApi.dashboardStability()
|
||||||
|
criticalcount.value = (stability.data.data || []).length
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The strip is a convenience, not the page. If it fails the table still
|
// The strip is a convenience, not the page. If it fails the table still
|
||||||
// works, so say nothing rather than blocking on it.
|
// works, so say nothing rather than blocking on it.
|
||||||
@@ -169,10 +210,15 @@ async function loadEndpoints() {
|
|||||||
if (search.value) params.search = search.value
|
if (search.value) params.search = search.value
|
||||||
if (status.value) params.status = status.value
|
if (status.value) params.status = status.value
|
||||||
if (sort.value) params.sort = sort.value
|
if (sort.value) params.sort = sort.value
|
||||||
|
if (computertypeid.value) params.computertypeid = computertypeid.value
|
||||||
|
if (locationid.value) params.locationid = locationid.value
|
||||||
|
|
||||||
loadError.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
|
||||||
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)
|
||||||
@@ -217,9 +263,33 @@ function formatRelative(value) {
|
|||||||
|
|
||||||
function setStatus(value) {
|
function setStatus(value) {
|
||||||
status.value = value
|
status.value = value
|
||||||
|
problemsonly.value = false
|
||||||
applyFilters()
|
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() {
|
function applyFilters() {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
loadEndpoints()
|
loadEndpoints()
|
||||||
|
|||||||
Reference in New Issue
Block a user