Fleet console gap closures: critical tile, filters, policy card, action placement

This commit is contained in:
2026-08-28 23:00:37 -04:00
parent 7e779a6829
commit 09d06399d3
7 changed files with 235 additions and 8 deletions

View File

@@ -397,3 +397,51 @@ def get_effective_policy(asset_id: int):
from ..services.policyresolver import effective_policy_for
effective = effective_policy_for(asset_id)
return success_response(effective.to_dict())
@computers_bp.route('/asset/<int:asset_id>/policypanel', methods=['GET'])
@require_permission('computers.view')
def policy_panel(asset_id: int):
"""Keyvalue panel: which policy governs this endpoint, and what it allows.
Provenance, not a control. It matters because the effective policy already
narrows the command allowlist, so it is the answer to "why is Start greyed
out on this PC" - which is otherwise invisible.
Returns an EMPTY field list rather than the feature-gate 404 when the
feature is off. The panel contract hides a card with no fields; an error
would render a broken card on every site that has not enabled policies,
which is most of them.
"""
if not policy_flag_enabled():
return success_response({'fields': []})
from ..services.policyresolver import effective_policy_for
effective = effective_policy_for(asset_id)
actions = effective.to_dict()
if not actions:
return success_response({'fields': []})
# One endpoint resolves through one winning business policy, but read the
# ids off the actions rather than assuming: a fold can draw from more than
# one configuration policy.
businesspolicyids = {a['businesspolicyid'] for a in actions.values()
if a.get('businesspolicyid')}
configpolicyids = {a['configurationpolicyid'] for a in actions.values()
if a.get('configurationpolicyid')}
names = []
if businesspolicyids:
names = [p.name for p in BusinessPolicy.query
.filter(BusinessPolicy.businesspolicyid.in_(businesspolicyids)).all()]
gated = sum(1 for a in actions.values() if a.get('mode') == ACTION_MODE_GATE)
desired = sum(1 for a in actions.values() if a.get('mode') == ACTION_MODE_DESIRED)
fields = []
if names:
fields.append({'label': 'Policy', 'value': ', '.join(sorted(names))})
fields.append({'label': 'Configuration policies', 'value': str(len(configpolicyids))})
fields.append({'label': 'Allowed actions', 'value': str(gated)})
fields.append({'label': 'Desired state', 'value': str(desired)})
return success_response({'fields': fields})

View File

@@ -2,9 +2,12 @@
<div class="detail-page">
<div class="page-header">
<h2>Computer Details</h2>
<!-- Record actions only. The two that act on the MACHINE - refresh and
restart - live in the triage band beside the state they change and
the machine they would take down with it. Restart sitting between
Print Label and Edit made the most consequential button on the page
look like a peer of printing a sticker. -->
<div class="header-actions">
<button v-if="canCommand" @click="refreshStatus" class="btn btn-secondary" :disabled="restarting || refreshing">{{ refreshing ? 'Refreshing...' : 'Refresh Status' }}</button>
<button v-if="canRestart" @click="confirmRestart" class="btn btn-warning" :disabled="restarting">Restart Computer</button>
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
@@ -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>

View File

@@ -1967,6 +1967,29 @@ class ComputersPlugin(BasePlugin):
},
]
def get_asset_panels(self) -> List[Dict]:
"""Policy provenance on the PC detail page.
The effective policy already narrows which commands an endpoint will
accept, so without this the reason a control is unavailable is not
visible anywhere. Right column, near the services it governs: this is
provenance, not a 6am triage signal.
The route returns an empty field list when the policy feature is off,
so the card disappears on sites that have not enabled it rather than
rendering an error.
"""
return [
{
'id': 'computers-policy',
'title': 'Policy',
'assettypes': ['computer'],
'endpoint': '/api/computers/asset/{assetid}/policypanel',
'render': 'keyvalue',
'position': 40,
},
]
def get_navigation_items(self) -> List[Dict]:
"""Return navigation menu items."""
return [

View File

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