Close the gaps against the design: critical tile, filters, policy card, action placement
Four things the mockup showed and the first cut did not have.
FLEET
- A 'with critical events' tile, from the stability route that existed and
nothing called. Clicking it narrows to rows whose health is not ok - client
side, because the health rollup is assembled per row after pagination, so
it narrows the PAGE rather than the fleet. Worth knowing before the count
is read as fleet-wide.
- PC type and Location filters. The list route already accepted both; there
was simply no control. Both use listAll, not list: perpage is clamped to
100 server-side, so a long location list would lose its tail in a dropdown
while still looking complete.
- The machine cell shows number and name together where they differ, so
'3105 Okuma' rather than a bare number.
PC DETAIL
- A Policy card, contributed by the computers plugin. It matters because the
effective policy already narrows the command allowlist, so without it the
reason a control is unavailable on a given PC is written down nowhere.
Provenance, so it sits in the reference column, not the triage band.
Its route returns an EMPTY field list rather than the feature-gate 404 when
policies are disabled. The panel contract hides a card with no fields, so
it disappears on sites that have not enabled the feature - a 404 would have
rendered a broken card on most of them. That was flagged in the design
review as a snag; this is the fix.
- Refresh Status and Restart Computer moved out of the page header into the
triage band. They act on the MACHINE; Print Label, Edit, Retire and Back to
List act on the RECORD, and mixing them put the most consequential button
on the page - it halts whatever machine the PC serves - between printing a
sticker and editing a form. In the band it sits beside the liveness chip
and the machine it would take down.
The band takes them through an 'actions' slot rather than knowing about PC
verbs, so a measuring tool can put 'Calibrate' there without the core
component learning anything about computers.
Full suite 2124 passed, vitest 260 passed, gates green, spec at 333 paths.
This commit is contained in:
@@ -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