diff --git a/CHANGELOG.md b/CHANGELOG.md index 48f37735..293d3b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,154 @@ ADR-007 and ADR-002. ## [Unreleased] +### Added + +- **Printer assignment has a form.** `PrinterAssignmentPicker` is the ONE picker + for both ends: MachineForm gained it (the machine is where the assignment + belongs and there was no way to set it except the relationships card), and + PCForm now uses the same component instead of its own copy, so the two sides + of an override cannot drift. PCForm also stopped reconciling row at a time + through the generic relationship endpoints, which left a PC half-assigned on + an HTTP failure; it calls + `PUT /api/printers/assignments/for-asset/` instead. +- **A relationship type can declare itself singular** + (`relationshiptypes.issingular`, migration 7d34), and `defaultprinter` does. + Setting a second default REPLACES the first. The unique constraint is + (source, target, type), so two different targets were two valid rows and the + resolver took the OLDEST: a new default silently lost. +- **Observed printer queues.** `POST /api/collector/printers` (the ADR-006 hook, + no new transport and no new credential) records what a bay ACTUALLY has in a + plugin-owned table, kept strictly apart from what it is assigned. Adoption is + explicit via `POST /api/printers/assignments/seed-from-observed`, which routes + through the same reconcile path as the editor and REFUSES a queue matching no + known printer. New client script `Report-PrintersToShopDB.ps1`. +- **Wave-gated driver rollout.** `Install-ShopdbPrinterDrivers.ps1` takes + `-WaveStart`, `-Waves`, `-WaveUnit` and `-IgnoreWave`. GE-Enforce offsets each + PC by SHA256(hostname) % 5 MINUTES, which was sized for a JSON check, not a + 100 MB driver set: ungated, ~300 bays pull ~30 GB inside one five-minute + window on the share the whole floor depends on. Each bay derives its wave from + its own hostname, the gate runs BEFORE the manifest is read (the manifest is + on that share too), and it fails closed on an unparseable date. + +### Changed + +- **MySQL 8.4 LTS** in both compose files, and as the default in + `build-offline-bundle.ps1`. 8.0 reached end of life in April 2026, which + `INSTALL-WINDOWS-IIS.md` already said while the stack still pinned it; the two + halves of the product disagreed about which server a site should run. + Verified against a real 8.4.11 server rather than by changing a tag: the core + chain and five plugin chains applied clean, 66 tables at a single + `utf8mb4_unicode_ci` collation, and the image's PyMySQL authenticates against + 8.4's `caching_sha2_password` (which is why `cryptography` is pinned). + Sites upgrading an EXISTING server should note that 8.4 removes + `mysql_native_password`: an account created on 5.6 or 5.7 must be moved to + `caching_sha2_password` before the upgrade or it cannot log in afterwards. + Air-gapped sites need a fresh offline bundle, since the tarball carries the + MySQL image. +- **The database healthcheck probes over TCP** rather than the unix socket. The + entrypoint's init pass answers on the socket while running the server with + `--skip-networking`, so a socket ping reported healthy DURING init and + `depends_on: service_healthy` released `api` and `migrate` against a server + that was about to restart. Found by having it happen: a probe passed at 8s and + the next query failed because the server was mid-restart. + +### Fixed + +- **The image did not build.** `npm run build` fires a `prebuild` hook that runs + `node ../scripts/stage-frontend.mjs`, which stages each plugin's frontend into + the Vite tree and codegens `routes.gen.js`. The Dockerfile's frontend stage + copied `frontend/` alone and flattened it to the stage root, so that path + resolved to `/scripts` and every build since the staging script landed died on + `Cannot find module`. The stage now keeps the repo-relative layout and copies + `scripts/stage-frontend.mjs` and `plugins/` in beside it. Verified end to end: + 16 plugin frontends staged, `frontend/dist` in the final image. +- **The compose stack did not persist `instance/`.** `db_data` was a volume and + the instance directory was not, so `docker compose build api && up -d api` + recreated the container and discarded `plugins.json` along with every upload: + floor plans, branding, model and application images, employee photos, + warranty proofs, slides, printed-part files and the Dell OAuth token. The + visible symptom was a site coming back with its plugins disabled. Both compose + files now mount an `instance_data` volume (the air-gap `migrate` service too, + since `plugin upgrade-all` writes `plugins.json`), and DEPLOY.md carries the + one-time rescue for a stack that predates it. +- **A drifted print queue is corrected, not just a missing one.** Queues were + matched by NAME alone, so a bay whose printer had moved or whose queue was + built on a replaced driver looked converged and printed to the wrong device. + `Set-ShopdbPrinters.ps1` now repoints a wrong port and swaps a wrong driver + IN PLACE with `Set-Printer`, so the queue keeps its name, sharing, permissions + and whoever holds it as their default. The driver is only swapped when the + wanted one is staged, and there is still no removal path in the script. +- **MachineForm's dropdowns all came up empty.** It read `.data.data` off + `computersApi.listAll()`, which already resolves to the array, so the whole + parallel load threw into the catch and the machine's own values never loaded. +- **The legacy import loader dropped `machines.printerid`**, the classic + system's record of each machine's default printer, so the production import + would have lost every one. + +## [0.11.3] - 2026-08-19 + +Fixes the last of the buildings-and-levels bugs, and brings the shop-floor +clients into the product: the collector reporter, EventSaver and a printer +assignment feature that lets a bay install its own printers. + +**Anyone on 0.11.0-0.11.2 should take this.** The hover preview reported "this +asset has a position but no level" for every asset, and the map PDF printed +markers from every floor onto one sheet. + +### Fixed + +- **Every hover mini-map said "no level".** When 0.11.0 gave LocationMapTooltip a + levelid, none of its seven call sites were taught to pass one - printer, + machine and PC detail, the toner report, enforcement reports, the warranty chip + and the dashboard cards. Two payloads behind them also emitted coordinates with + no level. +- **Map PDF export printed other floors' markers.** It plotted every filtered + asset onto one sheet, which is worse than the on-screen version was: nobody can + correct a sheet once it is printed and carried onto the floor. +- **The legacy import loader created undrawable markers**, sending mapleft/maptop + with no level at three call sites. That loader is the one still to run against + production. +- The build gate that should have caught all of this asked whether a FILE + mentions `levelid`, not whether each position does - one module emitted `mapx` + six times and `levelid` once and passed. It now checks per occurrence, covers + `scripts/`, and fails any Vue file that binds tooltip coordinates without a + level. + +### Added + +- **Printer assignment.** Printers belong to the MACHINE and reach whichever PC + controls it, so a reimaged or swapped PC comes back with the bay's printers and + nothing had to be saved off the old one. New `usesprinter` relationship type, + propagating through `controls` at read time; `GET /api/printers/for-host/` + for the client; `PUT /api/printers/assignments/for-asset/` to reconcile a + whole set in one call. +- **`printerdrivers.vendorid`**, so one row serves a make. HP and Xerox universal + drivers cover 41 of the reference site's 44 printers; binding a driver to a + single model meant 21 near-duplicate rows. Resolution is model, then vendor, + then the pre-vendorid name convention. +- **Client scripts** in `plugins/printers/client/`: stage a site's driver set + (`Install-ShopdbPrinterDrivers.ps1`, with `-TestOnly` for DSC), converge a + bay's queues (`Set-ShopdbPrinters.ps1`), and apply the per-user default + (`Apply-ShopdbDefaultPrinter.ps1`). +- **The collector reporter is in the repository** at + `plugins/computers/client/`, with no site in it, and the server generates a + copy carrying this site's URL and ranges: Settings > Computers > Asset + reporter, or `GET /api/computers/client-script`. The collector key is + deliberately never stamped into it. +- **EventSaver source** at `plugins/slides/client/`. The site UNC that was + compiled into it is gone; a missing ini now fails visibly rather than pointing + a new site at the reference site's file server. +- Settings: `computers_routableranges` (replaces two hardcoded VLANs) and the + buildings-and-levels admin gains editable width AND height. + +### Changed + +- The map editor accepts a click. Its handler was bound only if the map was + already a picker when it mounted, and the editor opens with nothing selected, + so placing a marker by hand was impossible for the life of the page. +- Global search orders every query before truncating, so the same search cannot + return a different subset twice. + ## [0.11.2] - 2026-08-17 Bug fixes for the buildings-and-levels work in 0.11.0, all found by using it. diff --git a/Dockerfile b/Dockerfile index d6c04a28..c6567d3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,12 +24,22 @@ FROM node:20-slim AS frontendbuild WORKDIR /build # Copy only the manifests first so `npm ci` caches on dependency changes. -COPY frontend/package*.json ./ -RUN npm ci +COPY frontend/package*.json ./frontend/ +RUN cd frontend && npm ci -COPY frontend/ ./ -RUN npm run build -# Output lands in /build/dist (Vite default), copied into the final stage below. +# The frontend keeps its repo-relative layout here, because npm prebuild runs +# `node ../scripts/stage-frontend.mjs`, which stages each plugin's frontend/ into +# the Vite tree and codegens routes.gen.js. That script resolves its repo root as +# its own directory's parent, so it needs scripts/ and plugins/ as SIBLINGS of +# frontend/. Building with frontend/ flattened to the stage root made `../scripts` +# resolve to /scripts and the build failed on a missing module. +COPY scripts/stage-frontend.mjs ./scripts/ +COPY plugins/ ./plugins/ + +COPY frontend/ ./frontend/ +RUN cd frontend && npm run build +# Output lands in /build/frontend/dist (Vite default), copied into the final +# stage below. # ---- Stage 2: Python application image ---- FROM python:3.14-slim AS base @@ -60,7 +70,14 @@ COPY scripts/ ./scripts/ COPY wsgi.py ./ # Built SPA from stage 1. Flask serves it via register_frontend_routes. -COPY --from=frontendbuild /build/dist ./frontend/dist +COPY --from=frontendbuild /build/frontend/dist ./frontend/dist + +# Create instance/ IN THE IMAGE, owned by the app user, before the chown below. +# Docker seeds an empty named volume from the image's content at the mountpoint, +# ownership included. Without this the mountpoint is created root-owned 0755 and +# the container, which runs as shopdb, cannot write plugins.json or any upload +# into its own instance directory. +RUN mkdir -p /app/instance RUN useradd --create-home --shell /bin/bash shopdb \ && chown -R shopdb:shopdb /app diff --git a/docker-compose.airgap.yml b/docker-compose.airgap.yml index d5c426d7..7354ccb9 100644 --- a/docker-compose.airgap.yml +++ b/docker-compose.airgap.yml @@ -35,7 +35,7 @@ x-app-env: &app-env services: db: - image: mysql:8.0 + image: mysql:8.4 command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci restart: unless-stopped environment: @@ -48,7 +48,12 @@ services: ports: - "127.0.0.1:${MYSQL_PORT:-3306}:3306" healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + # 127.0.0.1, not localhost: localhost means the unix socket, and the + # entrypoint's init pass answers on the socket while running the server + # with --skip-networking. A socket ping therefore reports healthy DURING + # init, and the api/migrate services start against a server that is about + # to restart. Over TCP the probe stays red until the real server listens. + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] interval: 10s timeout: 5s retries: 5 @@ -72,6 +77,11 @@ services: flask seed permissions && flask seed settings && flask seed reference-data + # plugin upgrade-all rewrites plugins.json, so migrate needs the same + # instance volume api uses. Without it the enabled-plugin list is written + # into a container that exits immediately afterwards. + volumes: + - instance_data:/app/instance api: image: shopdb-flask:${IMAGE_TAG:-0.7.0} @@ -85,6 +95,11 @@ services: <<: *app-env ports: - "${API_PORT:-5001}:5001" + # See docker-compose.yml for what lives here. Same reasoning: /app/instance + # is written state and does not survive a container recreate on its own. + volumes: + - instance_data:/app/instance volumes: db_data: + instance_data: diff --git a/docker-compose.yml b/docker-compose.yml index 8a7d62d3..5f591efe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: db: - image: mysql:8.0 + image: mysql:8.4 # utf8mb4 server-wide so the auto-created MYSQL_DATABASE is utf8mb4, not the # image default. Keeps every site's schema on the same charset/collation. command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci @@ -36,7 +36,12 @@ services: ports: - "127.0.0.1:${MYSQL_PORT:-3306}:3306" healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + # 127.0.0.1, not localhost: localhost means the unix socket, and the + # entrypoint's init pass answers on the socket while running the server + # with --skip-networking. A socket ping therefore reports healthy DURING + # init, and the api/migrate services start against a server that is about + # to restart. Over TCP the probe stays red until the real server listens. + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] interval: 10s timeout: 5s retries: 5 @@ -60,6 +65,15 @@ services: - "${API_PORT:-5001}:5001" volumes: - ./plugins:/app/plugins:ro + # /app/instance is WRITTEN state, not code: plugins.json (which plugins + # this site has enabled), uploaded floor plans, branding, model and + # application images, employee photos, warranty proofs, slides, + # printed-part files, and the Dell OAuth token. Without this volume a + # `docker compose build api && up -d api` recreates the container and + # takes all of it with it, so the site comes back with its plugins + # disabled and MySQL rows pointing at files that no longer exist. + - instance_data:/app/instance volumes: db_data: + instance_data: diff --git a/frontend/package.json b/frontend/package.json index 9d8352e0..e3c1956b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "shopdb-frontend", - "version": "0.11.2", + "version": "0.11.3", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 3592d725..0e9e54bb 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -306,6 +306,25 @@ export const printersApi = { list(params = {}) { return api.get('/printers', { params }) }, + + // Printer assignment for one asset - a MACHINE normally, since the assignment + // belongs to the bay and reaches whichever PC controls it. Reads and writes + // that asset's OWN rows: the resolved view (own, else inherited) is + // /printers/for-host, which the client uses, not the editor. + assignment: { + get(assetid) { + return api.get(`/printers/assignments/for-asset/${assetid}`) + }, + // The WHOLE set in one call. A row-at-a-time edit is a non-atomic + // reconcile: an HTTP failure part way leaves an asset half-assigned, with + // nothing recording what was meant. + set(assetid, printerassetids, defaultprinterassetid) { + return api.put(`/printers/assignments/for-asset/${assetid}`, { + printerassetids, + defaultprinterassetid: defaultprinterassetid ?? null + }) + } + }, // Every printer, paged past the backend's 100-row cap. Batch label printing // and "pick any record" dropdowns must use this: list() with a large // perpage is clamped to 100 and still returns a success response, so diff --git a/frontend/src/components/PrinterAssignmentPicker.vue b/frontend/src/components/PrinterAssignmentPicker.vue new file mode 100644 index 00000000..306097a3 --- /dev/null +++ b/frontend/src/components/PrinterAssignmentPicker.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/migrations/versions/7d34_singular_relationship_types.py b/migrations/versions/7d34_singular_relationship_types.py new file mode 100644 index 00000000..047d6f51 --- /dev/null +++ b/migrations/versions/7d34_singular_relationship_types.py @@ -0,0 +1,46 @@ +"""Let a relationship type say "at most one of these per asset". + +A PC has one default printer. The unique constraint on assetrelationships is +(source, target, type), which happily accepts two DIFFERENT defaults on one +asset - and the resolver then takes the oldest, so setting a new default through +the generic relationships card left the old one winning, silently. + +Cardinality belongs to the TYPE, not to the printers plugin: the next type that +means "exactly one" (a primary user, a primary location) gets the rule for free, +and core's create path is where every write already passes. + +Revision ID: 7d34_singular_relationship_types +Revises: 7d33_buildings_and_levels +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d34_singular_relationship_types' +down_revision = '7d33_buildings_and_levels' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column['name'] for column in inspector.get_columns('relationshiptypes')} + if 'issingular' not in columns: + op.add_column('relationshiptypes', + sa.Column('issingular', sa.Boolean(), nullable=False, + server_default=sa.false())) + + # defaultprinter is the type this exists for, and it is already in use, so + # set it here rather than waiting for a re-seed: a site that upgrades and + # does not re-seed would otherwise keep the old silent behaviour. + op.execute("UPDATE relationshiptypes SET issingular = 1 " + "WHERE LOWER(relationshiptype) = 'defaultprinter'") + + +def downgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column['name'] for column in inspector.get_columns('relationshiptypes')} + if 'issingular' in columns: + op.drop_column('relationshiptypes', 'issingular') diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index 79d0a734..fc9f26a4 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -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, + }) diff --git a/plugins/computers/client/Report-AssetToShopDB.ps1 b/plugins/computers/client/Report-AssetToShopDB.ps1 new file mode 100644 index 00000000..d5f09d85 --- /dev/null +++ b/plugins/computers/client/Report-AssetToShopDB.ps1 @@ -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 /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 diff --git a/plugins/computers/frontend/routes.js b/plugins/computers/frontend/routes.js index 360ecc9b..cd724a09 100644 --- a/plugins/computers/frontend/routes.js +++ b/plugins/computers/frontend/routes.js @@ -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', diff --git a/plugins/computers/frontend/views/CollectorSettings.vue b/plugins/computers/frontend/views/CollectorSettings.vue new file mode 100644 index 00000000..c530c76e --- /dev/null +++ b/plugins/computers/frontend/views/CollectorSettings.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/plugins/computers/frontend/views/PCForm.vue b/plugins/computers/frontend/views/PCForm.vue index 408dfbdd..5921fb41 100644 --- a/plugins/computers/frontend/views/PCForm.vue +++ b/plugins/computers/frontend/views/PCForm.vue @@ -245,6 +245,12 @@ + + +