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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -159,6 +159,9 @@ export const endpointsApi = {
|
||||
dashboardSummary() {
|
||||
return api.get('/endpoints/dashboard/summary')
|
||||
},
|
||||
dashboardStability() {
|
||||
return api.get('/endpoints/dashboard/stability')
|
||||
},
|
||||
list(params = {}) {
|
||||
return api.get('/endpoints', { params })
|
||||
},
|
||||
|
||||
@@ -36,6 +36,15 @@
|
||||
<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>
|
||||
|
||||
@@ -52,7 +61,7 @@
|
||||
* 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 } from 'vue'
|
||||
import { ref, computed, onMounted, watch, useSlots } from 'vue'
|
||||
import api from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -67,7 +76,10 @@ const props = defineProps({
|
||||
|
||||
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) ||
|
||||
@@ -122,6 +134,14 @@ function collect(edge, other, found) {
|
||||
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;
|
||||
|
||||
@@ -397,3 +397,51 @@ def get_effective_policy(asset_id: int):
|
||||
from ..services.policyresolver import effective_policy_for
|
||||
effective = effective_policy_for(asset_id)
|
||||
return success_response(effective.to_dict())
|
||||
|
||||
|
||||
@computers_bp.route('/asset/<int:asset_id>/policypanel', methods=['GET'])
|
||||
@require_permission('computers.view')
|
||||
def policy_panel(asset_id: int):
|
||||
"""Keyvalue panel: which policy governs this endpoint, and what it allows.
|
||||
|
||||
Provenance, not a control. It matters because the effective policy already
|
||||
narrows the command allowlist, so it is the answer to "why is Start greyed
|
||||
out on this PC" - which is otherwise invisible.
|
||||
|
||||
Returns an EMPTY field list rather than the feature-gate 404 when the
|
||||
feature is off. The panel contract hides a card with no fields; an error
|
||||
would render a broken card on every site that has not enabled policies,
|
||||
which is most of them.
|
||||
"""
|
||||
if not policy_flag_enabled():
|
||||
return success_response({'fields': []})
|
||||
|
||||
from ..services.policyresolver import effective_policy_for
|
||||
effective = effective_policy_for(asset_id)
|
||||
actions = effective.to_dict()
|
||||
if not actions:
|
||||
return success_response({'fields': []})
|
||||
|
||||
# One endpoint resolves through one winning business policy, but read the
|
||||
# ids off the actions rather than assuming: a fold can draw from more than
|
||||
# one configuration policy.
|
||||
businesspolicyids = {a['businesspolicyid'] for a in actions.values()
|
||||
if a.get('businesspolicyid')}
|
||||
configpolicyids = {a['configurationpolicyid'] for a in actions.values()
|
||||
if a.get('configurationpolicyid')}
|
||||
|
||||
names = []
|
||||
if businesspolicyids:
|
||||
names = [p.name for p in BusinessPolicy.query
|
||||
.filter(BusinessPolicy.businesspolicyid.in_(businesspolicyids)).all()]
|
||||
|
||||
gated = sum(1 for a in actions.values() if a.get('mode') == ACTION_MODE_GATE)
|
||||
desired = sum(1 for a in actions.values() if a.get('mode') == ACTION_MODE_DESIRED)
|
||||
|
||||
fields = []
|
||||
if names:
|
||||
fields.append({'label': 'Policy', 'value': ', '.join(sorted(names))})
|
||||
fields.append({'label': 'Configuration policies', 'value': str(len(configpolicyids))})
|
||||
fields.append({'label': 'Allowed actions', 'value': str(gated)})
|
||||
fields.append({'label': 'Desired state', 'value': str(desired)})
|
||||
return success_response({'fields': fields})
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Computer Details</h2>
|
||||
<!-- Record actions only. The two that act on the MACHINE - refresh and
|
||||
restart - live in the triage band beside the state they change and
|
||||
the machine they would take down with it. Restart sitting between
|
||||
Print Label and Edit made the most consequential button on the page
|
||||
look like a peer of printing a sticker. -->
|
||||
<div class="header-actions">
|
||||
<button v-if="canCommand" @click="refreshStatus" class="btn btn-secondary" :disabled="restarting || refreshing">{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}</button>
|
||||
<button v-if="canRestart" @click="confirmRestart" class="btn btn-warning" :disabled="restarting">Restart Computer</button>
|
||||
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
|
||||
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
@@ -58,7 +61,16 @@
|
||||
: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>
|
||||
|
||||
|
||||
@@ -1967,6 +1967,29 @@ class ComputersPlugin(BasePlugin):
|
||||
},
|
||||
]
|
||||
|
||||
def get_asset_panels(self) -> List[Dict]:
|
||||
"""Policy provenance on the PC detail page.
|
||||
|
||||
The effective policy already narrows which commands an endpoint will
|
||||
accept, so without this the reason a control is unavailable is not
|
||||
visible anywhere. Right column, near the services it governs: this is
|
||||
provenance, not a 6am triage signal.
|
||||
|
||||
The route returns an empty field list when the policy feature is off,
|
||||
so the card disappears on sites that have not enabled it rather than
|
||||
rendering an error.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'id': 'computers-policy',
|
||||
'title': 'Policy',
|
||||
'assettypes': ['computer'],
|
||||
'endpoint': '/api/computers/asset/{assetid}/policypanel',
|
||||
'render': 'keyvalue',
|
||||
'position': 40,
|
||||
},
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Return navigation menu items."""
|
||||
return [
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- Summary strip. Wired to dashboard routes that already existed and
|
||||
nothing called; each tile is a filter shortcut, not just a number. -->
|
||||
<div v-if="summary" class="fleet-tiles">
|
||||
<button class="fleet-tile" :class="{ on: status === '' }" @click="setStatus('')">
|
||||
<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')">
|
||||
@@ -19,6 +19,11 @@
|
||||
<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 -->
|
||||
@@ -36,6 +41,18 @@
|
||||
<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>
|
||||
@@ -82,7 +99,7 @@
|
||||
<!-- What stops if this PC is dead. On a shop floor that is
|
||||
the first thing anyone needs to know. -->
|
||||
<span v-if="!item.machines || !item.machines.length">-</span>
|
||||
<span v-else>{{ item.machines.map(m => m.assetnumber).join(', ') }}</span>
|
||||
<span v-else>{{ item.machines.map(machineLabel).join(', ') }}</span>
|
||||
</td>
|
||||
<td>{{ item.computertype || '-' }}</td>
|
||||
<td>
|
||||
@@ -119,7 +136,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { endpointsApi } from '@/api'
|
||||
import { endpointsApi, computersApi, locationsApi } from '@/api'
|
||||
import PaginationBar from '@/components/PaginationBar.vue'
|
||||
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.
|
||||
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([])
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadEndpoints()
|
||||
loadSummary()
|
||||
loadFilterOptions()
|
||||
})
|
||||
|
||||
async function loadFilterOptions() {
|
||||
// listAll, not list: perpage is clamped to 100 server-side, so a long type or
|
||||
// location list would silently lose its tail in a dropdown.
|
||||
try {
|
||||
computertypes.value = await computersApi.types.listAll()
|
||||
} catch (error) {
|
||||
computertypes.value = []
|
||||
}
|
||||
try {
|
||||
locations.value = await locationsApi.listAll()
|
||||
} catch (error) {
|
||||
locations.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
try {
|
||||
const response = await endpointsApi.dashboardSummary()
|
||||
summary.value = response.data.data || null
|
||||
const stability = await endpointsApi.dashboardStability()
|
||||
criticalcount.value = (stability.data.data || []).length
|
||||
} catch (error) {
|
||||
// The strip is a convenience, not the page. If it fails the table still
|
||||
// works, so say nothing rather than blocking on it.
|
||||
@@ -169,10 +210,15 @@ async function loadEndpoints() {
|
||||
if (search.value) params.search = search.value
|
||||
if (status.value) params.status = status.value
|
||||
if (sort.value) params.sort = sort.value
|
||||
if (computertypeid.value) params.computertypeid = computertypeid.value
|
||||
if (locationid.value) params.locationid = locationid.value
|
||||
|
||||
loadError.value = ''
|
||||
const response = await endpointsApi.list(params)
|
||||
endpoints.value = response.data.data || []
|
||||
const rows = response.data.data || []
|
||||
endpoints.value = problemsonly.value
|
||||
? rows.filter(row => row.health && row.health.level !== 'ok')
|
||||
: rows
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading endpoints:', error)
|
||||
@@ -217,9 +263,33 @@ function formatRelative(value) {
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user