Printer assignment end to end, and a container that keeps a site's files
Assign printers to a MACHINE and let whichever PC controls it install them, with the observed half kept strictly apart from the assigned half: a collector report can never write an assignment row. Adoption is explicit. Adds a singular relationship type so defaultprinter can mean exactly one, client scripts that converge a bay's queues and correct a drifted one in place, and a wave gate so a fleet-wide driver rollout cannot stampede its own share. On the container side: the compose stack never persisted the instance directory, so a rebuild discarded plugins.json and every upload. It is a volume now, the image ships instance/ owned by the app user so the mountpoint is writable, and the database moves to MySQL 8.4 LTS, which the Windows runbook has recommended since 8.0 went end of life. The image also could not build at all: the frontend stage never copied the plugin staging script it runs. Adds a topology migration guide covering IIS to Docker and back.
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
"""Computers plugin API endpoints."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint, request, Response, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
|
||||
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
|
||||
|
||||
from shopdb.api import require_permission, apply_import_timestamps
|
||||
from shopdb.api import require_permission, require_role, apply_import_timestamps
|
||||
|
||||
computers_bp = Blueprint('computers', __name__)
|
||||
|
||||
@@ -1064,3 +1064,112 @@ def dashboard_sharedmachines():
|
||||
|
||||
out.sort(key=lambda r: -r['pccount'])
|
||||
return success_response(out)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Collector client script
|
||||
# =============================================================================
|
||||
|
||||
CLIENT_SCRIPT_NAME = 'Report-AssetToShopDB.ps1'
|
||||
|
||||
|
||||
def _client_script_path():
|
||||
"""The reporter shipped with this plugin, which is the single source."""
|
||||
import os
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
'client', CLIENT_SCRIPT_NAME)
|
||||
|
||||
|
||||
def _setting_value(key):
|
||||
row = Setting.query.filter_by(key=key).first()
|
||||
return ((row.value if row else '') or '').strip()
|
||||
|
||||
|
||||
def _generate_client_script(source: str, baseurl: str, ranges: str,
|
||||
version: str, generatedon: str) -> str:
|
||||
"""Stamp a site's own values into the reporter's parameter defaults.
|
||||
|
||||
ONLY the defaults are substituted, never the body: the file in the repo
|
||||
stays runnable as-is, so there is no second copy to drift. Everything
|
||||
stamped here is overridable at runtime - the parameter still wins, then the
|
||||
registry - because a bay may need to differ from its site.
|
||||
|
||||
The collector key is NOT stamped in. This file lands on every shop-floor PC,
|
||||
and a token in a file on hundreds of bays cannot be rotated quietly; it is
|
||||
read from HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey, provisioned per
|
||||
ADOPTING-AT-ANOTHER-SITE.md.
|
||||
"""
|
||||
apiurl = baseurl.rstrip('/') + '/api/collector/computers' if baseurl else ''
|
||||
header = (
|
||||
'# GENERATED by ShopDB {version} on {generatedon}\n'
|
||||
'# for {baseurl}\n'
|
||||
'#\n'
|
||||
'# Re-download after upgrading ShopDB: this copy matches that server\'s\n'
|
||||
'# collector contract. Edits here are lost on the next download - change\n'
|
||||
'# the site settings instead, or pass -ApiUrl / -AllowedRanges.\n'
|
||||
'#\n'
|
||||
'# The collector key is deliberately NOT in this file. Provision it as\n'
|
||||
'# HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey - see the adoption guide.\n'
|
||||
'\n'
|
||||
).format(version=version, generatedon=generatedon,
|
||||
baseurl=baseurl or 'an unconfigured site (set site_base_url)')
|
||||
|
||||
out = source
|
||||
if apiurl:
|
||||
old = "[string]$ApiUrl = ''"
|
||||
assert old in out, 'the reporter no longer declares $ApiUrl as expected'
|
||||
out = out.replace(old, "[string]$ApiUrl = '{0}'".format(apiurl), 1)
|
||||
if ranges:
|
||||
old = "[string]$AllowedRanges = ''"
|
||||
assert old in out, 'the reporter no longer declares $AllowedRanges as expected'
|
||||
out = out.replace(old, "[string]$AllowedRanges = '{0}'".format(ranges), 1)
|
||||
return header + out
|
||||
|
||||
|
||||
@computers_bp.route('/client-script', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_role('admin')
|
||||
def download_client_script():
|
||||
"""The collector reporter, stamped with THIS site's values.
|
||||
|
||||
Admin-only. It carries no secret, but it does state a site's URL and its
|
||||
internal ranges, which is configuration rather than something to hand out.
|
||||
"""
|
||||
import datetime
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
path = _client_script_path()
|
||||
if not os.path.isfile(path):
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
'The collector script is not present in this install',
|
||||
http_code=404)
|
||||
|
||||
with open(path, 'r', encoding='utf-8') as handle:
|
||||
source = handle.read()
|
||||
|
||||
# A site that has not set its public URL still gets a usable script: the
|
||||
# browsing origin is the server the admin is talking to right now.
|
||||
baseurl = _setting_value('site_base_url') or request.url_root
|
||||
# From config, not an import: a plugin reaching into core is an ADR-002
|
||||
# violation and the contract test fails the build for it.
|
||||
version = current_app.config.get('VERSION') or 'unknown'
|
||||
generatedon = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d')
|
||||
|
||||
try:
|
||||
body = _generate_client_script(
|
||||
source, baseurl.strip(), _setting_value('computers_routableranges'),
|
||||
version, generatedon)
|
||||
except AssertionError as exc:
|
||||
return error_response(ErrorCodes.INTERNAL_ERROR, str(exc), http_code=500)
|
||||
|
||||
digest = hashlib.sha256(body.encode('utf-8')).hexdigest()
|
||||
return Response(
|
||||
body,
|
||||
mimetype='text/plain; charset=utf-8',
|
||||
headers={
|
||||
'Content-Disposition': 'attachment; filename={0}'.format(CLIENT_SCRIPT_NAME),
|
||||
# Published so a deployment can verify what it fetched, the same way
|
||||
# the installer publishes one.
|
||||
'X-Script-Sha256': digest,
|
||||
})
|
||||
|
||||
328
plugins/computers/client/Report-AssetToShopDB.ps1
Normal file
328
plugins/computers/client/Report-AssetToShopDB.ps1
Normal file
@@ -0,0 +1,328 @@
|
||||
# Report-AssetToShopDB.ps1
|
||||
#
|
||||
# Reports a PC's identity to ShopDB (Flask) so the computers/machines record
|
||||
# stays current with whatever the PC actually is right now: hostname, BIOS
|
||||
# serial, pc-type, logged-in user, DNC machine number (2001, 2002, ... when
|
||||
# present) and its corp/AESFMA IPv4 address.
|
||||
#
|
||||
# TARGET: the ADR-006 collector API.
|
||||
# POST <shopdb>/api/collector/computers
|
||||
# The server is NOT baked in. It comes from HKLM:\SOFTWARE\GE\ShopDB BaseUrl,
|
||||
# which Install-GEEnforce.ps1 provisions and the enforcement client already
|
||||
# needs, or from -ApiUrl in the manifest entry's Args. ADR-015: a site name in
|
||||
# product code is a defect, and this script ships to every site.
|
||||
# The computers plugin's apply_collector_payload upserts idempotently by
|
||||
# hostname (create if missing, patch-style update if present - only the fields
|
||||
# posted here change, so it never clobbers model/VNC/WinRM). Machine number maps
|
||||
# to Asset.assetnumber; the placeholder 9999 is skipped server-side.
|
||||
#
|
||||
# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only
|
||||
# covers the geenforce fetch/report endpoints). It needs a collector.ingest key,
|
||||
# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB
|
||||
# CollectorKey (same secret store the GE-Enforce client uses; provisioned at
|
||||
# imaging), or overridden via the manifest entry's Args -ApiKey. Never bake the
|
||||
# key into the manifest JSON on the share.
|
||||
#
|
||||
# Deployed in common\ (runs on EVERY shopfloor pc-type). Non-DNC bays report
|
||||
# identity with no machineNo, so no PC-to-machine link is built - by design.
|
||||
# Runs every GE-Enforce cycle as a Type=PS1 / DetectionMethod=Always entry under
|
||||
# the SYSTEM task. Always exits 0 so "last run result" stays clean; failures are
|
||||
# logged, never thrown.
|
||||
#
|
||||
# WHY ONE NIC ONLY:
|
||||
# Some bays carry two NICs - a private controller NIC and the routable
|
||||
# corporate NIC. Only the routable one belongs in ShopDB. A site may name its
|
||||
# corporate ranges (-AllowedRanges, or the CollectorRanges registry value); with
|
||||
# none configured the NIC carrying the DEFAULT ROUTE is used, which expresses
|
||||
# the same intent without knowing any site's addressing.
|
||||
|
||||
param(
|
||||
# Flask collector endpoint for the computers plugin. Empty resolves from
|
||||
# HKLM:\SOFTWARE\GE\ShopDB BaseUrl; override here if the path ever moves.
|
||||
[string]$ApiUrl = '',
|
||||
|
||||
# Comma-separated CIDRs naming this site's routable ranges, e.g.
|
||||
# '10.20.0.0/23,10.21.4.0/26'. Empty uses the default-route NIC instead.
|
||||
[string]$AllowedRanges = '',
|
||||
|
||||
# collector.ingest key (X-API-Key). Default: read from the GE-Enforce secret
|
||||
# store in the registry. Override with -ApiKey via Args for testing.
|
||||
[string]$ApiKey = '',
|
||||
|
||||
[int]$TimeoutSec = 30
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
# Force TLS 1.2 - older images default to SystemDefault which may negotiate a
|
||||
# protocol the site rejects; the collector POST is HTTPS.
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
$logDir = 'C:\Logs\Shopfloor'
|
||||
if (-not (Test-Path $logDir)) {
|
||||
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
$logFile = Join-Path $logDir ('report-asset-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
|
||||
|
||||
function Log([string]$msg) {
|
||||
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
|
||||
}
|
||||
|
||||
# Server from the GE-Enforce config hive when not passed via Args. Any site
|
||||
# running this script is running the enforcement client, which cannot work
|
||||
# without BaseUrl, so it is present wherever this is deployed.
|
||||
if (-not $ApiUrl) {
|
||||
foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
|
||||
try {
|
||||
if (Test-Path $regPath) {
|
||||
$base = [string](Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction Stop).BaseUrl
|
||||
if ($base -and $base.Trim()) {
|
||||
$ApiUrl = $base.Trim().TrimEnd('/') + '/api/collector/computers'
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (-not $ApiUrl) {
|
||||
Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -ApiUrl). Skipping.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# A site may name its routable ranges rather than rely on the default route.
|
||||
if (-not $AllowedRanges) {
|
||||
foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
|
||||
try {
|
||||
if (Test-Path $regPath) {
|
||||
$v = [string](Get-ItemProperty -Path $regPath -Name CollectorRanges -ErrorAction Stop).CollectorRanges
|
||||
if ($v -and $v.Trim()) { $AllowedRanges = $v.Trim(); break }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
# collector key from the GE-Enforce secret store when not passed via Args.
|
||||
if (-not $ApiKey) {
|
||||
foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) {
|
||||
try {
|
||||
if (Test-Path $regPath) {
|
||||
$v = [string](Get-ItemProperty -Path $regPath -Name CollectorKey -ErrorAction Stop).CollectorKey
|
||||
if ($v -and $v.Trim()) { $ApiKey = $v.Trim(); break }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (-not $ApiKey) {
|
||||
Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Routable ranges, if this site named any. NO SITE ADDRESSING IS BAKED IN: an
|
||||
# unconfigured site falls through to the default-route NIC below (ADR-015).
|
||||
#
|
||||
# NOT named $allowedRanges: PowerShell variable names are case-insensitive, so
|
||||
# that collides with the [string] parameter above and the array is silently
|
||||
# COERCED to a string. .Count on a string is 1, so the script then believes a
|
||||
# range is configured and never falls back to the default route.
|
||||
$rangeList = @()
|
||||
foreach ($cidr in ($AllowedRanges -split ',')) {
|
||||
$cidr = $cidr.Trim()
|
||||
if (-not $cidr) { continue }
|
||||
$parts = $cidr -split '/'
|
||||
if ($parts.Count -ne 2) { Log "WARN ignoring malformed range '$cidr'"; continue }
|
||||
$rangeList += @{ Network = $parts[0].Trim(); PrefixLen = [int]$parts[1] }
|
||||
}
|
||||
|
||||
function ConvertTo-Uint32([string]$ip) {
|
||||
$bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes()
|
||||
[Array]::Reverse($bytes)
|
||||
return [BitConverter]::ToUInt32($bytes, 0)
|
||||
}
|
||||
|
||||
function Test-InAllowedRange([string]$ip) {
|
||||
try {
|
||||
$ipInt = ConvertTo-Uint32 $ip
|
||||
foreach ($r in $rangeList) {
|
||||
$netInt = ConvertTo-Uint32 $r.Network
|
||||
$mask = [uint32]([math]::Pow(2, 32) - [math]::Pow(2, 32 - $r.PrefixLen))
|
||||
if (($ipInt -band $mask) -eq ($netInt -band $mask)) { return $true }
|
||||
}
|
||||
} catch {}
|
||||
return $false
|
||||
}
|
||||
|
||||
Log '=== Report asset to ShopDB (collector) ==='
|
||||
|
||||
# hostname - the collector identity field. required.
|
||||
$hostname = $env:COMPUTERNAME
|
||||
|
||||
# BIOS serial - optional now (collector keys on hostname). Sent when present.
|
||||
$serialNumber = ''
|
||||
try {
|
||||
$serialNumber = (Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber
|
||||
if ($serialNumber) { $serialNumber = $serialNumber.Trim() }
|
||||
} catch {
|
||||
Log "WARN could not read BIOS serial: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Machine identifier - optional, sent only if found. Maps to Asset.assetnumber
|
||||
# server-side (9999 placeholder skipped there and here).
|
||||
# 1. eDNC registry (WOW6432Node, then native) - DNC/collections bays (2001...).
|
||||
# 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3).
|
||||
# 3. C:\Enrollment\machine-number.txt - imaging value.
|
||||
# keyence / genspect / part-marker have no per-bay id -> no machineNo sent.
|
||||
$machineNo = ''
|
||||
foreach ($regPath in @(
|
||||
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
|
||||
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General'
|
||||
)) {
|
||||
if ($machineNo) { break }
|
||||
try {
|
||||
if (Test-Path $regPath) {
|
||||
$v = [string](Get-ItemProperty -Path $regPath -Name MachineNo -ErrorAction Stop).MachineNo
|
||||
if ($v -and $v.Trim() -ne '9999') { $machineNo = $v.Trim() }
|
||||
}
|
||||
} catch {
|
||||
Log "WARN could not read MachineNo from ${regPath}: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
if (-not $machineNo) {
|
||||
$cmmFile = 'C:\Enrollment\cmm\cmmid.txt'
|
||||
if (Test-Path -LiteralPath $cmmFile) {
|
||||
try {
|
||||
$v = ([string](Get-Content -LiteralPath $cmmFile -First 1 -ErrorAction Stop)).Trim()
|
||||
if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $cmmFile (CMM bay id): $machineNo" }
|
||||
} catch { Log "WARN could not read ${cmmFile}: $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
if (-not $machineNo) {
|
||||
$mnFile = 'C:\Enrollment\machine-number.txt'
|
||||
if (Test-Path -LiteralPath $mnFile) {
|
||||
try {
|
||||
$v = ([string](Get-Content -LiteralPath $mnFile -First 1 -ErrorAction Stop)).Trim()
|
||||
if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $mnFile (imaging value): $machineNo" }
|
||||
} catch { Log "WARN could not read ${mnFile}: $($_.Exception.Message)" }
|
||||
}
|
||||
}
|
||||
|
||||
# OS name string (caption + feature-update + build), e.g.
|
||||
# "Microsoft Windows 11 Enterprise 23H2 (build 22631)". Server upserts each
|
||||
# distinct string into operatingsystems.
|
||||
$osVersion = ''
|
||||
$lastBootTime = ''
|
||||
try {
|
||||
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
|
||||
$osVersion = "$($os.Caption)".Trim()
|
||||
$displayVersion = ''
|
||||
try {
|
||||
$displayVersion = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion
|
||||
} catch {}
|
||||
if ($displayVersion) { $osVersion += " $displayVersion" }
|
||||
if ($os.BuildNumber) { $osVersion += " (build $($os.BuildNumber))" }
|
||||
$osVersion = $osVersion.Trim()
|
||||
# ISO 8601 - collector parses via datetime.fromisoformat.
|
||||
try { $lastBootTime = $os.LastBootUpTime.ToString('yyyy-MM-ddTHH:mm:ss') } catch {}
|
||||
} catch {}
|
||||
|
||||
# interactive console user. runs as SYSTEM, so use Win32_ComputerSystem.UserName
|
||||
# (console session owner). empty when nobody logged on -> omitted so an
|
||||
# unattended bay does not blank the last-known user.
|
||||
$loggedInUser = ''
|
||||
try {
|
||||
$loggedInUser = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).UserName
|
||||
if ($loggedInUser) { $loggedInUser = ($loggedInUser -split '\\')[-1].Trim() }
|
||||
} catch {
|
||||
Log "WARN could not read logged-in user: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only
|
||||
# when present; absent -> server leaves existing pctype untouched (a bare report
|
||||
# never re-types a PC). Unmapped values return a warning, not an error.
|
||||
$pcType = ''
|
||||
$ptFile = 'C:\Enrollment\pc-type.txt'
|
||||
if (Test-Path -LiteralPath $ptFile) {
|
||||
try {
|
||||
$pcType = (Get-Content -LiteralPath $ptFile -First 1 -ErrorAction Stop).Trim()
|
||||
} catch { Log "WARN could not read ${ptFile}: $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# PC make/model from WMI. Server resolves/creates vendor + model. Sent only when
|
||||
# present so a WMI read failure does not blank the model.
|
||||
$manufacturer = ''
|
||||
$model = ''
|
||||
try {
|
||||
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
|
||||
$manufacturer = "$($cs.Manufacturer)".Trim()
|
||||
$model = "$($cs.Model)".Trim()
|
||||
} catch {
|
||||
Log "WARN could not read make/model: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# The routable IPv4 - a physical, connected NIC. The collector schema takes a
|
||||
# single ipaddress string, so report the corporate NIC and drop any
|
||||
# controller/machine-LAN NIC. With ranges configured the IP must be in one;
|
||||
# with none, the NIC carrying the default route is the routable one by
|
||||
# definition, which needs no knowledge of a site's addressing.
|
||||
$corpIp = ''
|
||||
$defaultRouteIfIndexes = @()
|
||||
if ($rangeList.Count -eq 0) {
|
||||
try {
|
||||
$defaultRouteIfIndexes = @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop |
|
||||
Sort-Object RouteMetric |
|
||||
Select-Object -ExpandProperty InterfaceIndex -Unique)
|
||||
} catch {
|
||||
Log "WARN could not read the route table: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
try {
|
||||
$ipObjs = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop |
|
||||
Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' }
|
||||
foreach ($ipo in $ipObjs) {
|
||||
if ($corpIp) { break }
|
||||
$adapter = $null
|
||||
try { $adapter = Get-NetAdapter -InterfaceIndex $ipo.InterfaceIndex -ErrorAction Stop } catch {}
|
||||
if (-not $adapter) { continue }
|
||||
if (-not $adapter.HardwareInterface) { continue }
|
||||
if ($adapter.Status -ne 'Up') { continue }
|
||||
if ($rangeList.Count -gt 0) {
|
||||
if (Test-InAllowedRange $ipo.IPAddress) { $corpIp = $ipo.IPAddress }
|
||||
} elseif ($defaultRouteIfIndexes -contains $ipo.InterfaceIndex) {
|
||||
$corpIp = $ipo.IPAddress
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Log "WARN interface enumeration failed: $($_.Exception.Message)"
|
||||
}
|
||||
if (-not $corpIp) { Log 'WARN no routable IPv4 NIC found; posting identity without ipaddress.' }
|
||||
|
||||
# collector schema fields (lowercase concatenated). hostname is required; the
|
||||
# rest are sent only when present so a partial read never blanks a good value.
|
||||
$body = @{ hostname = $hostname }
|
||||
if ($serialNumber) { $body['serialnumber'] = $serialNumber }
|
||||
if ($machineNo) { $body['machinenumber'] = $machineNo }
|
||||
if ($pcType) { $body['pctype'] = $pcType }
|
||||
if ($manufacturer) { $body['vendorname'] = $manufacturer }
|
||||
if ($model) { $body['modelnumber'] = $model }
|
||||
if ($osVersion) { $body['osname'] = $osVersion }
|
||||
if ($lastBootTime) { $body['lastboottime'] = $lastBootTime }
|
||||
if ($loggedInUser) { $body['loggedinuser'] = $loggedInUser }
|
||||
if ($corpIp) { $body['ipaddress'] = $corpIp }
|
||||
$body['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss')
|
||||
|
||||
$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 `
|
||||
$ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp)
|
||||
|
||||
try {
|
||||
$resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
|
||||
-ContentType 'application/json' `
|
||||
-Headers @{ 'X-API-Key' = $ApiKey } `
|
||||
-TimeoutSec $TimeoutSec -ErrorAction Stop
|
||||
Log ("RESPONSE {0}" -f ($resp | ConvertTo-Json -Compress -Depth 4))
|
||||
} catch {
|
||||
Log "ERROR POST failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
exit 0
|
||||
@@ -39,6 +39,12 @@ export default [
|
||||
meta: { requiresAuth: true, plugin: 'computers' }
|
||||
},
|
||||
// Computer-specific settings
|
||||
{
|
||||
path: 'settings/collector',
|
||||
name: 'collector-settings',
|
||||
component: () => import('./views/CollectorSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/pctypes',
|
||||
name: 'pctypes',
|
||||
|
||||
134
plugins/computers/frontend/views/CollectorSettings.vue
Normal file
134
plugins/computers/frontend/views/CollectorSettings.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Asset reporter</h2>
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
Shop-floor PCs report what they are to this server. The script below is
|
||||
generated with THIS site's values, so it downloads ready to deploy - there
|
||||
is nothing in it to find and edit.
|
||||
</p>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Routable ranges</label>
|
||||
<input v-model="ranges" type="text" class="form-control"
|
||||
placeholder="10.20.0.0/23,10.21.4.0/26" />
|
||||
<p class="field-hint">
|
||||
Comma-separated CIDRs for this site's corporate network. A bay with two
|
||||
NICs - a private controller NIC and a routable one - reports the
|
||||
address in these ranges. Leave it empty and the PC reports whichever
|
||||
NIC carries the default route, which is correct at most sites and needs
|
||||
no configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" @click="save" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<h3>Download the reporter</h3>
|
||||
<p class="field-hint">
|
||||
Stamped with this server's URL and the ranges above, and with the version
|
||||
that generated it, so a script found on a bay can be traced back here.
|
||||
Re-download after upgrading ShopDB.
|
||||
</p>
|
||||
|
||||
<button class="btn btn-secondary" @click="download" :disabled="downloading">
|
||||
{{ downloading ? 'Generating...' : 'Download Report-AssetToShopDB.ps1' }}
|
||||
</button>
|
||||
|
||||
<p v-if="digest" class="field-hint mono">
|
||||
SHA-256 {{ digest }}
|
||||
</p>
|
||||
|
||||
<p class="field-hint">
|
||||
<strong>The collector key is not in this file, deliberately.</strong> It
|
||||
lands on every shop-floor PC, and a token spread across hundreds of bays
|
||||
cannot be rotated quietly. Mint a token scoped to
|
||||
<code>collector.ingest</code> and provision it as
|
||||
<code>HKLM:\SOFTWARE\GE\ShopDB</code> value <code>CollectorKey</code> -
|
||||
the adoption guide has worked examples for Intune, DSC and GE-Enforce.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
import api from '@/api'
|
||||
|
||||
const RANGES_KEY = 'computers_routableranges'
|
||||
|
||||
const ranges = ref('')
|
||||
const saving = ref(false)
|
||||
const downloading = ref(false)
|
||||
const digest = ref('')
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await settingsApi.list({ category: 'computers' })
|
||||
const row = (response.data.data || []).find(entry => entry.key === RANGES_KEY)
|
||||
if (row) ranges.value = row.value || ''
|
||||
} catch (loadError) {
|
||||
error.value = 'Could not load settings'
|
||||
console.error(loadError)
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
await settingsApi.update(RANGES_KEY, String(ranges.value ?? ''))
|
||||
message.value = 'Saved. Re-download the script so it carries the new ranges.'
|
||||
} catch (saveError) {
|
||||
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
downloading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
// responseType text: this is a script, not JSON, and the hash the server
|
||||
// publishes is of exactly these bytes.
|
||||
const response = await api.get('/computers/client-script', { responseType: 'text' })
|
||||
digest.value = response.headers['x-script-sha256'] || ''
|
||||
|
||||
const blob = new Blob([response.data], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'Report-AssetToShopDB.ps1'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (downloadError) {
|
||||
error.value = downloadError.response?.status === 403
|
||||
? 'Only an administrator can download the reporter'
|
||||
: 'Could not generate the script'
|
||||
console.error(downloadError)
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono { font-family: monospace; word-break: break-all; }
|
||||
.form-card h3 { margin-top: 0; }
|
||||
</style>
|
||||
@@ -245,6 +245,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printers assigned to this PC ITSELF, which replace whatever the
|
||||
machine it controls is assigned. Same component the machine form
|
||||
uses: two copies of this UI would drift, and the two ends of an
|
||||
override disagreeing is the bug nobody would spot. -->
|
||||
<PrinterAssignmentPicker ref="printerPickerRef" :assetid="currentAssetId" scope="pc" />
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -317,16 +323,21 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '@/api'
|
||||
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi,
|
||||
printersApi, relationshipTypesApi } from '@/api'
|
||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||
from '@/composables/mapConfig'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const toast = useToast()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const route = useRoute()
|
||||
@@ -338,6 +349,7 @@ const isEdit = computed(() => !!route.params.id)
|
||||
// values are keyed by the underlying asset id, captured on load / create.
|
||||
const COMPUTER_ASSETTYPEID = 2
|
||||
const customFieldsRef = ref(null)
|
||||
const printerPickerRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
|
||||
// PC Number (assetnumber) defaults to the serial number while the user hasn't
|
||||
@@ -412,6 +424,7 @@ const models = ref([])
|
||||
const locations = ref([])
|
||||
const operatingsystems = ref([])
|
||||
|
||||
|
||||
// Default PC Number to serial while the user hasn't typed their own (new PC only)
|
||||
watch(() => form.value.serialnumber, (serial) => {
|
||||
if (!isEdit.value && !manualPcNumber.value && serial) {
|
||||
@@ -438,7 +451,8 @@ onMounted(async () => {
|
||||
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
locationsApi.list({ perpage: 100 }),
|
||||
operatingsystemsApi.list({ perpage: 100 }),
|
||||
computersApi.protocols.list()
|
||||
computersApi.protocols.list(),
|
||||
// Handles its own failure: a site without printers still gets a form.
|
||||
])
|
||||
|
||||
pcTypes.value = ptRes.data.data || []
|
||||
@@ -570,6 +584,22 @@ async function savePC() {
|
||||
}
|
||||
}
|
||||
|
||||
// Printer assignment through the shared picker, which reconciles the whole
|
||||
// set in one call rather than row at a time.
|
||||
//
|
||||
// Toasted, not thrown: the PC itself is saved by now, so staying on a form
|
||||
// whose Save would create a second PC is the worse failure - but an
|
||||
// assignment that quietly did not happen is the bug this feature exists to
|
||||
// stop, so it has to be said out loud.
|
||||
if (assetId && printerPickerRef.value) {
|
||||
try {
|
||||
await printerPickerRef.value.save(assetId)
|
||||
} catch (printerError) {
|
||||
console.error('Error saving printer assignment:', printerError)
|
||||
toast.error(apiError(printerError, 'PC saved, but the printer assignment did not'))
|
||||
}
|
||||
}
|
||||
|
||||
router.push('/pcs')
|
||||
} catch (err) {
|
||||
console.error('Error saving PC:', err)
|
||||
@@ -603,6 +633,30 @@ async function savePC() {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Scrolls rather than pushing the rest of the form off screen: a site can hold
|
||||
dozens of printers. */
|
||||
.printer-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.printer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.printer-meta {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.map-location-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -162,6 +162,25 @@ class ComputersPlugin(BasePlugin):
|
||||
},
|
||||
}
|
||||
|
||||
def get_settings_cards(self) -> List[dict]:
|
||||
"""The asset reporter's own settings page.
|
||||
|
||||
It is a settings card rather than a docs page because it does two things
|
||||
an operator needs at the same moment: name this site's routable ranges,
|
||||
and download the reporter that carries them.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'group': 'Computers',
|
||||
'to': '/settings/collector',
|
||||
'icon': 'download',
|
||||
'title': 'Asset reporter',
|
||||
'description': 'Download the collector script stamped with this '
|
||||
'site\'s URL and ranges',
|
||||
'position': 26,
|
||||
},
|
||||
]
|
||||
|
||||
def get_settings_defaults(self) -> List[dict]:
|
||||
"""Settings this plugin owns.
|
||||
|
||||
@@ -178,6 +197,19 @@ class ComputersPlugin(BasePlugin):
|
||||
'description': 'Hours without a collector report before a PC '
|
||||
'is listed as not reporting on the dashboard.',
|
||||
},
|
||||
{
|
||||
'key': 'computers_routableranges',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'computers',
|
||||
'description': 'Comma-separated CIDRs naming this site\'s '
|
||||
'routable ranges, e.g. 10.20.0.0/23,10.21.4.0/26. '
|
||||
'Stamped into the collector script this server '
|
||||
'generates, so a bay with a controller NIC and a '
|
||||
'corporate NIC reports the right one. Blank uses '
|
||||
'the NIC carrying the default route, which needs '
|
||||
'no knowledge of a site\'s addressing.',
|
||||
},
|
||||
{
|
||||
'key': 'computers_machinelink_alerts',
|
||||
'value': 'false',
|
||||
|
||||
Reference in New Issue
Block a user