Merge origin/main into wilm: dashboard/fleet console, vuln upgrades, colours
This commit is contained in:
@@ -237,6 +237,7 @@ class BackupsPlugin(BasePlugin):
|
||||
{
|
||||
'id': 'backups-stale',
|
||||
'title': 'Backups stopped',
|
||||
'shortname': 'Backups',
|
||||
'endpoint': '/api/backups/dashboard/stale',
|
||||
'render': 'exceptions',
|
||||
'severity': 'warning',
|
||||
|
||||
@@ -1934,6 +1934,7 @@ class ComputersPlugin(BasePlugin):
|
||||
'id': 'computers-quiet',
|
||||
'viewall': '/pcs',
|
||||
'title': 'PCs gone silent',
|
||||
'shortname': 'PC silent',
|
||||
'endpoint': '/api/computers/dashboard/quiet',
|
||||
'render': 'exceptions',
|
||||
'severity': 'warning',
|
||||
@@ -1952,6 +1953,7 @@ class ComputersPlugin(BasePlugin):
|
||||
'id': 'computers-sharedmachines',
|
||||
'viewall': '/machines',
|
||||
'title': 'Machine numbers on two PCs',
|
||||
'shortname': 'Duplicate machine no.',
|
||||
'endpoint': '/api/computers/dashboard/sharedmachines',
|
||||
'render': 'exceptions',
|
||||
'severity': 'critical',
|
||||
|
||||
@@ -47,6 +47,17 @@ def _service_running(entry):
|
||||
return raw in _RUNNING_STATES
|
||||
|
||||
|
||||
# Paused, plus the four transitional states Windows reports while a service is
|
||||
# on its way somewhere.
|
||||
_TRANSITIONAL_STATES = ('paused', 'start pending', 'stop pending',
|
||||
'continue pending', 'pause pending', 'starting',
|
||||
'stopping', 'pausing')
|
||||
|
||||
|
||||
def _service_transitional(raw):
|
||||
return (raw or '').strip().lower() in _TRANSITIONAL_STATES
|
||||
|
||||
|
||||
def _service_name(entry):
|
||||
return entry.get('name') or entry.get('service') or 'Unknown'
|
||||
|
||||
@@ -178,7 +189,7 @@ def list_endpoints():
|
||||
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))
|
||||
row['health'] = _health_for(state, critical.get(state.assetid))
|
||||
data.append(row)
|
||||
|
||||
return paginated_response(data, page, per_page, total)
|
||||
@@ -261,30 +272,48 @@ def _machines_for_assets(assetids):
|
||||
|
||||
|
||||
def _critical_counts_for_assets(assetids):
|
||||
"""{assetid: count} of critical events in the stability window."""
|
||||
"""{assetid: (total, dominant_eventid)} of critical events in the window.
|
||||
|
||||
Broken down BY EVENT ID, not just totalled. A bare count says "418
|
||||
critical", which tells an operator nothing about what to do; naming the
|
||||
event that dominates - service timeout, application crash - is the
|
||||
difference between a number and a lead.
|
||||
"""
|
||||
if not assetids:
|
||||
return {}
|
||||
cutoff = _naive_utcnow() - timedelta(hours=48)
|
||||
rows = (db.session.query(EndpointEvent.assetid,
|
||||
rows = (db.session.query(EndpointEvent.assetid, EndpointEvent.eventid,
|
||||
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)
|
||||
.group_by(EndpointEvent.assetid, EndpointEvent.eventid)
|
||||
.all())
|
||||
return dict(rows)
|
||||
|
||||
perasset = {}
|
||||
for assetid, eventid, count in rows:
|
||||
total, dominant, best = perasset.get(assetid, (0, None, 0))
|
||||
total += count
|
||||
if count > best:
|
||||
dominant, best = eventid, count
|
||||
perasset[assetid] = (total, dominant, best)
|
||||
return {assetid: (total, dominant)
|
||||
for assetid, (total, dominant, _best) in perasset.items()}
|
||||
|
||||
|
||||
def _health_for(state, criticalcount):
|
||||
def _health_for(state, critical):
|
||||
"""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.
|
||||
to do; a percentage does not - and neither does "418 critical", which is
|
||||
why the dominant event is named rather than merely counted.
|
||||
"""
|
||||
if criticalcount:
|
||||
return {'level': 'critical',
|
||||
'label': f'{criticalcount} critical' if criticalcount > 1 else '1 critical'}
|
||||
if critical:
|
||||
total, dominant = critical
|
||||
title = event_title(dominant) if dominant else 'critical'
|
||||
label = title if total == 1 else '{} x{}'.format(title, total)
|
||||
return {'level': 'critical', 'label': label}
|
||||
|
||||
stopped = []
|
||||
try:
|
||||
@@ -450,7 +479,14 @@ def panel_services(asset_id: int):
|
||||
'name': name,
|
||||
'kind': (kind or '').lower() or None,
|
||||
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
|
||||
'statecolor': 'green' if running else ('red' if raw else 'gray'),
|
||||
# Amber for paused and the four Windows transitional states. A
|
||||
# service stuck in Start Pending is failing to start on a retry
|
||||
# cycle - which is what raises event 7011 - and a paused one was
|
||||
# paused deliberately. Painting either the same red as Stopped
|
||||
# loses the distinction that tells you which to chase.
|
||||
'statecolor': ('green' if running
|
||||
else 'yellow' if _service_transitional(raw)
|
||||
else 'red' if raw else 'gray'),
|
||||
}
|
||||
# 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
|
||||
@@ -520,6 +556,20 @@ def panel_activity(asset_id: int):
|
||||
.limit(15)
|
||||
.all())
|
||||
|
||||
# The newest 15 of ANY severity can be entirely routine logons while the
|
||||
# health rollup is shouting about a burst of crashes underneath them - the
|
||||
# page then contradicts itself for no visible reason. Pull the newest
|
||||
# criticals separately and merge, so what health is reporting is always
|
||||
# present in the feed that is supposed to explain it.
|
||||
criticals = (EndpointEvent.query
|
||||
.filter(EndpointEvent.assetid == asset_id,
|
||||
EndpointEvent.eventid.in_(CRITICAL_EVENT_IDS))
|
||||
.order_by(EndpointEvent.eventtime.desc())
|
||||
.limit(5)
|
||||
.all())
|
||||
seen = {e.endpointeventid for e in events}
|
||||
events.extend(e for e in criticals if e.endpointeventid not in seen)
|
||||
|
||||
commands = (EndpointCommand.query
|
||||
.filter_by(assetid=asset_id)
|
||||
.order_by(EndpointCommand.createdat.desc())
|
||||
@@ -563,9 +613,24 @@ def panel_activity(asset_id: int):
|
||||
# Undated rows sort last rather than crashing the comparison.
|
||||
rows.sort(key=lambda r: (r['eventtime'] is not None, r['eventtime']), reverse=True)
|
||||
|
||||
# Trim to the cap without dropping the criticals that were fetched on
|
||||
# purpose: they keep their place in time, but they survive the cut.
|
||||
if len(rows) > 15:
|
||||
keep = [r for r in rows if r['severity'] == 'critical'][:5]
|
||||
rest = [r for r in rows if r not in keep]
|
||||
rows = sorted(keep + rest[:15 - len(keep)],
|
||||
key=lambda r: (r['eventtime'] is not None, r['eventtime']),
|
||||
reverse=True)
|
||||
|
||||
# The list renderer reads a badge colour from a ROW FIELD, so without this
|
||||
# every badge falls back to grey and the feed loses the one signal it is
|
||||
# there to carry.
|
||||
colours = {'critical': 'red', 'warning': 'yellow', 'info': 'gray'}
|
||||
|
||||
return success_response([{
|
||||
'title': r['title'],
|
||||
'severity': r['severity'],
|
||||
'severitycolor': colours.get(r['severity'], 'gray'),
|
||||
'detail': r['detail'],
|
||||
'eventtime': r['eventtime'].isoformat() + 'Z' if r['eventtime'] else None,
|
||||
} for r in rows[:15]])
|
||||
|
||||
@@ -281,6 +281,18 @@ class EndpointsPlugin(BasePlugin):
|
||||
'render': 'list',
|
||||
'collapsible': True,
|
||||
'filter': {'key': 'kind', 'equals': 'service'},
|
||||
# Facet on the STATE, not the status colour. statecolor is only
|
||||
# ever green, red or grey here, so chips derived from it would
|
||||
# read "OK" and "Critical" - not how anybody talks about a
|
||||
# service. statelabel carries the real vocabulary: Running,
|
||||
# Stopped, Paused, and the Windows transitional states. Any
|
||||
# value not listed still gets a chip, appended after these.
|
||||
'facets': {
|
||||
'key': 'statelabel',
|
||||
'order': ['Running', 'Stopped', 'Paused', 'Start Pending',
|
||||
'Stop Pending', 'Continue Pending', 'Pause Pending',
|
||||
'Unknown'],
|
||||
},
|
||||
'status': {
|
||||
'key': 'statecolor',
|
||||
'levels': {
|
||||
@@ -307,6 +319,18 @@ class EndpointsPlugin(BasePlugin):
|
||||
'render': 'list',
|
||||
'collapsible': True,
|
||||
'filter': {'key': 'kind', 'equals': 'process'},
|
||||
# Facet on the STATE, not the status colour. statecolor is only
|
||||
# ever green, red or grey here, so chips derived from it would
|
||||
# read "OK" and "Critical" - not how anybody talks about a
|
||||
# service. statelabel carries the real vocabulary: Running,
|
||||
# Stopped, Paused, and the Windows transitional states. Any
|
||||
# value not listed still gets a chip, appended after these.
|
||||
'facets': {
|
||||
'key': 'statelabel',
|
||||
'order': ['Running', 'Stopped', 'Paused', 'Start Pending',
|
||||
'Stop Pending', 'Continue Pending', 'Pause Pending',
|
||||
'Unknown'],
|
||||
},
|
||||
'status': {
|
||||
'key': 'statecolor',
|
||||
'levels': {
|
||||
@@ -350,6 +374,10 @@ class EndpointsPlugin(BasePlugin):
|
||||
'title': 'title',
|
||||
'badge': {
|
||||
'label': 'severity',
|
||||
# The renderer reads the colour from a row field; with
|
||||
# no colour declared every badge renders grey, which is
|
||||
# what the feed spent its first version doing.
|
||||
'color': 'severitycolor',
|
||||
'labelmap': {
|
||||
'critical': 'Critical',
|
||||
'warning': 'Warning',
|
||||
@@ -416,6 +444,7 @@ class EndpointsPlugin(BasePlugin):
|
||||
'id': 'endpoints-quiet',
|
||||
'viewall': '/pcs',
|
||||
'title': 'Endpoints gone quiet',
|
||||
'shortname': 'Agent quiet',
|
||||
'endpoint': '/api/endpoints/dashboard/quiet',
|
||||
'render': 'exceptions',
|
||||
'severity': 'warning',
|
||||
@@ -434,6 +463,7 @@ class EndpointsPlugin(BasePlugin):
|
||||
'id': 'endpoints-stability',
|
||||
'viewall': '/pcs',
|
||||
'title': 'Endpoints with crashes or shutdowns',
|
||||
'shortname': 'Crashes',
|
||||
'endpoint': '/api/endpoints/dashboard/stability',
|
||||
'render': 'exceptions',
|
||||
'severity': 'critical',
|
||||
|
||||
@@ -86,6 +86,7 @@ class GeEnforcePlugin(BasePlugin):
|
||||
'id': 'geenforce-failures',
|
||||
'viewall': '/geenforce',
|
||||
'title': 'Enforcement failures',
|
||||
'shortname': 'Enforcement',
|
||||
'endpoint': '/api/geenforce/dashboard/failures',
|
||||
'render': 'exceptions',
|
||||
'severity': 'critical',
|
||||
|
||||
@@ -20,16 +20,6 @@
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="machine">
|
||||
<!-- Dual-bay sibling banner: a Dualpath pair is one physical machine -->
|
||||
<div v-if="machine.dualpathpartner" class="dualpath-banner">
|
||||
Dual-bay machine - sibling bay:
|
||||
<router-link
|
||||
v-if="machine.dualpathpartner.machineid"
|
||||
:to="`/machines/${machine.dualpathpartner.machineid}`"
|
||||
>{{ machine.dualpathpartner.assetnumber }}</router-link>
|
||||
<span v-else>{{ machine.dualpathpartner.assetnumber }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="machine.machine?.imageurl">
|
||||
@@ -70,6 +60,18 @@
|
||||
<span class="hero-detail-value">{{ machine.locationname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The 6am band, machine side: which PCs drive this machine. The
|
||||
same fact as a PC's "Serves machine", read from the other end -
|
||||
and the end somebody is standing at when the machine has
|
||||
stopped. It lived only in the relationships card near the
|
||||
bottom of the page. -->
|
||||
<AssetTriageBand
|
||||
:assetid="machine.assetid"
|
||||
counterparttype="computer"
|
||||
counterpartlabel="Controlled by"
|
||||
:extraitems="triageextras"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -248,11 +250,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { machinesApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
import AssetTriageBand from '@/components/AssetTriageBand.vue'
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
||||
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||
@@ -269,6 +272,25 @@ const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const machine = ref(null)
|
||||
|
||||
// The dual-bay sibling, as a triage chip. It had its own banner above the hero
|
||||
// until the band existed; two strips of chrome answering two halves of the same
|
||||
// "what am I looking at" read as clutter, and the sibling belongs beside the
|
||||
// PC that drives the machine rather than above it.
|
||||
const triageextras = computed(() => {
|
||||
const partner = machine.value?.dualpathpartner
|
||||
if (!partner) return []
|
||||
return [{
|
||||
// Says what the pair MEANS, not just that a link exists: a dualpath pair is
|
||||
// one physical machine in two bays, which is why the old banner spelled it
|
||||
// out rather than showing a bare number.
|
||||
label: 'Dual-bay sibling',
|
||||
links: [{
|
||||
text: partner.assetnumber,
|
||||
to: partner.machineid ? `/machines/${partner.machineid}` : null
|
||||
}]
|
||||
}]
|
||||
})
|
||||
const { heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.assetid)
|
||||
|
||||
// relationships render via the shared AssetRelationships card
|
||||
@@ -322,21 +344,6 @@ async function confirmRetire() {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.dualpath-banner {
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--primary);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dualpath-banner a {
|
||||
color: var(--link);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
|
||||
@@ -209,6 +209,7 @@ class MachinesPlugin(BasePlugin):
|
||||
'id': 'machines-outofservice',
|
||||
'viewall': '/machines',
|
||||
'title': 'Machines out of service',
|
||||
'shortname': 'Out of service',
|
||||
'endpoint': '/api/machines/dashboard/outofservice',
|
||||
'render': 'exceptions',
|
||||
'severity': 'warning',
|
||||
|
||||
@@ -46,6 +46,22 @@
|
||||
<span class="hero-detail-value">{{ tool.locationname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The 6am band: which PC drives this instrument.
|
||||
A metrology tool is reached through the PC that runs it - the
|
||||
import synthesizes one measuring-tool asset per metrology PC and
|
||||
a 'controls' edge PC -> tool (stage_metrology), so the
|
||||
counterpart here is a COMPUTER, exactly as on the machine page.
|
||||
This was first written as counterparttype="machine" labelled
|
||||
"Used on", which filtered for a counterpart that cannot exist
|
||||
and so rendered nothing on every gage that had a PC. A hand-held
|
||||
gage genuinely has no such edge, and the band correctly shows
|
||||
nothing for it. -->
|
||||
<AssetTriageBand
|
||||
:assetid="tool.assetid"
|
||||
counterparttype="computer"
|
||||
counterpartlabel="Controlled by"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -169,6 +185,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { measuringtoolsApi } from '@/api'
|
||||
import AssetTriageBand from '@/components/AssetTriageBand.vue'
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
||||
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||
|
||||
@@ -68,6 +68,14 @@
|
||||
<span class="hero-detail-value">{{ device.businessunitname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The 6am band: what stops if this switch or AP goes down. A network
|
||||
device is rarely interesting in itself; it matters because of what
|
||||
sits behind it. -->
|
||||
<AssetTriageBand
|
||||
:assetid="device.assetid"
|
||||
counterparttype="machine"
|
||||
/>
|
||||
<div class="hero-features" v-if="device.networkdevice">
|
||||
<span v-if="device.networkdevice.ispoe" class="feature-badge poe">PoE</span>
|
||||
<span v-if="device.networkdevice.ismanaged" class="feature-badge managed">Managed</span>
|
||||
@@ -227,6 +235,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { networkApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||
import AssetTriageBand from '@/components/AssetTriageBand.vue'
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
||||
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||
import { useWarrantyBadge } from '@/composables/warrantyBadge'
|
||||
|
||||
@@ -206,6 +206,7 @@ class NotificationsPlugin(BasePlugin):
|
||||
'id': 'notifications-active',
|
||||
'viewall': '/notifications',
|
||||
'title': 'Active notifications',
|
||||
'shortname': 'Notification',
|
||||
'endpoint': '/api/notifications/dashboard/active',
|
||||
'render': 'list',
|
||||
'severity': 'info',
|
||||
|
||||
@@ -2124,8 +2124,21 @@ def _get_low_supplies_data():
|
||||
return cached
|
||||
|
||||
service = ZabbixService()
|
||||
if not service.isconfigured or not service.isreachable:
|
||||
# "No printer is low" and "we cannot tell whether any printer is low" are
|
||||
# different answers, and this used to give the same empty one for both. The
|
||||
# dashboard then showed Low Supplies 0 while the monitoring was dead, which
|
||||
# is not a missing feature but a wrong statement: it reads as reassurance.
|
||||
if not service.isconfigured:
|
||||
# Enabled plus a URL plus a token. Absent any of those the site has not
|
||||
# asked for supply monitoring, and a permanent "not configured" line
|
||||
# would be a card reporting nothing wrong every day - which is how a
|
||||
# board teaches people to stop reading it. Nothing to say: stay hidden.
|
||||
return {'printers': [], 'summary': {'total_checked': 0, 'low': 0, 'critical': 0}}
|
||||
if not service.isreachable:
|
||||
# Configured and unanswerable is a different thing: something meant to
|
||||
# work is not working, and the cartridges are NOT known to be fine.
|
||||
return {'printers': [], 'unavailable': 'Supply monitoring is not reachable',
|
||||
'summary': {'total_checked': 0, 'low': 0, 'critical': 0}}
|
||||
|
||||
# active printers with an IP, with vendor and model for waste/part rules
|
||||
rows = (
|
||||
@@ -2655,7 +2668,10 @@ def dashboard_supplies():
|
||||
})
|
||||
|
||||
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
|
||||
return success_response(rows)
|
||||
# Envelope rather than a bare list, so the card can say WHY it has no rows.
|
||||
# An empty card and a blind card must not look the same.
|
||||
return success_response({'rows': rows,
|
||||
'unavailable': data.get('unavailable')})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -66,6 +66,16 @@
|
||||
<span class="hero-detail-value mono">{{ ipAddress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The 6am band: which machine stops if this printer is down.
|
||||
A shop-floor printer is often a label or tag printer feeding one
|
||||
machine, and that link was only visible in the relationships
|
||||
card near the bottom of the page. -->
|
||||
<AssetTriageBand
|
||||
:assetid="printer.assetid"
|
||||
counterparttype="machine"
|
||||
:relationshiptypes="['usesprinter', 'defaultprinter']"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -301,6 +311,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { printersApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
import AssetTriageBand from '@/components/AssetTriageBand.vue'
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
|
||||
import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
|
||||
import AssetRelationships from '@/components/AssetRelationships.vue'
|
||||
|
||||
@@ -354,6 +354,7 @@ class PrintersPlugin(BasePlugin):
|
||||
'id': 'printers-supplies',
|
||||
'viewall': '/reports/toner',
|
||||
'title': 'Printer supplies',
|
||||
'shortname': 'Supplies',
|
||||
'endpoint': '/api/printers/dashboard/supplies',
|
||||
'render': 'exceptions',
|
||||
'severity': 'warning',
|
||||
|
||||
@@ -87,7 +87,10 @@ def _checkout_events(checkout):
|
||||
'device_id': checkout.device.serialnumber if checkout.device else None,
|
||||
'action': 'check-in',
|
||||
'timestamp': checkout.checkintime.isoformat() + 'Z',
|
||||
'scanned_viruses': None,
|
||||
# Both read from the checkout row now. scanned_viruses was
|
||||
# hardcoded None: the shape promised the field and had nowhere to
|
||||
# read it from, so an imported history would have shown blank.
|
||||
'scanned_viruses': checkout.scannedviruses,
|
||||
'locker_location': None,
|
||||
'sanitized': checkout.waswiped,
|
||||
})
|
||||
@@ -233,6 +236,10 @@ def checkin_device(device_id, data):
|
||||
checkintime = override
|
||||
open_checkout.checkintime = checkintime
|
||||
open_checkout.waswiped = bool(data.get('sanitized'))
|
||||
# None, not False, when the caller said nothing: an unrecorded scan is
|
||||
# not a failed scan, and an audit reads those very differently.
|
||||
scanned = data.get('scanned_viruses')
|
||||
open_checkout.scannedviruses = None if scanned is None else bool(scanned)
|
||||
open_checkout.checkinnotes = data.get('notes')
|
||||
device.ischeckedout = False
|
||||
device.currentuserid = None
|
||||
|
||||
235
plugins/usb/cli.py
Normal file
235
plugins/usb/cli.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""CLI: move USB records off an external cmmc_usb database and into ShopDB.
|
||||
|
||||
flask usb import-external report what would happen, change nothing
|
||||
flask usb import-external --commit write it
|
||||
|
||||
A site running the external mode keeps its devices and its check-in/out history
|
||||
in a separate cmmc_usb database. This reads that database through the same
|
||||
connection settings the routes already use, converts it to the self-hosted
|
||||
tables, and leaves the source untouched. After a clean run, set
|
||||
usb_directory_mode to selfhosted and the external database can be retired.
|
||||
|
||||
TWO THINGS THIS HAS TO RECONCILE, and they are the reason it is a command
|
||||
rather than a few INSERTs:
|
||||
|
||||
The two schemas disagree about shape. External is an EVENT LOG - one row per
|
||||
action, check-out or check-in - while self-hosted keeps a PAIRED ROW, one
|
||||
checkout carrying both its times. Pairing is per device and in time order, and
|
||||
real logs are not tidy: a check-out with no return is an open checkout, but a
|
||||
return with no matching check-out, or two check-outs in a row, mean something
|
||||
was recorded wrong years ago. Those are reported rather than guessed at, and
|
||||
they never silently vanish.
|
||||
|
||||
Identity is the other one. usbdevices.serialnumber is unique and NOT NULL, and
|
||||
the external devices table has no serial column - only device_id. So device_id
|
||||
becomes the serial. A blank or duplicated device_id stops the import for that
|
||||
device instead of inventing a key.
|
||||
|
||||
Dry run by default. The point of the first run is the report.
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
import click
|
||||
from flask.cli import AppGroup, with_appcontext
|
||||
|
||||
from shopdb.api import db, cmmc_usb_connection
|
||||
|
||||
from .models import USBDevice, USBCheckout
|
||||
|
||||
|
||||
usb_cli = AppGroup('usb', help='USB device records.')
|
||||
|
||||
|
||||
# The external status vocabulary, mapped to the self-hosted boolean. Anything
|
||||
# unrecognised is treated as checked in and reported, rather than assumed.
|
||||
_CHECKED_OUT = {'checked-out', 'checked out', 'out', 'issued'}
|
||||
_RETIRED = {'retired', 'disposed', 'destroyed'}
|
||||
|
||||
|
||||
def _external_rows():
|
||||
"""(devices, log) from the external database, oldest log entry first."""
|
||||
conn = cmmc_usb_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT device_id, device_desc, device_owner, status, '
|
||||
'locker_location FROM devices')
|
||||
devices = cur.fetchall()
|
||||
cur.execute('SELECT log_id, badge_number, device_id, action, timestamp, '
|
||||
'scanned_viruses, locker_location, sanitized '
|
||||
'FROM checkinoutlog ORDER BY device_id, timestamp, log_id')
|
||||
log = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
return devices, log
|
||||
|
||||
|
||||
def _is_checkout(action):
|
||||
return str(action or '').strip().lower().replace('_', '-') in (
|
||||
'check-out', 'checkout', 'out')
|
||||
|
||||
|
||||
def _is_checkin(action):
|
||||
return str(action or '').strip().lower().replace('_', '-') in (
|
||||
'check-in', 'checkin', 'in', 'return')
|
||||
|
||||
|
||||
def _pair(entries, problems, deviceid):
|
||||
"""Walk one device's log in time order and fold it into checkout pairs."""
|
||||
pairs, open_row = [], None
|
||||
for row in entries:
|
||||
action = row.get('action')
|
||||
if _is_checkout(action):
|
||||
if open_row is not None:
|
||||
# Two check-outs with no return between them. The first is
|
||||
# unclosed and stays that way; say so rather than inventing a
|
||||
# return time that nobody recorded.
|
||||
problems.append(
|
||||
'device %s: check-out at %s while %s was still out - first '
|
||||
'left open' % (deviceid, row.get('timestamp'),
|
||||
open_row.get('badge_number')))
|
||||
pairs.append((open_row, None))
|
||||
open_row = row
|
||||
elif _is_checkin(action):
|
||||
if open_row is None:
|
||||
problems.append(
|
||||
'device %s: check-in at %s with no matching check-out - '
|
||||
'skipped' % (deviceid, row.get('timestamp')))
|
||||
continue
|
||||
pairs.append((open_row, row))
|
||||
open_row = None
|
||||
else:
|
||||
problems.append('device %s: unrecognised action %r at %s - skipped'
|
||||
% (deviceid, action, row.get('timestamp')))
|
||||
if open_row is not None:
|
||||
pairs.append((open_row, None))
|
||||
return pairs
|
||||
|
||||
|
||||
@usb_cli.command('import-external')
|
||||
@click.option('--commit', is_flag=True, default=False,
|
||||
help='Write the records. Without this the command only reports.')
|
||||
@with_appcontext
|
||||
def import_external(commit):
|
||||
"""Copy devices and check-in/out history from the external cmmc_usb DB."""
|
||||
try:
|
||||
devices, log = _external_rows()
|
||||
except Exception as error:
|
||||
raise click.ClickException(
|
||||
'Could not read the external database: %s\n'
|
||||
'Check the cmmc_usb_db_* settings, and that this host can reach it.'
|
||||
% error)
|
||||
|
||||
problems = []
|
||||
seen_serials = set()
|
||||
made_devices = 0
|
||||
made_checkouts = 0
|
||||
open_checkouts = 0
|
||||
|
||||
by_device = defaultdict(list)
|
||||
for row in log:
|
||||
by_device[row.get('device_id')].append(row)
|
||||
|
||||
for source in devices:
|
||||
serial = (source.get('device_id') or '').strip()
|
||||
if not serial:
|
||||
problems.append('device with no device_id - skipped, it cannot be '
|
||||
'given a serial number')
|
||||
continue
|
||||
if serial in seen_serials:
|
||||
problems.append('device_id %r appears twice - only the first is '
|
||||
'imported' % serial)
|
||||
continue
|
||||
seen_serials.add(serial)
|
||||
|
||||
status = str(source.get('status') or '').strip().lower()
|
||||
if status and status not in _CHECKED_OUT and status not in _RETIRED \
|
||||
and status not in ('checked-in', 'checked in', 'in', 'available'):
|
||||
problems.append('device %s: unrecognised status %r - treated as '
|
||||
'checked in' % (serial, source.get('status')))
|
||||
|
||||
device = USBDevice.query.filter_by(serialnumber=serial).first()
|
||||
if device is None:
|
||||
device = USBDevice(serialnumber=serial)
|
||||
db.session.add(device)
|
||||
made_devices += 1
|
||||
device.label = source.get('device_desc') or device.label
|
||||
device.storagelocation = source.get('locker_location') or device.storagelocation
|
||||
device.notes = _append_owner(device.notes, source.get('device_owner'))
|
||||
device.ischeckedout = status in _CHECKED_OUT
|
||||
|
||||
db.session.flush()
|
||||
|
||||
for out_row, in_row in _pair(by_device.get(source.get('device_id'), []),
|
||||
problems, serial):
|
||||
checkout = USBCheckout(
|
||||
usbdeviceid=device.usbdeviceid,
|
||||
sso=str(out_row.get('badge_number') or '')[:20],
|
||||
checkouttime=out_row.get('timestamp'),
|
||||
checkintime=in_row.get('timestamp') if in_row else None,
|
||||
)
|
||||
if in_row:
|
||||
checkout.waswiped = _tri(in_row.get('sanitized'))
|
||||
checkout.scannedviruses = _tri(in_row.get('scanned_viruses'))
|
||||
else:
|
||||
open_checkouts += 1
|
||||
db.session.add(checkout)
|
||||
made_checkouts += 1
|
||||
|
||||
# The device's own flag and the history have to agree, or the kiosk
|
||||
# will offer a drive that the log says somebody still holds.
|
||||
if device.ischeckedout and not _has_open(device):
|
||||
problems.append('device %s: marked checked out, but its log has no '
|
||||
'open check-out' % serial)
|
||||
|
||||
click.echo('devices read from external : %d' % len(devices))
|
||||
click.echo('log rows read : %d' % len(log))
|
||||
click.echo('devices to create : %d' % made_devices)
|
||||
click.echo('checkouts to create : %d' % made_checkouts)
|
||||
click.echo(' of which still open : %d' % open_checkouts)
|
||||
|
||||
if problems:
|
||||
click.echo('')
|
||||
click.echo('%d row(s) need a human decision:' % len(problems))
|
||||
for line in problems:
|
||||
click.echo(' - %s' % line)
|
||||
|
||||
if not commit:
|
||||
db.session.rollback()
|
||||
click.echo('')
|
||||
click.echo('DRY RUN - nothing was written. Re-run with --commit when the '
|
||||
'report above looks right.')
|
||||
return
|
||||
|
||||
db.session.commit()
|
||||
click.echo('')
|
||||
click.echo('Written. Next: set usb_directory_mode to selfhosted in Settings, '
|
||||
'confirm the kiosk and the device list against the external '
|
||||
'database, and only then retire it.')
|
||||
|
||||
|
||||
def _tri(value):
|
||||
"""External flags are 0/1/NULL. Keep NULL as None: unrecorded is not false."""
|
||||
if value is None:
|
||||
return None
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _has_open(device):
|
||||
return USBCheckout.query.filter_by(usbdeviceid=device.usbdeviceid,
|
||||
checkintime=None).count() > 0
|
||||
|
||||
|
||||
def _append_owner(notes, owner):
|
||||
"""External device_owner has no column here, so keep it rather than lose it."""
|
||||
owner = (owner or '').strip()
|
||||
if not owner:
|
||||
return notes
|
||||
line = 'Imported owner: %s' % owner
|
||||
if notes and line in notes:
|
||||
return notes
|
||||
return (notes + '\n' + line) if notes else line
|
||||
|
||||
|
||||
def build_cli():
|
||||
return [usb_cli]
|
||||
49
plugins/usb/migrations/versions/0003_checkout_scan_fields.py
Normal file
49
plugins/usb/migrations/versions/0003_checkout_scan_fields.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Record whether a returned drive was virus-scanned.
|
||||
|
||||
The external cmmc_usb checkinoutlog carries scanned_viruses and sanitized on
|
||||
every row. The self-hosted usbcheckouts table already has waswiped, which is
|
||||
the same fact as sanitized under a different name, but nothing corresponds to
|
||||
scanned_viruses. That gap was harmless while the two backends were
|
||||
alternatives, and becomes data loss the moment a site imports its history and
|
||||
cuts over: whether a returned drive was scanned is precisely what a
|
||||
removable-media audit asks.
|
||||
|
||||
Nullable on purpose, and NOT defaulted to false. A checkout recorded before
|
||||
this column existed is not a checkout where the drive went unscanned; it is one
|
||||
where nobody wrote it down. False would assert something the data does not say.
|
||||
|
||||
Idempotent, so a re-run on an already-migrated database is a no-op.
|
||||
|
||||
Revision ID: usb0003scanfields
|
||||
Revises: usb0002dropmachineid
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'usb0003scanfields'
|
||||
down_revision = 'usb0002dropmachineid'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'usbcheckouts' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('usbcheckouts')}
|
||||
if 'scannedviruses' not in cols:
|
||||
op.add_column('usbcheckouts',
|
||||
sa.Column('scannedviruses', sa.Boolean(), nullable=True))
|
||||
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if 'usbcheckouts' not in insp.get_table_names():
|
||||
return
|
||||
cols = {c['name'] for c in insp.get_columns('usbcheckouts')}
|
||||
if 'scannedviruses' in cols:
|
||||
op.drop_column('usbcheckouts', 'scannedviruses')
|
||||
@@ -133,6 +133,10 @@ class USBCheckout(BaseModel):
|
||||
checkoutreason = db.Column(db.Text, nullable=True, comment='Reason for checkout')
|
||||
checkinnotes = db.Column(db.Text, nullable=True)
|
||||
waswiped = db.Column(db.Boolean, nullable=True, comment='Was device wiped after return')
|
||||
# Nullable, and never defaulted to False: a checkout with no recorded scan
|
||||
# is one where nobody wrote it down, not one where the drive went unscanned.
|
||||
scannedviruses = db.Column(db.Boolean, nullable=True,
|
||||
comment='Was the device virus-scanned on return')
|
||||
|
||||
# Relationships
|
||||
device = db.relationship('USBDevice', backref=db.backref('checkouts', lazy='dynamic'))
|
||||
|
||||
@@ -70,6 +70,11 @@ class USBPlugin(BasePlugin):
|
||||
'docs': 'plugins/usb/README.md',
|
||||
}
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""CLI: import records off an external cmmc_usb database."""
|
||||
from .cli import build_cli
|
||||
return build_cli()
|
||||
|
||||
def get_reports(self) -> List[Dict]:
|
||||
"""Removable-media audit.
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ class WarrantyPlugin(BasePlugin):
|
||||
'id': 'warranty-expiring',
|
||||
'viewall': '/reports/warranty',
|
||||
'title': 'Warranties expiring',
|
||||
'shortname': 'Warranty',
|
||||
'endpoint': '/api/warranty/dashboard/expiring',
|
||||
'render': 'exceptions',
|
||||
'severity': 'info',
|
||||
|
||||
Reference in New Issue
Block a user