remote-access protocol collection; dashboard site timezone + badge alignment

This commit is contained in:
2026-08-10 08:36:39 -04:00
parent e68f522132
commit d9d08f53ac
4 changed files with 228 additions and 20 deletions

View File

@@ -237,7 +237,8 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { notificationsApi, businessUnitsApi, dashboardDefaultsApi } from '@/api'
import { notificationsApi, businessUnitsApi, dashboardDefaultsApi, settingsApi } from '@/api'
import { formatInZone, zonedInputFromUtc, DEFAULT_TZ } from '@/utils/datetime'
import { getFacilityName, getSiteLogo, getServicenowUrls } from '@/utils/siteSettings'
import { withBase } from '@/utils/basePath'
@@ -386,17 +387,30 @@ const upcomingNotifications = computed(() =>
)
// Clock
// Site timezone drives every time on the board. Defaults until the setting
// loads, so a slow settings call shows plausible time rather than blank.
const siteTimezone = ref(DEFAULT_TZ)
const currentTime = ref('')
function updateClock() {
const now = new Date()
currentTime.value = now.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
// The wall clock too: a board showing a different time to the cards beneath
// it is worse than either being wrong on its own.
currentTime.value = formatInZone(new Date(), siteTimezone.value, {
year: undefined, month: undefined, day: undefined,
hour: '2-digit', minute: '2-digit', second: '2-digit'
})
}
onMounted(async () => {
// Before the first clock tick, so the board never renders viewer-zone time.
try {
const response = await settingsApi.get('site_timezone')
const value = response?.data?.data?.value
if (value) siteTimezone.value = value
} catch (e) {
// Keep the default zone; the board must still come up.
}
updateClock()
setInterval(updateClock, 1000)
@@ -528,15 +542,24 @@ function stateKind(item) {
// timestamp alone makes a reader do the arithmetic.
function startsWhen(dateStr) {
if (!dateStr) return 'SOON'
const start = new Date(dateStr)
const time = start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
const startday = new Date(start.getFullYear(), start.getMonth(), start.getDate())
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = Math.round((startday - today) / 86400000)
const time = formatInZone(dateStr, siteTimezone.value, {
year: undefined, month: undefined, day: undefined,
hour: 'numeric', minute: '2-digit'
})
// TODAY/TOMORROW has to be decided on the SITE's calendar day. Doing the
// arithmetic on browser-local days makes the wording itself wrong for a
// viewer in another zone - a card can read TOMORROW to a remote admin and
// TODAY on the board, from the same data.
const startday = zonedInputFromUtc(dateStr, siteTimezone.value).slice(0, 10)
const todayday = zonedInputFromUtc(new Date(), siteTimezone.value).slice(0, 10)
const days = Math.round(
(new Date(`${startday}T00:00:00`) - new Date(`${todayday}T00:00:00`)) / 86400000)
if (days <= 0) return `TODAY ${time}`
if (days === 1) return `TOMORROW ${time}`
const when = start.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
const when = formatInZone(dateStr, siteTimezone.value, {
weekday: 'short', month: 'short', day: 'numeric',
hour: undefined, minute: undefined
})
return `${when} ${time}`
}
@@ -604,17 +627,20 @@ function textOn(backgroundcolor) {
return (0.2126 * r + 0.7152 * g + 0.0722 * b) > 150 ? '#231b00' : '#fff'
}
// SITE zone, not the viewer's. A board hangs on a wall in the plant: it must
// read plant time whatever the machine driving it is set to, and a kiosk with a
// wrong system timezone would otherwise silently show wrong times to the floor.
function formatTime(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
return formatInZone(dateStr, siteTimezone.value, {
year: undefined, month: undefined, day: undefined,
hour: '2-digit', minute: '2-digit'
})
}
function formatDateTime(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString('en-US', {
return formatInZone(dateStr, siteTimezone.value, {
weekday: 'short',
month: 'short',
day: 'numeric',
@@ -935,7 +961,9 @@ function handlePhotoError(e) {
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 8px;
/* No margin-bottom. .chip-row centres each item's MARGIN box, so a bottom
margin here lifted this chip ~4px and left the state chip beside it
looking low. The row already provides the spacing underneath. */
}
/* Type colour as a bar down the left edge, the way the legacy board marked a

View File

@@ -124,6 +124,18 @@ class ComputersPlugin(BasePlugin):
'(Win32_Printer): windows name / share / '
'hostname / IP. Unresolved -> warning.'),
},
'accessprotocols': {
'type': 'array',
'items': {'type': 'string'},
'description': ('Remote-access protocols this PC actually '
'exposes, by catalog name (VNC, WinRM, '
'RDP). Presence of the key drives the sync: '
'reported protocols are activated and '
'catalogued ones not reported are '
'deactivated. Omit the key entirely to '
'leave existing rows alone - most came from '
'the legacy isvnc/iswinrm migration.'),
},
},
}
@@ -262,6 +274,9 @@ class ComputersPlugin(BasePlugin):
computerid=comp.computerid, appid=app.appid,
installedversion=version))
# Remote-access protocol sync (only when the payload carried the key).
accessprotocols = self._sync_access_protocols(comp, payload, warnings)
# Printer relationship sync (only when the payload carried printer data).
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
@@ -281,11 +296,75 @@ class ComputersPlugin(BasePlugin):
'printerlinkcount': len(printerlinks),
'measuringtoollinks': measuringtoollinks,
'measuringtoollinkcount': len(measuringtoollinks),
'accessprotocols': accessprotocols,
},
}
# -- printer relationship sync -----------------------------------------
def _sync_access_protocols(self, comp, payload, warnings):
"""Idempotently sync a PC's remote-access protocols from the collector.
Payload key 'accessprotocols' is a list of protocol NAMES as they appear
in the accessprotocols catalog ('VNC', 'WinRM', 'RDP'), matched
case-insensitively. An unknown name warns and is skipped rather than
creating a protocol: the catalog is admin-managed on purpose, so a
typo on one bay must not invent a protocol for the whole site.
Presence of the key drives the sync, exactly like the printer links: a
reported protocol is activated, and a catalogued protocol the PC did
NOT report is deactivated (not deleted, so a port override survives a
temporary outage). A payload with no 'accessprotocols' key leaves every
existing row untouched - most PCs' rows came from the legacy
isvnc/iswinrm migration and must not be wiped by a collector that
simply does not report them yet.
Returns the list of active protocol names after the sync.
"""
from plugins.computers.models import AccessProtocol, ComputerAccess
if 'accessprotocols' not in payload:
return []
reported = payload.get('accessprotocols') or []
if not isinstance(reported, list):
warnings.append('accessprotocols must be a list of protocol names')
return []
wanted = set()
for name in reported:
name = str(name or '').strip()
if not name:
continue
protocol = AccessProtocol.query.filter(
AccessProtocol.name.ilike(name)).first()
if not protocol:
warnings.append('unknown access protocol: {}'.format(name))
continue
wanted.add(protocol.protocolid)
existing = {row.protocolid: row for row in
ComputerAccess.query.filter_by(computerid=comp.computerid).all()}
for protocolid in wanted:
row = existing.get(protocolid)
if row:
row.isactive = True
else:
db.session.add(ComputerAccess(
computerid=comp.computerid, protocolid=protocolid,
isactive=True))
# Deactivate what the PC no longer exposes. Kept as rows so a manual
# portoverride is not lost the first time a service is briefly down.
for protocolid, row in existing.items():
if protocolid not in wanted:
row.isactive = False
names = [p.name for p in AccessProtocol.query.filter(
AccessProtocol.protocolid.in_(wanted)).all()] if wanted else []
return sorted(names)
def _sync_printer_links(self, pcasset, payload, warnings):
"""Idempotently sync PC->printer relationships from collector printer data.

View File

@@ -11,6 +11,7 @@ from flask_jwt_extended import jwt_required
from shopdb.api import db, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..models import Notification, NotificationType
from ..models.notification import _utc_iso
from shopdb.api import require_permission, Setting
@@ -792,8 +793,12 @@ def get_shopfloor_notifications():
result = {
'notificationid': n.notificationid,
'notification': n.notification,
'starttime': n.starttime.isoformat() if n.starttime else None,
'endtime': n.endtime.isoformat() if n.endtime else None,
# _utc_iso, not a bare isoformat: these are stored NAIVE but hold
# UTC, and an untagged string is read by the browser as LOCAL time,
# shifting every card on the board by the tz offset. The model's
# to_dict already learned this; the shopfloor feed had not.
'starttime': _utc_iso(n.starttime),
'endtime': _utc_iso(n.endtime),
'ticketnumber': n.ticketnumber,
'link': n.link,
'isactive': n.isactive,

View File

@@ -432,3 +432,99 @@ def test_repurposed_pc_archives_tool_link(client, db, collector_key,
label='collector:measuringtool').all()
assert len(rels) == 1
assert rels[0].isactive is False
# =============================================================================
# Remote-access protocol sync (RealVNC and friends)
# =============================================================================
@pytest.fixture
def access_protocols(db):
"""Seed the admin-managed protocol catalog the collector matches against."""
from plugins.computers.models import AccessProtocol
db.session.add_all([
AccessProtocol(name='VNC', scheme='vnc', defaultport=5900,
linktemplate='vnc://{host}:{port}'),
AccessProtocol(name='WinRM', scheme='https', defaultport=5986,
linktemplate='https://{host}:{port}'),
])
db.session.commit()
def _post(client, collector_key, payload):
return client.post('/api/collector/computers', json=payload,
headers={'X-API-Key': collector_key})
def _active_protocols(hostname):
from plugins.computers.models import Computer, ComputerAccess, AccessProtocol
comp = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
rows = ComputerAccess.query.filter_by(computerid=comp.computerid,
isactive=True).all()
ids = [r.protocolid for r in rows]
return sorted(p.name for p in AccessProtocol.query.filter(
AccessProtocol.protocolid.in_(ids)).all()) if ids else []
def test_reported_vnc_is_recorded(client, db, collector_key,
computer_assettype, access_protocols):
response = _post(client, collector_key,
{'hostname': 'BAY001', 'accessprotocols': ['VNC']})
assert response.status_code == 200, response.get_json()
assert _active_protocols('BAY001') == ['VNC']
def test_protocol_match_is_case_insensitive(client, db, collector_key,
computer_assettype, access_protocols):
_post(client, collector_key,
{'hostname': 'BAY002', 'accessprotocols': ['vnc']})
assert _active_protocols('BAY002') == ['VNC']
def test_protocol_no_longer_reported_is_deactivated(client, db, collector_key,
computer_assettype,
access_protocols):
"""RealVNC removed from a bay must stop showing as available."""
_post(client, collector_key,
{'hostname': 'BAY003', 'accessprotocols': ['VNC', 'WinRM']})
assert _active_protocols('BAY003') == ['VNC', 'WinRM']
_post(client, collector_key,
{'hostname': 'BAY003', 'accessprotocols': ['WinRM']})
assert _active_protocols('BAY003') == ['WinRM']
def test_omitting_the_key_leaves_existing_rows_alone(client, db, collector_key,
computer_assettype,
access_protocols):
"""Most rows came from the legacy isvnc/iswinrm migration. A collector that
does not report protocols yet must not wipe them."""
_post(client, collector_key,
{'hostname': 'BAY004', 'accessprotocols': ['VNC']})
_post(client, collector_key, {'hostname': 'BAY004', 'loggedinuser': 'someone'})
assert _active_protocols('BAY004') == ['VNC']
def test_empty_list_deactivates_everything(client, db, collector_key,
computer_assettype, access_protocols):
"""An explicit empty list means 'this PC exposes nothing' - distinct from
omitting the key."""
_post(client, collector_key,
{'hostname': 'BAY005', 'accessprotocols': ['VNC']})
_post(client, collector_key, {'hostname': 'BAY005', 'accessprotocols': []})
assert _active_protocols('BAY005') == []
def test_unknown_protocol_warns_and_does_not_create_one(client, db, collector_key,
computer_assettype,
access_protocols):
"""The catalog is admin-managed: a typo on one bay must not invent a
protocol for the whole site."""
from plugins.computers.models import AccessProtocol
response = _post(client, collector_key,
{'hostname': 'BAY006', 'accessprotocols': ['TeamViewer']})
body = response.get_json()['data']
assert any('TeamViewer' in w for w in body['warnings'])
assert AccessProtocol.query.filter(
AccessProtocol.name.ilike('TeamViewer')).first() is None