Collector auto-links measuring tools for metrology PCs; settings rail cleanup
Metrology PCs (CMM, Keyence, Genspect, wax-and-trace imaging pc-types) drive an attached measuring instrument. The PC itself stays a shopfloor PC, but the collector now models the instrument: - New METROLOGY_TOOL_MAP (pctypemap.py) maps those pc-types to a MeasuringToolType (CMM, Vision System, Genspect, Form Tracer). - ComputersPlugin._sync_measuringtool_link creates the MeasuringTool asset once and a directional PC->tool "controls" relationship, tagged collector:measuringtool. Idempotent (re-push reuses, no duplicate asset) and self-archiving (a PC re-imaged to a non-metrology type deactivates the link but keeps the asset and any calibration history). Mirrors the printer-link pattern. The MeasuringToolType is created on demand if not seeded. - 4 tests: create+link, idempotent re-push, non-metrology skip, repurpose archives. Non-metrology PCs never warn about a missing controls type. Settings rail cleanup: - Collapsible groups so the 13-group rail fits without scrolling (1511px -> 488px). The group containing the current page expands; the rest collapse. CSS-drawn caret (ASCII source, no Unicode). Empty groups never render, in both the rail and the landing page. - Measuring Tools group placed with the other asset groups (right after Machines) instead of appended last; empty placeholder positions the plugin-contributed cards. - Operating Systems moved from PCs to General Reference: OS is cross-asset (PCs, machines, measuring tools, network devices all run one). Plus docs/proposals/ge-enforce-plugin.md: a planning doc for refactoring GE-Enforce/DSC into a shopdb plugin (manifest as shopdb data, payloads on SMB/HTTP/inline), grounded in the real manifest schema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
331
docs/proposals/ge-enforce-plugin.md
Normal file
331
docs/proposals/ge-enforce-plugin.md
Normal file
@@ -0,0 +1,331 @@
|
||||
# Proposal: GE-Enforce as a shopdb plugin
|
||||
|
||||
Status: DRAFT / planning only. Not accepted, not built.
|
||||
Author: planning session 2026-07-12.
|
||||
|
||||
## 1. What this is
|
||||
|
||||
Today GE-Enforce is a PowerShell manifest engine that reads per-PC-type
|
||||
`manifest.json` files off an SMB share (`\\tsgwp00525.wjs.geaerospace.net\
|
||||
shared\dt\shopfloor\`). Each logon, a scheduled task running as SYSTEM mounts
|
||||
the share, reads the manifest for the machine's PC type, and installs or
|
||||
self-heals apps, files, drivers, registry values, and scripts. A parallel
|
||||
`preinstall.json` runs the same schema once at imaging.
|
||||
|
||||
This proposal turns the *manifest* into shopdb data: the authoritative manifest
|
||||
lives in the shopdb database, is edited through the shopdb UI (an expansion of
|
||||
`/settings/pctypemapping`), and is served to clients over HTTP as JSON. The
|
||||
*payloads* (MSI/EXE/PS1/config bytes) stay on SMB, on HTTP, or both, referenced
|
||||
by URL/path from the manifest rows. GE-Enforce.ps1 changes from "read a file on
|
||||
W:" to "GET a manifest from shopdb, then fetch each payload from wherever the
|
||||
row says."
|
||||
|
||||
The result: managing imaging PC types, their apps, scripts, files, registry
|
||||
rules, and version gates becomes a first-class shopdb feature instead of hand-
|
||||
edited JSON on a file share.
|
||||
|
||||
## 2. Why it fits shopdb
|
||||
|
||||
- shopdb already models the fleet (the collector ingests every PC's hostname,
|
||||
pctype, installed software, versions). Making shopdb *also* own what SHOULD be
|
||||
installed closes the loop: desired-state (manifest) and observed-state
|
||||
(collector) live in one system and can be diffed.
|
||||
- `/settings/pctypemapping` already maps `gea-shopfloor-*` PC types to
|
||||
`ComputerType`. That page becomes the entry point for full imaging-PC-type
|
||||
management.
|
||||
- The plugin contract (per-plugin models, migrations, API prefix, settings
|
||||
cards, collector hooks) is exactly the shape this needs.
|
||||
- ADR-004 (per-site instances) matches: each site's shopdb owns each site's
|
||||
manifest. No multi-tenant complication.
|
||||
|
||||
## 3. Grounding: the real manifest schema
|
||||
|
||||
Source of truth for these field names (do not invent others):
|
||||
|
||||
- Schema: `pxe-images/tsgwp00525-v2/shared/dt/shopfloor/_meta/manifest-schema.json`
|
||||
- Engine: `pxe-images/common/lib/Install-FromManifest.ps1`
|
||||
- Dispatcher: `.../shopfloor/common/GE-Enforce.ps1`
|
||||
- Architecture: `pxe/docs/ge-enforce-v2-architecture.md`
|
||||
|
||||
A manifest is `{ "Version": str, "_comment": str, "Applications": [entry, ...] }`.
|
||||
Only `Name` and `Type` are required per entry.
|
||||
|
||||
### Per-entry fields (complete set)
|
||||
|
||||
Identity / action:
|
||||
- `Name` (required, unique, also the status-key `<scope>/<Name>`)
|
||||
- `Type` (required): one of `MSI EXE CMD BAT PS1 INF File Registry`
|
||||
- `_comment` (documentation, heavily used in practice)
|
||||
|
||||
Type-specific payload references (sparse; depends on Type):
|
||||
- MSI/EXE/CMD/BAT/INF: `Installer` (relative path) + `InstallArgs`
|
||||
- PS1: `Script` (relative path, falls back to `Installer`) + `Args`
|
||||
- File: `Source` (relative) + `Destination` (absolute on-PC path)
|
||||
- Registry: `RegPath` + `RegName` + `RegValue` + `RegType`
|
||||
(`RegType` in `String DWord QWord MultiString ExpandString Binary`)
|
||||
- Optional `LogFile`, `WaitTimeoutSec` (EXE hang kill), `InUseCheck`
|
||||
|
||||
Detection (decides whether the action fires / self-heals):
|
||||
- `DetectionMethod`: one of
|
||||
`Registry File FileVersion Hash MarkerFile ValueMatches pnputil Always`
|
||||
- `DetectionPath`, `DetectionName`, `DetectionValue`, `DetectionPattern`
|
||||
- Note: `DetectionValue` is method-dependent - SHA256 for Hash, a 4-part
|
||||
version for FileVersion, a registry value for Registry, ignored for
|
||||
Always/File. Same column, different meaning per method.
|
||||
- No `DetectionMethod` = always installs.
|
||||
|
||||
Targeting filters (all ANDed; each is multi-value):
|
||||
- `PCTypes` (array; `"*"` = all; alias graph expands old<->new names)
|
||||
- `PCSubTypes` / subtype via `<pctype>-<subtype>` values
|
||||
- `TargetHostnames` (array; exact + `-like WJS-*` wildcards)
|
||||
- `TargetMachineNumbers` (array; per-bay)
|
||||
- `_CmmVersion` (scalar; per-entry PC-DMIS version gate, needs lib >= 2.6)
|
||||
|
||||
Nested:
|
||||
- `InUseCheck`: `{ Behavior, Processes: [{Name, ExePath, GracefulCloseTimeoutSec}] }`
|
||||
Behavior in `Defer CloseAndReopen ForceClose ScheduleForReboot`
|
||||
|
||||
Parsed-but-inert today (model them, mark inert):
|
||||
- `ApplyMode` (`Nightly Immediate ImmediateReboot`), `UpdateWindow` (`HH:MM-HH:MM`)
|
||||
|
||||
Preinstall-only extras (phase discriminator):
|
||||
- `PreEnrollment`, `KillAfterDetection`, `PCTypesStrict`, `_pcTypesNote`
|
||||
|
||||
### Load-bearing behaviors the model must preserve
|
||||
|
||||
1. **Array order IS execution order.** Config-restore entries are deliberately
|
||||
placed AFTER their vendor installer so a mid-cycle overwrite heals the same
|
||||
cycle (eMxInfo.txt after eDNC; udc_webserver_settings after UDC). We MUST
|
||||
store an explicit per-scope `sortorder`, not a set.
|
||||
2. **PCTypes alias graph** is many-to-many old<->new names resolved by set
|
||||
intersection, with a `PCTypesStrict` escape hatch. Not a simple FK.
|
||||
3. **Polymorphic entry by Type** - sparse column set per type. Model as
|
||||
single-table with nullable columns, or a typed-payload child. STI is simpler.
|
||||
4. **Two manifest phases** - runtime (self-heal, per logon) and preinstall
|
||||
(once at imaging) share the schema. One table with a `phase` discriminator.
|
||||
|
||||
## 4. Data model (new `geenforce` plugin)
|
||||
|
||||
Per-plugin Alembic chain (ADR-008). Tables (lowercase concatenated per naming
|
||||
convention):
|
||||
|
||||
- `manifestscopes` - one row per imaging PC type / scope.
|
||||
- `scopeid` PK
|
||||
- `scopename` (e.g. `gea-shopfloor-cmm`; unique)
|
||||
- `computertypeid` FK -> `computertypes` (this REPLACES the thin
|
||||
`pctypemap_<pxetype>` setting; the mapping becomes a column here)
|
||||
- `phase` enum (`runtime` | `preinstall`)
|
||||
- `manifestversion` (string, mirrors manifest `Version`)
|
||||
- `description`, `isactive`
|
||||
- `iscommon` bool (the `common/` fleet-wide scope)
|
||||
|
||||
- `manifestentries` - one row per Applications[] entry.
|
||||
- `entryid` PK, `scopeid` FK
|
||||
- `sortorder` int (preserves array order; the ordering contract)
|
||||
- `name`, `entrytype` (MSI/EXE/.../Registry), `comment`
|
||||
- payload columns (nullable, per type): `installer`, `installargs`,
|
||||
`scriptpath`, `scriptargs`, `sourcepath`, `destination`,
|
||||
`regpath`, `regname`, `regvalue`, `regtype`
|
||||
- `payloadsource` enum (`smb` | `http` | `inline`) + `payloadref`
|
||||
(see section 5)
|
||||
- detection columns: `detectionmethod`, `detectionpath`, `detectionname`,
|
||||
`detectionvalue`, `detectionpattern`
|
||||
- gates: `cmmversion`, plus child tables for the multi-value filters
|
||||
- control: `logfile`, `waittimeoutsec`, `applymode`, `updatewindow`
|
||||
- preinstall flags: `preenrollment`, `killafterdetection`, `pctypesstrict`
|
||||
- `isactive`
|
||||
|
||||
- `manifestentrypctypes`, `manifestentryhostnames`, `manifestentrymachinenumbers`
|
||||
- child rows for the ANDed multi-value filters (one value per row, wildcards
|
||||
stored verbatim as patterns)
|
||||
|
||||
- `manifestinusechecks` + `manifestinusecheckprocesses`
|
||||
- the nested InUseCheck object and its Processes[] child list
|
||||
|
||||
- `pctypealiases` - the old<->new name alias graph (seed from
|
||||
`Install-FromManifest.ps1:463-475`), so the server can resolve/validate
|
||||
PCTypes the same way the engine does.
|
||||
|
||||
The JSON the client receives is REBUILT from these rows in exact array order.
|
||||
It must byte-for-byte match what the current engine expects (round-trip tested
|
||||
against the live manifests).
|
||||
|
||||
## 5. Payloads: SMB and/or HTTP (both supported)
|
||||
|
||||
The user asked whether payloads can be SMB and/or HTTP. Yes - per entry:
|
||||
|
||||
- `payloadsource = smb`: `payloadref` is the current relative path
|
||||
(`apps/eDNC_6-4-5.msi`); the client resolves it against the mounted scope
|
||||
root exactly as today. Zero client behavior change for these rows. This is
|
||||
the default and the migration target for large binaries (MSIs are hundreds of
|
||||
MB; SMB streaming beats HTTP for those).
|
||||
- `payloadsource = http`: `payloadref` is a URL (absolute, or relative to a
|
||||
configured payload base). The client downloads to a local temp dir, verifies
|
||||
the Hash/FileVersion detection value, then runs it. Good for small
|
||||
config/script payloads and for sites with no SMB share.
|
||||
- `payloadsource = inline`: for small text payloads (a `.ps1`, a config file, a
|
||||
registry value), the bytes live in shopdb itself and are served in-band. No
|
||||
external store at all. Best for scripts and File-type config drops.
|
||||
|
||||
Manifest generation emits, per entry, whatever the client needs to fetch the
|
||||
bytes. The engine's existing "stage network EXE to local temp first" logic
|
||||
(SYSTEM access-denied workaround) generalizes cleanly to HTTP download.
|
||||
|
||||
Hashing stays the integrity mechanism regardless of transport: `DetectionValue`
|
||||
carries the SHA256, the client verifies after fetch. This is the security-
|
||||
critical bit - shopdb serves a manifest that says "install X with hash H"; a
|
||||
tampered payload from any transport fails the hash and does not run.
|
||||
|
||||
## 6. API surface (`/api/geenforce/...`)
|
||||
|
||||
Admin CRUD (gated by a new `geenforce.manage` permission via the plugin's
|
||||
`get_permissions()` hook):
|
||||
- `GET/POST /scopes`, `GET/PUT/DELETE /scopes/<id>` - imaging PC types
|
||||
- `GET/POST /scopes/<id>/entries`, `PUT/DELETE /entries/<id>` - manifest entries
|
||||
- `PUT /scopes/<id>/entries/reorder` - the ordering contract, drag-to-reorder
|
||||
- `POST /entries/<id>/payload` - upload an inline/http payload (multipart),
|
||||
compute + store its SHA256 into `detectionvalue`
|
||||
- `GET /scopes/<id>/preview` - the exact JSON a client would receive (for review
|
||||
before publish)
|
||||
|
||||
Client-facing (gated by a collector-style service token, `geenforce.fetch`
|
||||
scope, reusing the PAT + `X-API-Key` machinery already built for the collector):
|
||||
- `GET /manifest?pctype=<scope>&subtype=<s>&hostname=<h>&machinenumber=<n>`
|
||||
Returns the assembled manifest JSON for that machine. The server can pre-apply
|
||||
the PCTypes/hostname/machinenumber/cmmversion filters (thin client) OR return
|
||||
the full scope and let the engine filter (fat client, matches today). Start
|
||||
fat: return the scope manifest unchanged so the engine logic is untouched.
|
||||
- Payload fetch for `http`/`inline` rows: `GET /payload/<entryid>` streaming the
|
||||
bytes with the right content type.
|
||||
|
||||
## 7. Frontend: expand `/settings/pctypemapping`
|
||||
|
||||
The current page (`PCTypeMappingSettings.vue`, "Collector PC Types") is a read-
|
||||
only-ish table of `pxetype -> ComputerType` dropdowns. It grows into the imaging-
|
||||
PC-type manager:
|
||||
|
||||
- **Scopes list**: add/rename/delete imaging PC types; each still carries its
|
||||
`ComputerType` mapping (that column moves from a setting into `manifestscopes`).
|
||||
A `phase` toggle (runtime vs preinstall). Common scope flagged.
|
||||
- **Scope detail / manifest editor**: an ordered, drag-reorderable list of
|
||||
entries (the ordering contract made visible). Each entry is a typed form -
|
||||
the visible fields switch on `entrytype` (MSI shows Installer+InstallArgs;
|
||||
PS1 shows Script+Args; File shows Source+Destination; Registry shows the Reg*
|
||||
quartet). Detection block with a method dropdown that reveals only the
|
||||
relevant Detection* fields. Filter chips for PCTypes/hostnames/machine numbers.
|
||||
InUseCheck sub-editor. Payload source selector (smb/http/inline) with upload
|
||||
for the latter two.
|
||||
- **Preview + publish**: show the generated JSON; "publish" bumps
|
||||
`manifestversion` and writes a history snapshot (mirrors the current
|
||||
`_meta/history/<date>-<scope>.json` backups).
|
||||
- **Desired vs observed**: because the collector already reports
|
||||
`installedversions`, the scope page can show, per entry, how many fleet PCs
|
||||
match the expected detection value. This is the payoff of unifying the two.
|
||||
|
||||
This is an ADR-010 settings card contributed by the geenforce plugin, so it only
|
||||
appears when the plugin is enabled.
|
||||
|
||||
## 8. Client change (minimal, staged)
|
||||
|
||||
`GE-Enforce.ps1` today: mount W:, read `<scope>\manifest.json`, hand to
|
||||
`Install-FromManifest`. New path: GET the manifest from shopdb, write it to the
|
||||
same local location the engine reads, then run the engine unchanged. That is the
|
||||
smallest possible client delta - the engine, detection logic, self-heal, and
|
||||
SMB payload resolution all stay identical. Only the *source of the JSON* moves
|
||||
from file to HTTP.
|
||||
|
||||
Payloads: `smb` rows need no client change. `http`/`inline` rows need a small
|
||||
fetch-and-verify helper (download to temp, check SHA256, then the existing
|
||||
installer action runs against the local copy). The engine already stages network
|
||||
EXEs to temp, so this is an extension, not a rewrite.
|
||||
|
||||
Auth: the client already has SFLD credentials in
|
||||
`HKLM:\SOFTWARE\GE\SFLD\Credentials`. Add a shopdb service token (a
|
||||
`geenforce.fetch` PAT) provisioned the same way (Azure DSC writes it to
|
||||
registry), sent as `X-API-Key`. If shopdb is unreachable, the client falls back
|
||||
to the last-known-good manifest cached locally (fail-safe: never leave a PC
|
||||
unmanaged because the web app is down). This mirrors today's "creds missing =
|
||||
exit 0, retry next cycle" resilience.
|
||||
|
||||
## 9. Cutover strategy
|
||||
|
||||
The manifest is desired-state that runs as SYSTEM and installs software fleet-
|
||||
wide. A bad cutover = a fleet-wide mis-install. Stage it:
|
||||
|
||||
1. **Import + parity.** Write a one-shot importer that reads the current
|
||||
on-share manifests (common + every `gea-shopfloor-*` + preinstall.json) into
|
||||
the new tables. Then generate JSON back out and `diff` against the originals
|
||||
until byte-identical for every scope. This proves the model is lossless
|
||||
before anything depends on it. (This is the same discipline as the ADR-001
|
||||
data migration.)
|
||||
2. **Shadow mode.** shopdb serves the manifest at a new endpoint; a canary PC
|
||||
fetches from shopdb but ALSO reads the share, and logs any diff. No install
|
||||
behavior changes. Run across one of each PC type for a few cycles.
|
||||
3. **Read cutover, payloads still SMB.** Flip GE-Enforce to source the JSON from
|
||||
shopdb (payloads stay `smb`). The blast radius is only "where the JSON comes
|
||||
from"; the bytes and engine are unchanged. Keep the share manifests as the
|
||||
rollback (revert the dispatcher one-liner).
|
||||
4. **Payload migration (optional, per entry).** Move small scripts/configs to
|
||||
`inline`/`http` opportunistically. Leave big MSIs on SMB indefinitely - SMB
|
||||
is the right transport for them.
|
||||
5. **Author in shopdb.** Once read-cutover is stable, new manifest edits happen
|
||||
in the shopdb UI and the on-share JSON is retired (or auto-exported as a
|
||||
backup for break-glass).
|
||||
|
||||
Rollback at every stage is a one-line dispatcher revert, because the engine and
|
||||
payload layout never stop working from the share.
|
||||
|
||||
## 10. Risks / open questions
|
||||
|
||||
- **The engine is the contract.** Any drift between shopdb's generated JSON and
|
||||
what `Install-FromManifest.ps1` expects is a fleet-wide install bug. The
|
||||
byte-identical round-trip test (step 1) is non-negotiable, and the plugin must
|
||||
pin which engine lib version it targets (>= 2.6 for `_CmmVersion`).
|
||||
- **PCTypes alias graph** must be seeded and kept in sync with
|
||||
`Install-FromManifest.ps1:463-475`. If the engine's alias map changes, ours
|
||||
must too. Consider having the engine fetch the alias map from shopdb as well,
|
||||
so there is one source.
|
||||
- **Availability coupling.** GE-Enforce currently depends only on SMB. Adding an
|
||||
HTTP dependency on shopdb means shopdb downtime could stall enforcement -
|
||||
hence the last-known-good local cache in section 8. Must be built in from day
|
||||
one, not bolted on.
|
||||
- **Secrets in payloads.** Some config drops (site-config, credentials) may
|
||||
contain secrets. `inline` payloads live in the shopdb DB - those must respect
|
||||
the existing "secrets stay in .env, not the settings table" rule. Likely keep
|
||||
any secret-bearing payload on SMB with ACLs, never inline.
|
||||
- **Preinstall runner** is a separate consumer (`00-PreInstall-*` at imaging,
|
||||
before enrollment). It may not have a shopdb token yet at that point in the
|
||||
imaging sequence. Preinstall may need to stay share-sourced longer than
|
||||
runtime, or fetch a bootstrap manifest anonymously over HTTP.
|
||||
- **This is a big build.** Realistically phased: (P1) model + importer + parity
|
||||
test; (P2) admin API + CRUD; (P3) frontend editor on /settings/pctypemapping;
|
||||
(P4) client fetch + shadow mode; (P5) read cutover; (P6) payload migration.
|
||||
P1 is the gating de-risk - if the round-trip is not lossless, stop.
|
||||
|
||||
## 11. Relationship to existing work
|
||||
|
||||
- Replaces `plugins/computers/pctypemap.py` (the thin `pctypemap_<pxetype>`
|
||||
settings) - the pctype -> ComputerType mapping becomes the `computertypeid`
|
||||
column on `manifestscopes`. Migrate those settings in, then retire them.
|
||||
- Also folds in the metrology mapping now living in `pctypemap.py`
|
||||
(`METROLOGY_TOOL_MAP`). The collector already auto-creates a MeasuringTool
|
||||
asset and a directional PC->tool `controls` relationship when it sees a
|
||||
metrology pctype (CMM / Keyence / Genspect / wax-and-trace); the PC stays a
|
||||
shopfloor PC. A metrology scope in the manifest model should carry the
|
||||
attached-measuring-tool type alongside its ComputerType so imaging and
|
||||
collector agree on what device the scope implies.
|
||||
- Reuses the collector's token machinery (PAT + `X-API-Key` + scopes) for the
|
||||
client-facing endpoints.
|
||||
- Reuses `get_permissions()` (contract 0.10.0) for `geenforce.manage` /
|
||||
`geenforce.fetch`.
|
||||
- Pairs with the collector: desired-state (this plugin) + observed-state
|
||||
(collector) enable a fleet compliance view.
|
||||
|
||||
## 12. Recommendation
|
||||
|
||||
Feasible and a strong architectural fit, but it is a multi-phase build with a
|
||||
fleet-wide blast radius. The single most important gate is P1: import the real
|
||||
manifests and prove byte-identical round-trip. Do not build the UI or touch a
|
||||
client until that parity test is green. If and when we proceed, this warrants a
|
||||
new ADR (ADR-012: GE-Enforce manifest ownership) capturing the desired-state
|
||||
model, the SMB/HTTP/inline payload contract, and the fail-safe cache.
|
||||
@@ -4,7 +4,7 @@
|
||||
Pick a section from the left, or choose one below.
|
||||
</p>
|
||||
|
||||
<section v-for="group in groups" :key="group.title" class="landing-section">
|
||||
<section v-for="group in visibleGroups" :key="group.title" class="landing-section">
|
||||
<h2 class="section-heading">{{ group.title }}</h2>
|
||||
<div class="landing-grid">
|
||||
<router-link
|
||||
@@ -25,10 +25,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useSettingsCatalog } from '../../composables/settingsCatalog'
|
||||
|
||||
// Core settings groups merged with plugin-contributed cards (ADR-010).
|
||||
const { groups } = useSettingsCatalog()
|
||||
|
||||
// Drop empty groups (e.g. the Measuring Tools placeholder when its plugin is
|
||||
// disabled) so a bare section heading never renders.
|
||||
const visibleGroups = computed(() => groups.value.filter(g => g.cards.length))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -10,17 +10,27 @@
|
||||
|
||||
<nav class="rail-nav">
|
||||
<template v-for="group in visibleGroups" :key="group.title">
|
||||
<div class="rail-group-heading">{{ group.title }}</div>
|
||||
<router-link
|
||||
v-for="card in group.cards"
|
||||
:key="card.to"
|
||||
:to="card.to"
|
||||
class="rail-link"
|
||||
:class="{ active: isActive(card.to) }"
|
||||
<button
|
||||
type="button"
|
||||
class="rail-group-heading"
|
||||
:class="{ collapsed: !isExpanded(group.title) }"
|
||||
@click="toggleGroup(group.title)"
|
||||
>
|
||||
<span class="rail-icon"><component :is="card.icon" :size="16" /></span>
|
||||
<span class="rail-label">{{ card.title }}</span>
|
||||
</router-link>
|
||||
<span class="rail-chevron" aria-hidden="true"></span>
|
||||
{{ group.title }}
|
||||
</button>
|
||||
<template v-if="isExpanded(group.title)">
|
||||
<router-link
|
||||
v-for="card in group.cards"
|
||||
:key="card.to"
|
||||
:to="card.to"
|
||||
class="rail-link"
|
||||
:class="{ active: isActive(card.to) }"
|
||||
>
|
||||
<span class="rail-icon"><component :is="card.icon" :size="16" /></span>
|
||||
<span class="rail-label">{{ card.title }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
</template>
|
||||
<p v-if="!visibleGroups.length" class="rail-empty">No matching settings</p>
|
||||
</nav>
|
||||
@@ -48,7 +58,9 @@ const search = ref('')
|
||||
// Filter the rail by title/description; drop groups that end up empty.
|
||||
const visibleGroups = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
if (!term) return groups.value
|
||||
// Drop empty groups (e.g. the Measuring Tools placeholder when its plugin is
|
||||
// disabled) so a bare heading never renders.
|
||||
if (!term) return groups.value.filter(g => g.cards.length)
|
||||
return groups.value
|
||||
.map(g => ({
|
||||
title: g.title,
|
||||
@@ -65,6 +77,25 @@ function isActive(to) {
|
||||
const base = to.split('?')[0]
|
||||
return route.path === base
|
||||
}
|
||||
|
||||
// Collapsible groups so the 13-section rail fits without scrolling: only the
|
||||
// group containing the current page is open; the user can toggle others.
|
||||
// Searching forces every matching group open.
|
||||
const groupOfActive = computed(() => {
|
||||
for (const g of groups.value) {
|
||||
if (g.cards.some(c => isActive(c.to))) return g.title
|
||||
}
|
||||
return groups.value[0]?.title
|
||||
})
|
||||
const manualToggles = ref({})
|
||||
function toggleGroup(title) {
|
||||
manualToggles.value = { ...manualToggles.value, [title]: !isExpanded(title) }
|
||||
}
|
||||
function isExpanded(title) {
|
||||
if (search.value.trim()) return true
|
||||
if (title in manualToggles.value) return manualToggles.value[title]
|
||||
return title === groupOfActive.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -82,11 +113,11 @@ function isActive(to) {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.rail-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.rail-search {
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
@@ -99,26 +130,52 @@ function isActive(to) {
|
||||
}
|
||||
|
||||
.rail-group-heading {
|
||||
margin: 1rem 0 0.3rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
margin: 0.35rem 0 0.15rem 0;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-light);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.rail-group-heading:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.rail-group-heading:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* CSS-drawn caret (ASCII-only source): a right-pointing triangle that
|
||||
rotates down when the group is expanded. */
|
||||
.rail-chevron {
|
||||
width: 0;
|
||||
height: 0;
|
||||
flex: 0 0 auto;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 5px solid currentColor;
|
||||
transition: transform 0.12s ease;
|
||||
}
|
||||
.rail-group-heading:not(.collapsed) .rail-chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.rail-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-size: 0.88rem;
|
||||
font-size: 0.86rem;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.rail-link:hover {
|
||||
|
||||
@@ -26,13 +26,15 @@ export const settingsGroups = [
|
||||
{ to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' },
|
||||
{ to: '/settings/customfields', icon: SlidersHorizontal, title: 'Custom Fields', description: 'Define extra attributes per asset type (shown on detail + forms)' },
|
||||
{ to: '/settings/supportteams', icon: Users, title: 'Support Teams', description: 'Application support teams and their contacts (ServiceNow group links)' },
|
||||
// Operating Systems is cross-asset (PCs, machines, measuring tools, network
|
||||
// devices all run an OS), so it lives in General Reference, not under PCs.
|
||||
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates (used by PCs, machines, network devices, and more)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'PCs',
|
||||
cards: [
|
||||
{ to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors + map colors' },
|
||||
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates' },
|
||||
{ to: '/settings/accessprotocols', icon: Network, title: 'PC Access Protocols', description: 'Remote-access protocols (VNC, RDP, WinRM) and link templates' },
|
||||
],
|
||||
},
|
||||
@@ -50,9 +52,16 @@ export const settingsGroups = [
|
||||
{ to: '/settings/machinetypes', icon: Wrench, title: 'Machine Types', description: 'Manage machine subtypes + map colors' },
|
||||
],
|
||||
},
|
||||
// The "Measuring Tools" group is contributed by the measuringtools plugin via
|
||||
// the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog),
|
||||
// not hardcoded here.
|
||||
// The "Measuring Tools" cards are contributed by the measuringtools plugin via
|
||||
// the ADR-010 get_settings_cards hook (merged in composables/settingsCatalog).
|
||||
// This empty placeholder fixes the group's position among the asset groups
|
||||
// (right after Machines); the merger fills it in place instead of appending a
|
||||
// new group at the end. When the plugin is disabled the group stays empty and
|
||||
// the rail/landing drop it (empty groups never render).
|
||||
{
|
||||
title: 'Measuring Tools',
|
||||
cards: [],
|
||||
},
|
||||
{
|
||||
title: 'Locations & Organization',
|
||||
cards: [
|
||||
|
||||
@@ -26,6 +26,25 @@ DEFAULT_PCTYPE_MAP = {
|
||||
_SETTING_PREFIX = 'pctypemap_'
|
||||
_SETTING_CATEGORY = 'pctypemapping'
|
||||
|
||||
# Metrology imaging pc-types: the PC itself stays a shopfloor PC, but it drives
|
||||
# an attached measuring instrument. When the collector sees one of these, it
|
||||
# auto-creates a MeasuringTool asset for the device and links the PC to it with
|
||||
# a "controls" relationship (see ComputersPlugin._sync_measuringtool_link).
|
||||
# Maps imaging pc-type -> (MeasuringToolType name, type description). The type
|
||||
# is created on demand if the measuringtools plugin has not seeded it.
|
||||
METROLOGY_TOOL_MAP = {
|
||||
'gea-shopfloor-cmm': ('CMM', 'Coordinate measuring machine'),
|
||||
'gea-shopfloor-keyence': ('Vision System', 'Optical / vision measurement system (Keyence)'),
|
||||
'gea-shopfloor-genspect': ('Genspect', 'Genspect visual / borescope inspection system'),
|
||||
'gea-shopfloor-waxtrace': ('Form Tracer', 'Surface / form tracing system (wax and trace)'),
|
||||
}
|
||||
|
||||
|
||||
def metrology_tool_for(pctype):
|
||||
"""Return (typename, typedescription) if this pc-type drives a measuring
|
||||
tool, else None."""
|
||||
return METROLOGY_TOOL_MAP.get((pctype or '').strip())
|
||||
|
||||
|
||||
def pctype_mapping():
|
||||
"""Return the live pctype -> ComputerType-name map (settings over defaults)."""
|
||||
|
||||
@@ -22,6 +22,12 @@ logger = logging.getLogger(__name__)
|
||||
# assetrelationships.label column (no origin column exists; see BUILD notes).
|
||||
PRINTER_LINK_ORIGIN = 'collector:printers'
|
||||
|
||||
# Marker stamped on PC->measuringtool "controls" links this collector creates
|
||||
# for metrology PCs (CMM, Keyence, Genspect, wax-and-trace). Same stale-link
|
||||
# discipline as PRINTER_LINK_ORIGIN: only rows carrying this label are archived
|
||||
# by a collector push, so hand-made tool links survive.
|
||||
MEASURINGTOOL_LINK_ORIGIN = 'collector:measuringtool'
|
||||
|
||||
|
||||
class ComputersPlugin(BasePlugin):
|
||||
"""
|
||||
@@ -259,6 +265,11 @@ class ComputersPlugin(BasePlugin):
|
||||
# Printer relationship sync (only when the payload carried printer data).
|
||||
printerlinks = self._sync_printer_links(comp.asset, payload, warnings)
|
||||
|
||||
# Measuring-tool sync: metrology PCs (CMM/Keyence/Genspect/wax-trace)
|
||||
# get an attached MeasuringTool asset auto-created and linked.
|
||||
measuringtoollinks = self._sync_measuringtool_link(
|
||||
comp.asset, pctype, hostname, warnings)
|
||||
|
||||
db.session.commit()
|
||||
return {
|
||||
'action': action,
|
||||
@@ -268,6 +279,8 @@ class ComputersPlugin(BasePlugin):
|
||||
'extra': {
|
||||
'printerlinks': printerlinks,
|
||||
'printerlinkcount': len(printerlinks),
|
||||
'measuringtoollinks': measuringtoollinks,
|
||||
'measuringtoollinkcount': len(measuringtoollinks),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -400,6 +413,116 @@ class ComputersPlugin(BasePlugin):
|
||||
db.session.add(rel)
|
||||
return rel
|
||||
|
||||
# -- measuring-tool sync -----------------------------------------------
|
||||
|
||||
def _sync_measuringtool_link(self, pcasset, pctype, hostname, warnings):
|
||||
"""Auto-create + link the MeasuringTool a metrology PC drives.
|
||||
|
||||
A CMM / Keyence / Genspect / wax-and-trace imaging pc-type means the
|
||||
shopfloor PC controls an attached measuring instrument. This creates
|
||||
that instrument once as a MeasuringTool asset and a directional
|
||||
PC->tool 'controls' relationship, tagged MEASURINGTOOL_LINK_ORIGIN so
|
||||
it is idempotent and self-archiving. The PC's own ComputerType is left
|
||||
alone (it stays a shopfloor PC). A non-metrology pc-type archives any
|
||||
collector-created tool link (e.g. a PC re-imaged to another type) but
|
||||
never deletes the tool asset, which may carry calibration history.
|
||||
Returns the desired-link list.
|
||||
"""
|
||||
from shopdb.api import AssetRelationship, RelationshipType, Asset
|
||||
from .pctypemap import metrology_tool_for
|
||||
|
||||
tool_spec = metrology_tool_for(pctype)
|
||||
controls = RelationshipType.query.filter_by(
|
||||
relationshiptype='controls').first()
|
||||
if not controls:
|
||||
# No 'controls' type => no tool links can exist. Only a problem for a
|
||||
# metrology PC that needs one; stay quiet for ordinary PCs.
|
||||
if tool_spec:
|
||||
warnings.append("'controls' relationship type missing; "
|
||||
'run flask seed reference-data')
|
||||
return []
|
||||
|
||||
# Collector-created tool links already on this PC (active or archived).
|
||||
existing = AssetRelationship.query.filter(
|
||||
AssetRelationship.sourceassetid == pcasset.assetid,
|
||||
AssetRelationship.relationshiptypeid == controls.relationshiptypeid,
|
||||
AssetRelationship.label == MEASURINGTOOL_LINK_ORIGIN,
|
||||
).all()
|
||||
|
||||
if not tool_spec:
|
||||
# Not a metrology PC: archive any collector-created tool link.
|
||||
for rel in existing:
|
||||
if rel.isactive:
|
||||
rel.isactive = False
|
||||
return []
|
||||
|
||||
try:
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
except ImportError:
|
||||
warnings.append('measuringtools plugin unavailable; '
|
||||
'tool link skipped')
|
||||
return []
|
||||
|
||||
typename, typedescription = tool_spec
|
||||
tooltype = self._ensure_measuringtool_type(typename, typedescription)
|
||||
|
||||
# Reuse any prior collector link (reactivate + retype) before creating,
|
||||
# so a re-metrology PC never duplicates the tool asset.
|
||||
reuse = next((rel for rel in existing if rel.isactive), None) \
|
||||
or (existing[0] if existing else None)
|
||||
if reuse:
|
||||
reuse.isactive = True
|
||||
toolasset = db.session.get(Asset, reuse.targetassetid)
|
||||
if toolasset and toolasset.measuringtool and tooltype:
|
||||
toolasset.measuringtool.measuringtooltypeid = \
|
||||
tooltype.measuringtooltypeid
|
||||
targetid = reuse.targetassetid
|
||||
else:
|
||||
mt_assettype = AssetType.query.filter_by(
|
||||
assettype='measuring_tool').first()
|
||||
if not mt_assettype:
|
||||
warnings.append('measuring_tool asset type missing; '
|
||||
'tool link skipped')
|
||||
return []
|
||||
suffix = (pctype or '').split('-')[-1].upper()
|
||||
baseasset = pcasset.assetnumber or hostname
|
||||
toolasset = Asset(
|
||||
assetnumber=f'{baseasset}-{suffix}',
|
||||
name=f'{typename} ({hostname})',
|
||||
assettypeid=mt_assettype.assettypeid,
|
||||
statusid=1)
|
||||
db.session.add(toolasset)
|
||||
db.session.flush()
|
||||
db.session.add(MeasuringTool(
|
||||
assetid=toolasset.assetid,
|
||||
measuringtooltypeid=tooltype.measuringtooltypeid
|
||||
if tooltype else None))
|
||||
db.session.add(AssetRelationship(
|
||||
sourceassetid=pcasset.assetid,
|
||||
targetassetid=toolasset.assetid,
|
||||
relationshiptypeid=controls.relationshiptypeid,
|
||||
label=MEASURINGTOOL_LINK_ORIGIN))
|
||||
targetid = toolasset.assetid
|
||||
|
||||
# Only one tool link is desired; archive any other collector rows.
|
||||
for rel in existing:
|
||||
if rel is not reuse and rel.isactive:
|
||||
rel.isactive = False
|
||||
|
||||
return [{'assetid': targetid, 'relationshiptype': 'controls',
|
||||
'measuringtooltype': typename}]
|
||||
|
||||
def _ensure_measuringtool_type(self, name, description):
|
||||
"""Find or create a MeasuringToolType (metrology types are not in the
|
||||
measuringtools starter seed)."""
|
||||
from plugins.measuringtools.models import MeasuringToolType
|
||||
tooltype = MeasuringToolType.query.filter_by(name=name).first()
|
||||
if not tooltype:
|
||||
tooltype = MeasuringToolType(name=name, description=description)
|
||||
db.session.add(tooltype)
|
||||
db.session.flush()
|
||||
return tooltype
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
"""Called when plugin is installed."""
|
||||
with app.app_context():
|
||||
|
||||
@@ -329,3 +329,106 @@ def test_env_key_still_works_alongside_tokens(client, db, collector_key,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['action'] == 'created'
|
||||
|
||||
|
||||
# -- metrology measuring-tool auto-link ------------------------------------
|
||||
# A CMM/Keyence/Genspect/wax-trace PC stays a shopfloor PC, but the collector
|
||||
# creates the attached measuring instrument as a MeasuringTool asset and links
|
||||
# the PC to it with a directional 'controls' relationship.
|
||||
|
||||
@pytest.fixture
|
||||
def measuringtool_env(db):
|
||||
"""Seed the measuring_tool asset type + 'controls' relationship type the
|
||||
metrology collector path links through."""
|
||||
from shopdb.core.models import AssetType, RelationshipType
|
||||
db.session.add(AssetType(assettype='measuring_tool',
|
||||
pluginname='measuringtools',
|
||||
tablename='measuringtools',
|
||||
description='Metrology instruments'))
|
||||
db.session.add(RelationshipType(relationshiptype='controls',
|
||||
description='Operational authority',
|
||||
isdirectional=True))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_metrology_pctype_creates_and_links_measuringtool(
|
||||
client, db, collector_key, computer_assettype, measuringtool_env):
|
||||
"""A CMM PC gets a MeasuringTool asset + controls link; the CMM tool type
|
||||
is auto-created (it is not in the measuringtools starter seed)."""
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.measuringtools.models import MeasuringTool, MeasuringToolType
|
||||
from shopdb.core.models import AssetRelationship
|
||||
|
||||
payload = {'hostname': 'WJCMM01', 'pctype': 'gea-shopfloor-cmm'}
|
||||
resp = client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
data = resp.get_json()['data']
|
||||
assert data['measuringtoollinkcount'] == 1
|
||||
link = data['measuringtoollinks'][0]
|
||||
assert link['relationshiptype'] == 'controls'
|
||||
assert link['measuringtooltype'] == 'CMM'
|
||||
|
||||
with client.application.app_context():
|
||||
tools = MeasuringTool.query.all()
|
||||
assert len(tools) == 1
|
||||
assert MeasuringToolType.query.filter_by(name='CMM').first() is not None
|
||||
pc = Computer.query.filter(Computer.hostname.ilike('WJCMM01')).first()
|
||||
rels = AssetRelationship.query.filter_by(
|
||||
sourceassetid=pc.assetid, label='collector:measuringtool').all()
|
||||
assert len(rels) == 1
|
||||
assert rels[0].targetassetid == tools[0].assetid
|
||||
assert rels[0].isactive
|
||||
|
||||
|
||||
def test_metrology_link_idempotent(client, db, collector_key,
|
||||
computer_assettype, measuringtool_env):
|
||||
"""Re-pushing the same metrology PC does not duplicate the tool asset."""
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
|
||||
payload = {'hostname': 'WJCMM02', 'pctype': 'gea-shopfloor-cmm'}
|
||||
for _ in range(2):
|
||||
resp = client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200
|
||||
with client.application.app_context():
|
||||
assert MeasuringTool.query.count() == 1
|
||||
|
||||
|
||||
def test_nonmetrology_pctype_makes_no_tool(client, db, collector_key,
|
||||
computer_assettype, measuringtool_env):
|
||||
"""A plain shopfloor PC gets no measuring-tool asset."""
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
|
||||
payload = {'hostname': 'WJPC900', 'pctype': 'gea-shopfloor-collections'}
|
||||
resp = client.post('/api/collector/computers', json=payload,
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['measuringtoollinkcount'] == 0
|
||||
with client.application.app_context():
|
||||
assert MeasuringTool.query.count() == 0
|
||||
|
||||
|
||||
def test_repurposed_pc_archives_tool_link(client, db, collector_key,
|
||||
computer_assettype, measuringtool_env):
|
||||
"""A metrology PC re-imaged to a non-metrology type has its collector tool
|
||||
link archived (tool asset kept, relationship deactivated)."""
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
from shopdb.core.models import AssetRelationship
|
||||
|
||||
host = {'hostname': 'WJCMM03'}
|
||||
client.post('/api/collector/computers',
|
||||
json={**host, 'pctype': 'gea-shopfloor-cmm'},
|
||||
headers={'X-API-Key': KEY})
|
||||
resp = client.post('/api/collector/computers',
|
||||
json={**host, 'pctype': 'gea-shopfloor-collections'},
|
||||
headers={'X-API-Key': KEY})
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()['data']['measuringtoollinkcount'] == 0
|
||||
with client.application.app_context():
|
||||
# asset preserved (may carry calibration history), link deactivated
|
||||
assert MeasuringTool.query.count() == 1
|
||||
rels = AssetRelationship.query.filter_by(
|
||||
label='collector:measuringtool').all()
|
||||
assert len(rels) == 1
|
||||
assert rels[0].isactive is False
|
||||
|
||||
Reference in New Issue
Block a user