Device identity, collector enrollment file, and network device fixes
This commit is contained in:
@@ -172,6 +172,7 @@ try {
|
|||||||
# 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3).
|
# 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3).
|
||||||
# 3. C:\Enrollment\machine-number.txt - imaging value.
|
# 3. C:\Enrollment\machine-number.txt - imaging value.
|
||||||
# keyence / genspect / part-marker have no per-bay id -> no machineNo sent.
|
# keyence / genspect / part-marker have no per-bay id -> no machineNo sent.
|
||||||
|
# Those bays name their DEVICE instead, in asset-id.txt below.
|
||||||
$machineNo = ''
|
$machineNo = ''
|
||||||
foreach ($regPath in @(
|
foreach ($regPath in @(
|
||||||
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
|
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
|
||||||
@@ -236,6 +237,45 @@ try {
|
|||||||
Log "WARN could not read logged-in user: $($_.Exception.Message)"
|
Log "WARN could not read logged-in user: $($_.Exception.Message)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The DEVICE hanging off this PC - the ONE enrollment file for every bay that
|
||||||
|
# has no NTLARS/eDNC MachineNo registry to identify it. It names the asset and
|
||||||
|
# nothing else: it does not say what kind of device that is, because the pc-type
|
||||||
|
# already does, so the same file serves a part marker, a Keyence, a Genspect or
|
||||||
|
# anything added later without a new file or a client change.
|
||||||
|
#
|
||||||
|
# Deliberately NOT machine-number.txt. machinenumber answers "which bay is
|
||||||
|
# this" and is what GE-Enforce TargetMachineNumbers gates on, so naming a device
|
||||||
|
# there would silently stop every bay-gated manifest entry from matching.
|
||||||
|
#
|
||||||
|
# WHY A FILE AT ALL: every other identity the server can fall back to is derived
|
||||||
|
# from the PC - a prior link from this PC, or an asset number built from this
|
||||||
|
# hostname - so none of them survive a PC SWAP. The replacement box mints a
|
||||||
|
# SECOND record for the same physical device while the first keeps its history
|
||||||
|
# under a dead PC's name. This file is what survives.
|
||||||
|
#
|
||||||
|
# The server ADOPTS the named asset, refuses a value it cannot resolve, and
|
||||||
|
# refuses one that resolves to the wrong kind of thing - so a typo warns instead
|
||||||
|
# of inventing a phantom device.
|
||||||
|
$deviceId = ''
|
||||||
|
$idFile = 'C:\Enrollment\asset-id.txt'
|
||||||
|
if (Test-Path -LiteralPath $idFile) {
|
||||||
|
try {
|
||||||
|
$deviceId = ([string](Get-Content -LiteralPath $idFile -First 1 -ErrorAction Stop)).Trim()
|
||||||
|
if ($deviceId) { Log "deviceId from ${idFile}: $deviceId" }
|
||||||
|
} catch { Log "WARN could not read ${idFile}: $($_.Exception.Message)" }
|
||||||
|
}
|
||||||
|
# 0.12.0 shipped measuringtool-id.txt before the file was generalised. Read it
|
||||||
|
# as a fallback so a bay already staged with one keeps reporting.
|
||||||
|
if (-not $deviceId) {
|
||||||
|
$mtFile = 'C:\Enrollment\measuringtool-id.txt'
|
||||||
|
if (Test-Path -LiteralPath $mtFile) {
|
||||||
|
try {
|
||||||
|
$deviceId = ([string](Get-Content -LiteralPath $mtFile -First 1 -ErrorAction Stop)).Trim()
|
||||||
|
if ($deviceId) { Log "deviceId from ${mtFile} (pre-0.13 name): $deviceId" }
|
||||||
|
} catch { Log "WARN could not read ${mtFile}: $($_.Exception.Message)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only
|
# imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only
|
||||||
# when present; absent -> server leaves existing pctype untouched (a bare report
|
# when present; absent -> server leaves existing pctype untouched (a bare report
|
||||||
# never re-types a PC). Unmapped values return a warning, not an error.
|
# never re-types a PC). Unmapped values return a warning, not an error.
|
||||||
@@ -302,6 +342,7 @@ $body = @{ hostname = $hostname }
|
|||||||
if ($serialNumber) { $body['serialnumber'] = $serialNumber }
|
if ($serialNumber) { $body['serialnumber'] = $serialNumber }
|
||||||
if ($machineNo) { $body['machinenumber'] = $machineNo }
|
if ($machineNo) { $body['machinenumber'] = $machineNo }
|
||||||
if ($pcType) { $body['pctype'] = $pcType }
|
if ($pcType) { $body['pctype'] = $pcType }
|
||||||
|
if ($deviceId) { $body['deviceid'] = $deviceId }
|
||||||
if ($manufacturer) { $body['vendorname'] = $manufacturer }
|
if ($manufacturer) { $body['vendorname'] = $manufacturer }
|
||||||
if ($model) { $body['modelnumber'] = $model }
|
if ($model) { $body['modelnumber'] = $model }
|
||||||
if ($osVersion) { $body['osname'] = $osVersion }
|
if ($osVersion) { $body['osname'] = $osVersion }
|
||||||
@@ -312,8 +353,8 @@ $body['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:s
|
|||||||
|
|
||||||
$json = $body | ConvertTo-Json -Compress -Depth 4
|
$json = $body | ConvertTo-Json -Compress -Depth 4
|
||||||
|
|
||||||
Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10}" -f `
|
Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10} deviceId={11}" -f `
|
||||||
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp)
|
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp, $deviceId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
|
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
|
||||||
|
|||||||
@@ -106,12 +106,24 @@ class ComputersPlugin(BasePlugin):
|
|||||||
'fields': {
|
'fields': {
|
||||||
'hostname': {'type': 'string', 'required': True},
|
'hostname': {'type': 'string', 'required': True},
|
||||||
'machinenumber': {'type': 'string'},
|
'machinenumber': {'type': 'string'},
|
||||||
# The instrument this PC drives, named by the enrollment file
|
# The DEVICE hanging off this PC, named by the enrollment file
|
||||||
# measuringtool-id.txt. SEPARATE from machinenumber on purpose:
|
# asset-id.txt. One file for every bay that has no NTLARS/eDNC
|
||||||
# "which bay is this" and "which instrument is this" are
|
# MachineNo registry to identify it, and it does NOT say what
|
||||||
# different facts, and machinenumber is what GE-Enforce
|
# kind of device it is: the pc-type already does, through
|
||||||
# TargetMachineNumbers gates on - repointing it at a tool would
|
# SUBORDINATE_DEVICE_MAP.
|
||||||
# silently stop every bay-gated manifest entry matching.
|
#
|
||||||
|
# SEPARATE from machinenumber on purpose: "which bay is this"
|
||||||
|
# and "which device is this" are different facts, and
|
||||||
|
# machinenumber is what GE-Enforce TargetMachineNumbers gates
|
||||||
|
# on - repointing it at a device would silently stop every
|
||||||
|
# bay-gated manifest entry matching.
|
||||||
|
#
|
||||||
|
# Named deviceid rather than assetid because assetid is this
|
||||||
|
# contract's own RESPONSE field for a PC's integer primary key.
|
||||||
|
'deviceid': {'type': 'string'},
|
||||||
|
# Shipped in 0.12.0, before the file was generalised. Accepted
|
||||||
|
# as an alias so a bay staged with measuringtool-id.txt keeps
|
||||||
|
# reporting; deviceid wins when both arrive.
|
||||||
'measuringtoolid': {'type': 'string'},
|
'measuringtoolid': {'type': 'string'},
|
||||||
'pctype': {'type': 'string'},
|
'pctype': {'type': 'string'},
|
||||||
'pcsubtype': {'type': 'string'},
|
'pcsubtype': {'type': 'string'},
|
||||||
@@ -394,8 +406,14 @@ class ComputersPlugin(BasePlugin):
|
|||||||
# marker PC must not also claim the operation directly: several markers
|
# marker PC must not also claim the operation directly: several markers
|
||||||
# serve one operation number, so direct claims would fight over it. The
|
# serve one operation number, so direct claims would fight over it. The
|
||||||
# marker is partof the operation and control propagates along that rail.
|
# marker is partof the operation and control propagates along that rail.
|
||||||
|
# asset-id.txt, falling back to the 0.12.0 field name. One value feeds
|
||||||
|
# BOTH device paths: the file names the device and nothing else, so
|
||||||
|
# which sync consumes it follows from the pc-type.
|
||||||
|
deviceid = (payload.get('deviceid')
|
||||||
|
or payload.get('measuringtoolid') or '').strip() or None
|
||||||
|
|
||||||
partmarkers = self._sync_partmarker(comp, pctype, machinenumber,
|
partmarkers = self._sync_partmarker(comp, pctype, machinenumber,
|
||||||
warnings)
|
warnings, deviceid=deviceid)
|
||||||
|
|
||||||
# PC -> machine link from the reported machine number.
|
# PC -> machine link from the reported machine number.
|
||||||
if partmarkers:
|
if partmarkers:
|
||||||
@@ -410,7 +428,7 @@ class ComputersPlugin(BasePlugin):
|
|||||||
# get an attached MeasuringTool asset auto-created and linked.
|
# get an attached MeasuringTool asset auto-created and linked.
|
||||||
measuringtoollinks = self._sync_measuringtool_link(
|
measuringtoollinks = self._sync_measuringtool_link(
|
||||||
comp.asset, pctype, hostname, warnings,
|
comp.asset, pctype, hostname, warnings,
|
||||||
measuringtoolid=payload.get('measuringtoolid'),
|
measuringtoolid=deviceid,
|
||||||
machinenumber=machinenumber)
|
machinenumber=machinenumber)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -750,7 +768,70 @@ class ComputersPlugin(BasePlugin):
|
|||||||
'machinenumber': machine.assetnumber,
|
'machinenumber': machine.assetnumber,
|
||||||
'superseded': len(held)}]
|
'superseded': len(held)}]
|
||||||
|
|
||||||
def _sync_partmarker(self, comp, pctype, machinenumber, warnings):
|
def _is_device_of_type(self, asset, spec):
|
||||||
|
"""Is this asset the kind of device the pc-type's spec describes?
|
||||||
|
|
||||||
|
asset-id.txt names a device and says nothing about its type, so the
|
||||||
|
type is what proves the file points somewhere sensible. A measuring
|
||||||
|
tool is checked against the measuring-tool set; anything else is
|
||||||
|
checked against its machine type, which is where a Part Marker lives.
|
||||||
|
"""
|
||||||
|
if asset is None:
|
||||||
|
return False
|
||||||
|
if spec.get('assettype') == 'measuring_tool':
|
||||||
|
return asset.assetid in self._measuringtool_assetids()
|
||||||
|
try:
|
||||||
|
from plugins.machines.models import Machine, MachineType
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
row = (db.session.query(MachineType.machinetype)
|
||||||
|
.join(Machine, Machine.machinetypeid == MachineType.machinetypeid)
|
||||||
|
.filter(Machine.assetid == asset.assetid).first())
|
||||||
|
return bool(row) and row[0] == spec['typename']
|
||||||
|
|
||||||
|
def _ensure_device_rows(self, deviceasset, spec, pcasset, controls, label,
|
||||||
|
warnings):
|
||||||
|
"""Get-or-create the extension row and the PC -> device control link.
|
||||||
|
|
||||||
|
Shared by the named-device and minted paths: an adopted asset may have
|
||||||
|
no extension row, and the control link may already exist under another
|
||||||
|
label, so both are get-or-create rather than insert.
|
||||||
|
"""
|
||||||
|
from shopdb.api import AssetRelationship
|
||||||
|
try:
|
||||||
|
from plugins.machines.models import Machine, MachineType
|
||||||
|
except ImportError:
|
||||||
|
warnings.append('machines plugin unavailable; {} device skipped'
|
||||||
|
.format(spec['typename']))
|
||||||
|
return
|
||||||
|
if not Machine.query.filter_by(assetid=deviceasset.assetid).first():
|
||||||
|
devicetype = MachineType.query.filter_by(
|
||||||
|
machinetype=spec['typename']).first()
|
||||||
|
if not devicetype:
|
||||||
|
devicetype = MachineType(machinetype=spec['typename'],
|
||||||
|
description=spec.get('description'))
|
||||||
|
db.session.add(devicetype)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(Machine(assetid=deviceasset.assetid,
|
||||||
|
machinetypeid=devicetype.machinetypeid))
|
||||||
|
controlrow = AssetRelationship.query.filter_by(
|
||||||
|
sourceassetid=pcasset.assetid,
|
||||||
|
targetassetid=deviceasset.assetid,
|
||||||
|
relationshiptypeid=controls.relationshiptypeid).first()
|
||||||
|
if controlrow is not None:
|
||||||
|
controlrow.isactive = True
|
||||||
|
if not controlrow.label:
|
||||||
|
controlrow.label = label
|
||||||
|
else:
|
||||||
|
db.session.add(AssetRelationship(
|
||||||
|
sourceassetid=pcasset.assetid,
|
||||||
|
targetassetid=deviceasset.assetid,
|
||||||
|
relationshiptypeid=controls.relationshiptypeid,
|
||||||
|
label=label,
|
||||||
|
isactive=True))
|
||||||
|
|
||||||
|
def _sync_partmarker(self, comp, pctype, machinenumber, warnings,
|
||||||
|
deviceid=None):
|
||||||
"""Give a part-marker PC a marker asset of its own, under its operation.
|
"""Give a part-marker PC a marker asset of its own, under its operation.
|
||||||
|
|
||||||
Several Telesis markers serve one operation number - 0613, 0615 and
|
Several Telesis markers serve one operation number - 0613, 0615 and
|
||||||
@@ -760,10 +841,19 @@ class ComputersPlugin(BasePlugin):
|
|||||||
question about an individual marker (how many are there, which port,
|
question about an individual marker (how many are there, which port,
|
||||||
which one failed) could be asked at all.
|
which one failed) could be asked at all.
|
||||||
|
|
||||||
One marker per PC, so the PC identifies the marker and the collector can
|
One marker per PC. The marker is a machine asset of type Part Marker,
|
||||||
mint it the same way it already mints a CMM or a Keyence unit for a
|
the PC `controls` it, and the marker is `partof` the operation it
|
||||||
metrology PC. The marker is a machine asset of type Part Marker, the PC
|
serves.
|
||||||
`controls` it, and the marker is `partof` the operation it serves.
|
|
||||||
|
IDENTITY COMES FROM asset-id.txt FIRST, because everything else here is
|
||||||
|
derived from the PC and a PC is not permanent. Reuse looks for a prior
|
||||||
|
link from THIS PC asset and adoption looks up `<PC number>-PARTMARKER`,
|
||||||
|
so both survive a re-image and neither survives a PC SWAP: a new
|
||||||
|
hostname is a new PC asset with no prior link and a predicted number
|
||||||
|
that has never existed, so the same physical Telesis unit gets a second
|
||||||
|
record while the first keeps its config and backup history under a dead
|
||||||
|
PC's name. That is the failure the metrology path already learned from,
|
||||||
|
where minting from the hostname left 43 instruments shadowed by twins.
|
||||||
|
|
||||||
That last rail is why the PC does not also claim the operation directly:
|
That last rail is why the PC does not also claim the operation directly:
|
||||||
`controls` propagates through `partof` (seeded in reference-data), so
|
`controls` propagates through `partof` (seeded in reference-data), so
|
||||||
@@ -801,8 +891,26 @@ class ComputersPlugin(BasePlugin):
|
|||||||
|
|
||||||
label = spec['label']
|
label = spec['label']
|
||||||
|
|
||||||
# Reuse this PC's existing device before minting one, so a re-image
|
# --- 1. an explicitly named device wins over everything -------------
|
||||||
# never leaves a second device behind for the same physical unit.
|
named = (deviceid or '').strip()
|
||||||
|
namedasset = None
|
||||||
|
if named:
|
||||||
|
candidate = self._asset_by_number(named)
|
||||||
|
if candidate is None:
|
||||||
|
# Warn rather than invent: a typo must not mint a phantom
|
||||||
|
# device that nobody can account for.
|
||||||
|
warnings.append(
|
||||||
|
'no asset for device {!r}; not linked'.format(named))
|
||||||
|
elif not self._is_device_of_type(candidate, spec):
|
||||||
|
# The name resolved, but not to this pc-type's device. Refuse
|
||||||
|
# and say which asset it hit, rather than filing an unrelated
|
||||||
|
# asset under a device label the collector also owns.
|
||||||
|
warnings.append(
|
||||||
|
'asset {!r} is not a {}; not linked'.format(
|
||||||
|
candidate.assetnumber, spec['typename']))
|
||||||
|
else:
|
||||||
|
namedasset = candidate
|
||||||
|
|
||||||
existing = AssetRelationship.query.filter(
|
existing = AssetRelationship.query.filter(
|
||||||
AssetRelationship.sourceassetid == pcasset.assetid,
|
AssetRelationship.sourceassetid == pcasset.assetid,
|
||||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||||
@@ -811,7 +919,18 @@ class ComputersPlugin(BasePlugin):
|
|||||||
reuse = next((rel for rel in existing if rel.isactive), None) \
|
reuse = next((rel for rel in existing if rel.isactive), None) \
|
||||||
or (existing[0] if existing else None)
|
or (existing[0] if existing else None)
|
||||||
|
|
||||||
if reuse:
|
if namedasset is not None:
|
||||||
|
# The file is authoritative. A PC that previously minted its own
|
||||||
|
# marker and is now told the real one keeps only the named link;
|
||||||
|
# the stale link is archived by the one-marker-per-PC sweep below.
|
||||||
|
markerasset = namedasset
|
||||||
|
reuse = next((rel for rel in existing
|
||||||
|
if rel.targetassetid == namedasset.assetid), None)
|
||||||
|
if reuse is not None:
|
||||||
|
reuse.isactive = True
|
||||||
|
self._ensure_device_rows(markerasset, spec, pcasset, controls,
|
||||||
|
label, warnings)
|
||||||
|
elif reuse:
|
||||||
reuse.isactive = True
|
reuse.isactive = True
|
||||||
markerasset = db.session.get(Asset, reuse.targetassetid)
|
markerasset = db.session.get(Asset, reuse.targetassetid)
|
||||||
else:
|
else:
|
||||||
@@ -852,26 +971,8 @@ class ComputersPlugin(BasePlugin):
|
|||||||
db.session.add(markerasset)
|
db.session.add(markerasset)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
|
|
||||||
# The extension row may be missing on an adopted asset, and the link
|
self._ensure_device_rows(markerasset, spec, pcasset, controls,
|
||||||
# may already exist under another label - both get-or-create.
|
label, warnings)
|
||||||
if not Machine.query.filter_by(assetid=markerasset.assetid).first():
|
|
||||||
db.session.add(Machine(assetid=markerasset.assetid,
|
|
||||||
machinetypeid=devicetype.machinetypeid))
|
|
||||||
controlrow = AssetRelationship.query.filter_by(
|
|
||||||
sourceassetid=pcasset.assetid,
|
|
||||||
targetassetid=markerasset.assetid,
|
|
||||||
relationshiptypeid=controls.relationshiptypeid).first()
|
|
||||||
if controlrow is not None:
|
|
||||||
controlrow.isactive = True
|
|
||||||
if not controlrow.label:
|
|
||||||
controlrow.label = label
|
|
||||||
else:
|
|
||||||
db.session.add(AssetRelationship(
|
|
||||||
sourceassetid=pcasset.assetid,
|
|
||||||
targetassetid=markerasset.assetid,
|
|
||||||
relationshiptypeid=controls.relationshiptypeid,
|
|
||||||
label=label,
|
|
||||||
isactive=True))
|
|
||||||
|
|
||||||
# One marker per PC: archive any other collector marker link.
|
# One marker per PC: archive any other collector marker link.
|
||||||
for rel in existing:
|
for rel in existing:
|
||||||
|
|||||||
@@ -36,7 +36,9 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="item in machines" :key="item.assetid" class="clickable-row" @click="$router.push(`/machines/${item.machine?.machineid || item.assetid}`)">
|
<tr v-for="item in machines" :key="item.assetid"
|
||||||
|
:class="{ 'clickable-row': item.machine?.machineid }"
|
||||||
|
@click="item.machine?.machineid && $router.push(`/machines/${item.machine.machineid}`)">
|
||||||
<td>
|
<td>
|
||||||
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
|
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
|
||||||
</td>
|
</td>
|
||||||
@@ -58,12 +60,20 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ item.locationname || '-' }}</td>
|
<td>{{ item.locationname || '-' }}</td>
|
||||||
<td class="actions" @click.stop>
|
<td class="actions" @click.stop>
|
||||||
|
<!-- /machines/:id keys on machineid, the plugin extension id, NOT
|
||||||
|
the assetid. Falling back to the assetid lands on whichever
|
||||||
|
machine happens to carry that number: a wrong page that looks
|
||||||
|
right, which is worse than a 404 (see the same fix in
|
||||||
|
EnforcementReports and the warning in BackupHistory). With no
|
||||||
|
machineid there is no page to link to, so show nothing. -->
|
||||||
<router-link
|
<router-link
|
||||||
:to="`/machines/${item.machine?.machineid || item.assetid}`"
|
v-if="item.machine?.machineid"
|
||||||
|
:to="`/machines/${item.machine.machineid}`"
|
||||||
class="btn btn-secondary btn-sm"
|
class="btn btn-secondary btn-sm"
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<span v-else>-</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="machines.length === 0">
|
<tr v-if="machines.length === 0">
|
||||||
|
|||||||
@@ -33,22 +33,26 @@
|
|||||||
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
|
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- hero-detail / -label / -value are the styled classes every other
|
||||||
|
detail page uses. This page had its own detail-item / label / value
|
||||||
|
names, which match nothing in the stylesheet, so the label and the
|
||||||
|
value rendered as one run-together string. -->
|
||||||
<div class="hero-details">
|
<div class="hero-details">
|
||||||
<div class="detail-item" v-if="device.assetnumber">
|
<div class="hero-detail" v-if="device.assetnumber">
|
||||||
<span class="label">Asset #</span>
|
<span class="hero-detail-label">Asset #</span>
|
||||||
<span class="value">{{ device.assetnumber }}</span>
|
<span class="hero-detail-value">{{ device.assetnumber }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item" v-if="device.serialnumber">
|
<div class="hero-detail" v-if="device.serialnumber">
|
||||||
<span class="label">Serial</span>
|
<span class="hero-detail-label">Serial</span>
|
||||||
<span class="value mono">{{ device.serialnumber }}</span>
|
<span class="hero-detail-value mono">{{ device.serialnumber }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item" v-if="device.locationname">
|
<div class="hero-detail" v-if="device.locationname">
|
||||||
<span class="label">Location</span>
|
<span class="hero-detail-label">Location</span>
|
||||||
<span class="value">{{ device.locationname }}</span>
|
<span class="hero-detail-value">{{ device.locationname }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item" v-if="device.businessunitname">
|
<div class="hero-detail" v-if="device.businessunitname">
|
||||||
<span class="label">Business Unit</span>
|
<span class="hero-detail-label">Business Unit</span>
|
||||||
<span class="value">{{ device.businessunitname }}</span>
|
<span class="hero-detail-value">{{ device.businessunitname }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-features" v-if="device.networkdevice">
|
<div class="hero-features" v-if="device.networkdevice">
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
<label for="modelnumberid">Model</label>
|
<label for="modelnumberid">Model</label>
|
||||||
<select id="modelnumberid" v-model="form.modelnumberid" class="form-control">
|
<select id="modelnumberid" v-model="form.modelnumberid" class="form-control">
|
||||||
<option value="">Select Model</option>
|
<option value="">Select Model</option>
|
||||||
<option v-for="m in models" :key="m.modelnumberid" :value="m.modelnumberid">
|
<option v-for="m in filteredModels" :key="m.modelnumberid" :value="m.modelnumberid">
|
||||||
{{ m.modelnumber }}
|
{{ m.modelnumber }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -395,6 +395,15 @@ const generatedAssetNumber = computed(() => {
|
|||||||
})
|
})
|
||||||
const vendors = ref([])
|
const vendors = ref([])
|
||||||
const models = ref([])
|
const models = ref([])
|
||||||
|
|
||||||
|
// Models belong to a vendor, so picking one narrows the list - the same
|
||||||
|
// behaviour PCForm, MachineForm and PrinterForm have. With no vendor chosen the
|
||||||
|
// whole catalogue shows, rather than an empty dropdown that reads as "no models
|
||||||
|
// exist" when it means "pick a vendor first".
|
||||||
|
const filteredModels = computed(() => {
|
||||||
|
if (!form.value.vendorid) return models.value
|
||||||
|
return models.value.filter(m => m.vendorid === form.value.vendorid)
|
||||||
|
})
|
||||||
const locations = ref([])
|
const locations = ref([])
|
||||||
const statuses = ref([])
|
const statuses = ref([])
|
||||||
const businessUnits = ref([])
|
const businessUnits = ref([])
|
||||||
|
|||||||
207
tests/test_plugins/test_collector_deviceid.py
Normal file
207
tests/test_plugins/test_collector_deviceid.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
"""asset-id.txt names the device, and that name survives a PC swap.
|
||||||
|
|
||||||
|
Every other identity the collector has for a subordinate device is derived from
|
||||||
|
the PC: reuse looks for a prior link from THIS PC asset, and adoption looks up
|
||||||
|
`<PC number>-<SUFFIX>`. Both survive a re-image and neither survives a swap - a
|
||||||
|
new hostname is a new PC asset with no prior link and a predicted number that
|
||||||
|
has never existed, so the same physical device gets a second record while the
|
||||||
|
first keeps its history under a dead PC's name.
|
||||||
|
|
||||||
|
That is the failure that left 43 measuring tools shadowed by minted twins. These
|
||||||
|
tests pin the fix for BOTH device families, because the part-marker path was
|
||||||
|
modelled on the metrology path as it stood before it was fixed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shopdb.core.models import Asset, AssetType
|
||||||
|
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
||||||
|
|
||||||
|
KEY = 'deviceid-key'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def collector_key(app):
|
||||||
|
old = app.config.get('COLLECTOR_API_KEY')
|
||||||
|
app.config['COLLECTOR_API_KEY'] = KEY
|
||||||
|
yield KEY
|
||||||
|
app.config['COLLECTOR_API_KEY'] = old
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def rig(db):
|
||||||
|
for name in ('computer', 'machine', 'measuring_tool'):
|
||||||
|
if not AssetType.query.filter_by(assettype=name).first():
|
||||||
|
db.session.add(AssetType(assettype=name))
|
||||||
|
for name in ('controls', 'partof'):
|
||||||
|
if not RelationshipType.query.filter_by(relationshiptype=name).first():
|
||||||
|
db.session.add(RelationshipType(relationshiptype=name))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _report(client, key, hostname, pctype, **extra):
|
||||||
|
payload = {'hostname': hostname, 'pctype': pctype}
|
||||||
|
payload.update(extra)
|
||||||
|
return client.post('/api/collector/computers', json=payload,
|
||||||
|
headers={'X-API-Key': key})
|
||||||
|
|
||||||
|
|
||||||
|
def _asset(db, assetnumber, assettype='machine'):
|
||||||
|
"""A bare asset: an operation, or something that is NOT a device."""
|
||||||
|
at = AssetType.query.filter_by(assettype=assettype).first()
|
||||||
|
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
|
||||||
|
db.session.add(asset)
|
||||||
|
db.session.commit()
|
||||||
|
return asset
|
||||||
|
|
||||||
|
|
||||||
|
def _marker(db, assetnumber):
|
||||||
|
"""A Part Marker the collector did NOT create - the real unit on the floor.
|
||||||
|
|
||||||
|
The extension row and machine type are what make it a marker; a bare asset
|
||||||
|
of the right number is deliberately refused, which the wrong-type test pins.
|
||||||
|
"""
|
||||||
|
from plugins.machines.models import Machine, MachineType
|
||||||
|
at = AssetType.query.filter_by(assettype='machine').first()
|
||||||
|
mtype = MachineType.query.filter_by(machinetype='Part Marker').first()
|
||||||
|
if mtype is None:
|
||||||
|
mtype = MachineType(machinetype='Part Marker')
|
||||||
|
db.session.add(mtype)
|
||||||
|
db.session.flush()
|
||||||
|
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
|
||||||
|
db.session.add(asset)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(Machine(assetid=asset.assetid,
|
||||||
|
machinetypeid=mtype.machinetypeid))
|
||||||
|
db.session.commit()
|
||||||
|
return asset
|
||||||
|
|
||||||
|
|
||||||
|
def _tool(db, assetnumber):
|
||||||
|
"""A measuring tool the collector did NOT create."""
|
||||||
|
from plugins.measuringtools.models import MeasuringTool
|
||||||
|
at = AssetType.query.filter_by(assettype='measuring_tool').first()
|
||||||
|
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
|
||||||
|
db.session.add(asset)
|
||||||
|
db.session.flush()
|
||||||
|
db.session.add(MeasuringTool(assetid=asset.assetid))
|
||||||
|
db.session.commit()
|
||||||
|
return asset
|
||||||
|
|
||||||
|
|
||||||
|
def _controlled(pcname, label):
|
||||||
|
"""Asset numbers this PC controls under a collector label."""
|
||||||
|
pc = Asset.query.filter(Asset.assetnumber.ilike(pcname)).first()
|
||||||
|
if pc is None:
|
||||||
|
return []
|
||||||
|
rels = AssetRelationship.query.filter_by(
|
||||||
|
sourceassetid=pc.assetid, label=label, isactive=True).all()
|
||||||
|
return sorted(Asset.query.filter_by(assetid=r.targetassetid).first().assetnumber
|
||||||
|
for r in rels)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- part markers
|
||||||
|
|
||||||
|
def test_a_pc_swap_does_not_mint_a_second_marker(client, db, rig, collector_key):
|
||||||
|
"""THE case this exists for. Same physical marker, two different PCs."""
|
||||||
|
_asset(db, '0613')
|
||||||
|
marker = _marker(db, 'PM-0613-A')
|
||||||
|
|
||||||
|
first = _report(client, collector_key, 'FMARK100',
|
||||||
|
pctype='gea-shopfloor-partmarker',
|
||||||
|
machinenumber='0613', deviceid='PM-0613-A')
|
||||||
|
assert first.status_code in (200, 201), first.get_data(as_text=True)[:300]
|
||||||
|
|
||||||
|
# The bay's PC is replaced. New hostname, same marker named in asset-id.txt.
|
||||||
|
second = _report(client, collector_key, 'FMARK200',
|
||||||
|
pctype='gea-shopfloor-partmarker',
|
||||||
|
machinenumber='0613', deviceid='PM-0613-A')
|
||||||
|
assert second.status_code in (200, 201), second.get_data(as_text=True)[:300]
|
||||||
|
|
||||||
|
assert Asset.query.filter_by(assetnumber='FMARK100-PARTMARKER').first() is None
|
||||||
|
assert Asset.query.filter_by(assetnumber='FMARK200-PARTMARKER').first() is None
|
||||||
|
assert _controlled('FMARK200', 'collector:partmarker') == ['PM-0613-A']
|
||||||
|
assert Asset.query.filter_by(assetnumber='PM-0613-A').count() == 1
|
||||||
|
assert marker.assetid == Asset.query.filter_by(
|
||||||
|
assetnumber='PM-0613-A').first().assetid
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_the_file_a_swap_still_mints_the_old_way(client, db, rig,
|
||||||
|
collector_key):
|
||||||
|
"""The unfixed behaviour, pinned so the file's value stays visible."""
|
||||||
|
_asset(db, '0614')
|
||||||
|
assert _report(client, collector_key, 'FMARK300',
|
||||||
|
pctype='gea-shopfloor-partmarker',
|
||||||
|
machinenumber='0614').status_code in (200, 201)
|
||||||
|
assert _report(client, collector_key, 'FMARK400',
|
||||||
|
pctype='gea-shopfloor-partmarker',
|
||||||
|
machinenumber='0614').status_code in (200, 201)
|
||||||
|
assert Asset.query.filter_by(assetnumber='FMARK300-PARTMARKER').first()
|
||||||
|
assert Asset.query.filter_by(assetnumber='FMARK400-PARTMARKER').first()
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unknown_device_warns_and_links_nothing(client, db, rig,
|
||||||
|
collector_key):
|
||||||
|
_asset(db, '0616')
|
||||||
|
resp = _report(client, collector_key, 'FMARK500',
|
||||||
|
pctype='gea-shopfloor-partmarker',
|
||||||
|
machinenumber='0616', deviceid='PM-TYPO')
|
||||||
|
assert resp.status_code in (200, 201)
|
||||||
|
assert Asset.query.filter_by(assetnumber='PM-TYPO').first() is None
|
||||||
|
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
|
||||||
|
assert 'PM-TYPO' in warnings
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key):
|
||||||
|
"""A machine number pasted into asset-id.txt must not become a marker."""
|
||||||
|
_asset(db, '0617')
|
||||||
|
_asset(db, 'PLAIN-MACHINE')
|
||||||
|
resp = _report(client, collector_key, 'FMARK600',
|
||||||
|
pctype='gea-shopfloor-partmarker',
|
||||||
|
machinenumber='0617', deviceid='PLAIN-MACHINE')
|
||||||
|
assert resp.status_code in (200, 201)
|
||||||
|
assert _controlled('FMARK600', 'collector:partmarker') != ['PLAIN-MACHINE']
|
||||||
|
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
|
||||||
|
assert 'PLAIN-MACHINE' in warnings
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeat_cycles_with_the_file_are_stable(client, db, rig, collector_key):
|
||||||
|
_asset(db, '0618')
|
||||||
|
_marker(db, 'PM-0618-A')
|
||||||
|
codes = [_report(client, collector_key, 'FMARK700',
|
||||||
|
pctype='gea-shopfloor-partmarker',
|
||||||
|
machinenumber='0618', deviceid='PM-0618-A').status_code
|
||||||
|
for _ in range(3)]
|
||||||
|
assert codes == [codes[0]] * 3, codes
|
||||||
|
assert _controlled('FMARK700', 'collector:partmarker') == ['PM-0618-A']
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ measuring tools
|
||||||
|
|
||||||
|
def test_the_same_file_serves_a_metrology_bay(client, db, rig, collector_key):
|
||||||
|
"""One file, no device type in it: the pc-type decides which sync uses it."""
|
||||||
|
_tool(db, 'MT-9001')
|
||||||
|
resp = _report(client, collector_key, 'KEYENCE100',
|
||||||
|
pctype='gea-shopfloor-keyence', deviceid='MT-9001')
|
||||||
|
assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300]
|
||||||
|
assert _controlled('KEYENCE100', 'collector:measuringtool') == ['MT-9001']
|
||||||
|
assert Asset.query.filter_by(assetnumber='KEYENCE100-KEYENCE').first() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_0120_field_name_still_works(client, db, rig, collector_key):
|
||||||
|
"""measuringtool-id.txt shipped in 0.12.0; a staged bay keeps reporting."""
|
||||||
|
_tool(db, 'MT-9002')
|
||||||
|
resp = _report(client, collector_key, 'KEYENCE200',
|
||||||
|
pctype='gea-shopfloor-keyence', measuringtoolid='MT-9002')
|
||||||
|
assert resp.status_code in (200, 201)
|
||||||
|
assert _controlled('KEYENCE200', 'collector:measuringtool') == ['MT-9002']
|
||||||
|
|
||||||
|
|
||||||
|
def test_deviceid_wins_when_both_arrive(client, db, rig, collector_key):
|
||||||
|
_tool(db, 'MT-9003')
|
||||||
|
_tool(db, 'MT-9004')
|
||||||
|
resp = _report(client, collector_key, 'KEYENCE300',
|
||||||
|
pctype='gea-shopfloor-keyence',
|
||||||
|
deviceid='MT-9003', measuringtoolid='MT-9004')
|
||||||
|
assert resp.status_code in (200, 201)
|
||||||
|
assert _controlled('KEYENCE300', 'collector:measuringtool') == ['MT-9003']
|
||||||
Reference in New Issue
Block a user