Merge wilm: endpoints plugin, agent, policies, CSV import, Entra fields
Thirteen commits from benjamin.baker plus the review fixes. Everything he built is here: the endpoints plugin and its command queue, the .NET endpoint agent, the computers policies subsystem, the site-asset CSV import with its adapters and collector, Entra device identity, and the catalog hand-merge. His branch predates four commits on main (section ticking, the generator share links, Secure Send copy message, and the smoke-test workflow), so those come through untouched - nothing of ours is removed by this merge. wilm stays as its own branch; this does not close it.
This commit is contained in:
239
scripts/dev/seed_endpoints_sample.py
Normal file
239
scripts/dev/seed_endpoints_sample.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""Seed sample endpoint telemetry for the Fleet module (DEV ONLY).
|
||||
|
||||
Attaches EndpointState snapshots plus a handful of curated EndpointEvents to
|
||||
existing Computer assets so the fleet list, the PC-detail asset panels
|
||||
(status / services / recent events), and the dashboard quiet + stability cards
|
||||
have something to render before a real agent exists.
|
||||
|
||||
Sample rows are marked with a sentinel agentversion ("0.0.0-sample") so they can
|
||||
be listed and cleared without touching real agent data.
|
||||
|
||||
DEV ONLY: this file lives under scripts/dev/ and must never ship in a
|
||||
production image (see devnotes/live-container-and-testing-workflow.md). Do not
|
||||
import it from application code.
|
||||
|
||||
Usage (in the API container):
|
||||
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py
|
||||
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py --clear
|
||||
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_endpoints_sample.py --limit 8
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# Repo root on sys.path so `shopdb` and `plugins` import when run as a script.
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
|
||||
|
||||
from shopdb import create_app # noqa: E402
|
||||
from shopdb.api import db # noqa: E402
|
||||
|
||||
SAMPLE_MARKER = '0.0.0-sample'
|
||||
|
||||
|
||||
def _naive_utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _services(*running_then_stopped):
|
||||
"""Build a services snapshot. Positional pairs are (name, state)."""
|
||||
return [{'name': name, 'state': state} for name, state in running_then_stopped]
|
||||
|
||||
|
||||
def _ports(*pairs):
|
||||
return [{'port': port, 'process': process} for port, process in pairs]
|
||||
|
||||
|
||||
def _interfaces(ip, mac):
|
||||
return [{'name': 'Ethernet', 'ip': ip, 'mac': mac}]
|
||||
|
||||
|
||||
# Per-machine telemetry shapes. minutesago drives whether a machine reads as
|
||||
# fresh, stale, or quiet; a couple are pushed past a plausible quiet window so
|
||||
# the dashboard quiet card has rows. Everything is offline-plausible because
|
||||
# there is no live agent.
|
||||
SAMPLE_PROFILES = [
|
||||
{
|
||||
'loggedinuser': 'CORP\\jmartin',
|
||||
'agentversion': SAMPLE_MARKER,
|
||||
'minutesago': 3,
|
||||
'uptimeseconds': 4 * 86400 + 6 * 3600,
|
||||
'idleseconds': 12 * 60,
|
||||
'cpupercent': 14.0,
|
||||
'memorypercent': 46.0,
|
||||
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
||||
('WinRM', 'Running'), ('MSSQLSERVER', 'Running')),
|
||||
'ports': _ports((3389, 'svchost'), (5985, 'winrm'), (445, 'System')),
|
||||
'interfaces': _interfaces('10.20.4.11', '00:1A:2B:3C:4D:01'),
|
||||
'events': [(4624, -8), (4672, -8), (4698, -120)],
|
||||
},
|
||||
{
|
||||
'loggedinuser': 'CORP\\operator2',
|
||||
'agentversion': SAMPLE_MARKER,
|
||||
'minutesago': 9,
|
||||
'uptimeseconds': 2 * 3600 + 40 * 60,
|
||||
'idleseconds': 3 * 60,
|
||||
'cpupercent': 61.0,
|
||||
'memorypercent': 72.0,
|
||||
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
||||
('WinRM', 'Stopped')),
|
||||
'ports': _ports((3389, 'svchost'), (445, 'System')),
|
||||
'interfaces': _interfaces('10.20.4.27', '00:1A:2B:3C:4D:02'),
|
||||
# Recent unexpected shutdown -> shows on the stability card.
|
||||
'events': [(41, -50), (1001, -30), (4625, -12)],
|
||||
},
|
||||
{
|
||||
'loggedinuser': None,
|
||||
'agentversion': SAMPLE_MARKER,
|
||||
'minutesago': 22,
|
||||
'uptimeseconds': 11 * 86400,
|
||||
'idleseconds': 9 * 3600,
|
||||
'cpupercent': 3.0,
|
||||
'memorypercent': 38.0,
|
||||
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
||||
('WinRM', 'Running')),
|
||||
'ports': _ports((3389, 'svchost'), (5985, 'winrm')),
|
||||
'interfaces': _interfaces('10.20.4.44', '00:1A:2B:3C:4D:03'),
|
||||
'events': [(4624, -600), (7011, -180)],
|
||||
},
|
||||
{
|
||||
'loggedinuser': 'CORP\\nightshift',
|
||||
'agentversion': SAMPLE_MARKER,
|
||||
# Quiet: no report in over a day.
|
||||
'minutesago': 1500,
|
||||
'uptimeseconds': 30 * 60,
|
||||
'idleseconds': 30 * 60,
|
||||
'cpupercent': 1.0,
|
||||
'memorypercent': 29.0,
|
||||
'services': _services(('Spooler', 'Stopped'), ('W32Time', 'Running')),
|
||||
'ports': _ports((445, 'System'),),
|
||||
'interfaces': _interfaces('10.20.4.58', '00:1A:2B:3C:4D:04'),
|
||||
'events': [(55, -1600), (41, -1580)],
|
||||
},
|
||||
{
|
||||
'loggedinuser': 'CORP\\qa_kiosk',
|
||||
'agentversion': SAMPLE_MARKER,
|
||||
'minutesago': 6,
|
||||
'uptimeseconds': 6 * 3600,
|
||||
'idleseconds': 45 * 60,
|
||||
'cpupercent': 22.0,
|
||||
'memorypercent': 55.0,
|
||||
'services': _services(('Spooler', 'Running'), ('W32Time', 'Running'),
|
||||
('WinRM', 'Running'), ('BITS', 'Running')),
|
||||
'ports': _ports((3389, 'svchost'), (5985, 'winrm'), (135, 'rpc')),
|
||||
'interfaces': _interfaces('10.20.4.63', '00:1A:2B:3C:4D:05'),
|
||||
'events': [(4624, -20), (10016, -90), (4697, -240)],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _clear(EndpointState, EndpointEvent):
|
||||
sample_states = EndpointState.query.filter_by(agentversion=SAMPLE_MARKER).all()
|
||||
asset_ids = [s.assetid for s in sample_states]
|
||||
events_removed = 0
|
||||
if asset_ids:
|
||||
events_removed = (EndpointEvent.query
|
||||
.filter(EndpointEvent.assetid.in_(asset_ids))
|
||||
.delete(synchronize_session=False))
|
||||
states_removed = (EndpointState.query
|
||||
.filter_by(agentversion=SAMPLE_MARKER)
|
||||
.delete(synchronize_session=False))
|
||||
db.session.commit()
|
||||
print('Cleared {} sample state(s) and {} sample event(s).'
|
||||
.format(states_removed, events_removed))
|
||||
|
||||
|
||||
def _seed(limit):
|
||||
from plugins.computers.models import Computer
|
||||
from plugins.endpoints.models import EndpointState, EndpointEvent
|
||||
from plugins.endpoints.eventcatalog import EVENT_CATALOG
|
||||
|
||||
computers = (Computer.query
|
||||
.filter(Computer.assetid.isnot(None))
|
||||
.order_by(Computer.computerid.asc())
|
||||
.limit(limit)
|
||||
.all())
|
||||
if not computers:
|
||||
print('No Computer assets found to attach sample endpoints to. '
|
||||
'Seed the computers plugin first.')
|
||||
return
|
||||
|
||||
now = _naive_utcnow()
|
||||
created, updated, events_added = 0, 0, 0
|
||||
|
||||
for index, computer in enumerate(computers):
|
||||
profile = SAMPLE_PROFILES[index % len(SAMPLE_PROFILES)]
|
||||
reported = now - timedelta(minutes=profile['minutesago'])
|
||||
boot = reported - timedelta(seconds=profile['uptimeseconds'])
|
||||
|
||||
state = EndpointState.query.filter_by(assetid=computer.assetid).first()
|
||||
if state and state.agentversion not in (None, SAMPLE_MARKER):
|
||||
# Never stomp a real agent's snapshot.
|
||||
continue
|
||||
if not state:
|
||||
state = EndpointState(assetid=computer.assetid, createdat=now)
|
||||
db.session.add(state)
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
state.hostname = computer.hostname or 'HOST-{}'.format(computer.computerid)
|
||||
state.loggedinuser = profile['loggedinuser']
|
||||
state.agentversion = SAMPLE_MARKER
|
||||
state.lastboottime = boot
|
||||
state.uptimeseconds = profile['uptimeseconds']
|
||||
state.idleseconds = profile['idleseconds']
|
||||
state.cpupercent = profile['cpupercent']
|
||||
state.memorypercent = profile['memorypercent']
|
||||
state.servicesjson = json.dumps(profile['services'])
|
||||
state.portsjson = json.dumps(profile['ports'])
|
||||
state.interfacesjson = json.dumps(profile['interfaces'])
|
||||
state.scheduledtasksjson = json.dumps([])
|
||||
state.lastreportedat = reported
|
||||
|
||||
# Refresh this asset's sample events so re-running stays idempotent.
|
||||
EndpointEvent.query.filter_by(assetid=computer.assetid).delete(
|
||||
synchronize_session=False)
|
||||
for eventid, offset_minutes in profile['events']:
|
||||
title, severity, channel = EVENT_CATALOG.get(
|
||||
eventid, ('Event {}'.format(eventid), 'info', 'System'))
|
||||
event = EndpointEvent(
|
||||
assetid=computer.assetid,
|
||||
eventid=eventid,
|
||||
eventchannel=channel,
|
||||
eventsource='sample-seed',
|
||||
eventtime=reported + timedelta(minutes=offset_minutes),
|
||||
eventmessage='{} (sample)'.format(title),
|
||||
payloadjson=json.dumps({'sample': True, 'severity': severity}),
|
||||
recordedat=now,
|
||||
)
|
||||
db.session.add(event)
|
||||
events_added += 1
|
||||
|
||||
db.session.commit()
|
||||
print('Seeded sample endpoints: {} created, {} updated, {} event(s) added '
|
||||
'across {} computer(s).'
|
||||
.format(created, updated, events_added, len(computers)))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--clear', action='store_true',
|
||||
help='Remove sample-marked endpoint states and their events.')
|
||||
parser.add_argument('--limit', type=int, default=5,
|
||||
help='Max number of computers to attach sample endpoints to.')
|
||||
args = parser.parse_args()
|
||||
|
||||
app = create_app(os.environ.get('FLASK_ENV', 'production'))
|
||||
with app.app_context():
|
||||
from plugins.endpoints.models import EndpointState, EndpointEvent
|
||||
if args.clear:
|
||||
_clear(EndpointState, EndpointEvent)
|
||||
else:
|
||||
_seed(args.limit)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
102
scripts/dev/seed_shared_applications.py
Normal file
102
scripts/dev/seed_shared_applications.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Seed the org's shared shopfloor applications (DEV ONLY).
|
||||
|
||||
Inserts the applications that ship as common across the organization so the
|
||||
Configuration Policy builder has real apps to scope against and the software
|
||||
inventory has canonical rows to match collected installs to. Each app records
|
||||
its executable and (where relevant) its Windows service name in
|
||||
applicationnotes, since the Application model has no dedicated process/service
|
||||
column - these identifiers are what a generic service_*/process_* policy action
|
||||
targets.
|
||||
|
||||
Shipped set:
|
||||
* UDC - UDC.exe
|
||||
* MTConnect - agent.exe process, "MTConnect UDC Agent" service
|
||||
* eDNC - DncMain.exe process
|
||||
|
||||
Idempotent: matches on appname and updates in place, so re-running does not
|
||||
duplicate. Marked installable so they appear in installable-app pickers.
|
||||
|
||||
DEV ONLY: lives under scripts/dev/ and must never be imported by application
|
||||
code. Run inside the API container.
|
||||
|
||||
Usage (in the API container):
|
||||
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_shared_applications.py
|
||||
docker exec shopdb-flask-wilm-api-1 python scripts/dev/seed_shared_applications.py --clear
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Repo root on sys.path so `shopdb` imports when run as a script.
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
|
||||
|
||||
from shopdb import create_app # noqa: E402
|
||||
from shopdb.api import db # noqa: E402
|
||||
|
||||
# appname -> seed fields. 'notes' captures the process image and service name so
|
||||
# a generic policy action (params.target) has a canonical identifier to bind to.
|
||||
SHARED_APPS = [
|
||||
{
|
||||
'appname': 'UDC',
|
||||
'appdescription': 'Universal Data Collector',
|
||||
'notes': 'process=UDC.exe',
|
||||
},
|
||||
{
|
||||
'appname': 'MTConnect',
|
||||
'appdescription': 'MTConnect UDC agent',
|
||||
'notes': 'process=agent.exe; service=MTConnect UDC Agent',
|
||||
},
|
||||
{
|
||||
'appname': 'eDNC',
|
||||
'appdescription': 'eDNC distributed NC file transfer',
|
||||
'notes': 'process=DncMain.exe',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _seed(Application):
|
||||
created = 0
|
||||
updated = 0
|
||||
for spec in SHARED_APPS:
|
||||
app_row = Application.query.filter_by(appname=spec['appname']).first()
|
||||
if app_row is None:
|
||||
app_row = Application(appname=spec['appname'])
|
||||
db.session.add(app_row)
|
||||
created += 1
|
||||
else:
|
||||
updated += 1
|
||||
app_row.appdescription = spec['appdescription']
|
||||
app_row.applicationnotes = spec['notes']
|
||||
app_row.isinstallable = True
|
||||
db.session.commit()
|
||||
print('Seeded shared applications: {} created, {} updated.'
|
||||
.format(created, updated))
|
||||
|
||||
|
||||
def _clear(Application):
|
||||
names = [spec['appname'] for spec in SHARED_APPS]
|
||||
removed = (Application.query
|
||||
.filter(Application.appname.in_(names))
|
||||
.delete(synchronize_session=False))
|
||||
db.session.commit()
|
||||
print('Removed {} shared application row(s).'.format(removed))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--clear', action='store_true',
|
||||
help='Remove the seeded shared applications.')
|
||||
args = parser.parse_args()
|
||||
|
||||
app = create_app(os.environ.get('FLASK_ENV', 'production'))
|
||||
with app.app_context():
|
||||
from shopdb.api import Application
|
||||
if args.clear:
|
||||
_clear(Application)
|
||||
else:
|
||||
_seed(Application)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
156
scripts/reconcile_locations.py
Normal file
156
scripts/reconcile_locations.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Generate reconciliation SQL for the locations table.
|
||||
|
||||
Reads a TSV export (locationid, locationname, description, locationtypeid,
|
||||
parentlocationid) and emits a single transaction-wrapped SQL script that:
|
||||
- Treats bare grid coordinates ([A-V][0-9]{1,2}W? and LD[0-9]+W?) as the
|
||||
canonical identity for a cell.
|
||||
- Folds coordinate-plus-suffix rows into the canonical coordinate, moving the
|
||||
suffix text into the canonical row's description (never the name).
|
||||
- Folds named-area suffix variants ("NDE - 6409") into the parent named area
|
||||
("NDE"); named areas are kept as-is and never forced into coordinates.
|
||||
- Repoints assets.locationid, subnets.locationid and child
|
||||
locations.parentlocationid onto the canonical row, then deletes duplicates.
|
||||
|
||||
Bare coordinates with no variants are left untouched. The trailing W (west
|
||||
column, e.g. G2W, K3W) is part of the coordinate and is never stripped, so
|
||||
G2W is kept distinct from a hypothetical G2.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
COORD = re.compile(r'^(LD|[ABCDEFGHJKLMNPQRSTUV])(\d{1,2})(W)?(?=$|[ \-])')
|
||||
|
||||
|
||||
def esc(s):
|
||||
return s.replace("'", "''")
|
||||
|
||||
|
||||
def load(path):
|
||||
rows = []
|
||||
with open(path, encoding='utf-8-sig') as fh:
|
||||
for line in fh:
|
||||
line = line.rstrip('\n').rstrip('\r')
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split('\t')
|
||||
if len(parts) < 5:
|
||||
parts += [''] * (5 - len(parts))
|
||||
lid, name, desc, typeid, parentid = parts[:5]
|
||||
rows.append({
|
||||
'id': int(lid),
|
||||
'name': name,
|
||||
'desc': desc,
|
||||
'typeid': typeid,
|
||||
'parentid': parentid,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def classify(name):
|
||||
"""Return (canonical, suffix). canonical is the bare coordinate or named
|
||||
parent; suffix is the trailing detail (may be '')."""
|
||||
m = COORD.match(name)
|
||||
if m:
|
||||
prefix, digits, west = m.group(1), m.group(2), m.group(3) or ''
|
||||
canonical = f"{prefix}{digits}{west}"
|
||||
suffix = name[m.end():].strip()
|
||||
suffix = re.sub(r'^[\-\s]+', '', suffix).strip()
|
||||
return canonical, suffix, 'coord'
|
||||
# named area: fold on ' - ' separator only
|
||||
if ' - ' in name:
|
||||
base, _, rest = name.partition(' - ')
|
||||
return base.strip(), rest.strip(), 'named'
|
||||
return name.strip(), '', 'named'
|
||||
|
||||
|
||||
def main(tsv, out):
|
||||
rows = load(tsv)
|
||||
|
||||
# Build canonical groups. Key is casefolded canonical for grouping, but we
|
||||
# preserve a display canonical (prefer an existing exact bare/standalone).
|
||||
groups = {}
|
||||
for r in rows:
|
||||
canonical, suffix, kind = classify(r['name'])
|
||||
key = (kind, canonical.casefold())
|
||||
g = groups.setdefault(key, {
|
||||
'canonical': canonical,
|
||||
'kind': kind,
|
||||
'members': [],
|
||||
'exact_id': None,
|
||||
})
|
||||
r['_suffix'] = suffix
|
||||
r['_canonical'] = canonical
|
||||
g['members'].append(r)
|
||||
# exact row: name equals canonical (no suffix)
|
||||
if suffix == '' and r['name'].strip() == canonical:
|
||||
g['exact_id'] = r['id']
|
||||
g['canonical'] = r['name'].strip()
|
||||
|
||||
lines = []
|
||||
lines.append('-- Auto-generated locations reconciliation. Review before running.')
|
||||
lines.append('START TRANSACTION;')
|
||||
|
||||
touched = 0
|
||||
for key, g in sorted(groups.items(), key=lambda kv: kv[0][1]):
|
||||
members = sorted(g['members'], key=lambda r: r['id'])
|
||||
# nothing to do: single member that is already the exact canonical
|
||||
if len(members) == 1 and g['exact_id'] == members[0]['id']:
|
||||
continue
|
||||
touched += 1
|
||||
canonical = g['canonical']
|
||||
|
||||
# choose target id: prefer exact bare row, else promote smallest id
|
||||
if g['exact_id'] is not None:
|
||||
target = g['exact_id']
|
||||
promote = None
|
||||
else:
|
||||
target = members[0]['id']
|
||||
promote = members[0]
|
||||
|
||||
# accumulate suffix/description text from all members (dedup, ordered)
|
||||
details = []
|
||||
for m in members:
|
||||
for chunk in (m['_suffix'], m['desc']):
|
||||
chunk = (chunk or '').strip()
|
||||
if chunk and chunk not in details:
|
||||
details.append(chunk)
|
||||
desc_val = '; '.join(details)
|
||||
|
||||
lines.append('')
|
||||
lines.append(f"-- {canonical} (target locationid {target}) <= "
|
||||
+ ', '.join(str(m['id']) for m in members))
|
||||
if promote is not None:
|
||||
lines.append(
|
||||
f"UPDATE locations SET locationname='{esc(canonical)}' "
|
||||
f"WHERE locationid={target};")
|
||||
if desc_val:
|
||||
lines.append(
|
||||
f"UPDATE locations SET description='{esc(desc_val)}' "
|
||||
f"WHERE locationid={target};")
|
||||
|
||||
others = [m['id'] for m in members if m['id'] != target]
|
||||
if others:
|
||||
ids = ','.join(str(i) for i in others)
|
||||
lines.append(
|
||||
f"UPDATE assets SET locationid={target} "
|
||||
f"WHERE locationid IN ({ids});")
|
||||
lines.append(
|
||||
f"UPDATE subnets SET locationid={target} "
|
||||
f"WHERE locationid IN ({ids});")
|
||||
lines.append(
|
||||
f"UPDATE locations SET parentlocationid={target} "
|
||||
f"WHERE parentlocationid IN ({ids});")
|
||||
lines.append(
|
||||
f"DELETE FROM locations WHERE locationid IN ({ids});")
|
||||
|
||||
lines.append('')
|
||||
lines.append('COMMIT;')
|
||||
lines.append(f'-- groups touched: {touched}')
|
||||
|
||||
with open(out, 'w', encoding='utf-8') as fh:
|
||||
fh.write('\n'.join(lines) + '\n')
|
||||
print(f'wrote {out}: {touched} groups touched, {len(rows)} source rows')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(sys.argv[1], sys.argv[2])
|
||||
594
scripts/site_imports/assetcollector/Collect-ShopDB.ps1
Normal file
594
scripts/site_imports/assetcollector/Collect-ShopDB.ps1
Normal file
@@ -0,0 +1,594 @@
|
||||
#Requires -Version 5.1
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# Where CSVs are collected/accumulated. Overrides the in-script CONFIG block
|
||||
# below. See the CONFIG block for the easy place to set this permanently.
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$OutputRoot,
|
||||
|
||||
# Optional network share to mirror the store into after a run. Overrides the
|
||||
# in-script CONFIG block below.
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$PublishRoot,
|
||||
|
||||
# Business identifier for this PC (workstation number). Defaults to hostname.
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AssetNumber,
|
||||
|
||||
# Value written into assets.assettypeid for the PC (resolved by name).
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AssetType = 'Computer',
|
||||
|
||||
# Value written into computers.computertypeid (resolved by name).
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$ComputerType,
|
||||
|
||||
# Value written into machines.machinetypeid (resolved by name).
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$MachineType,
|
||||
|
||||
# Explicit eDNC/UDC source overrides. When omitted, standard on-asset
|
||||
# locations are searched.
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$RegPath,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$JsonPath,
|
||||
|
||||
# Skip switches for partial runs.
|
||||
[switch]$SkipMachine,
|
||||
[switch]$SkipApps
|
||||
)
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Unified ShopDB site-adoption collector. Runs on any PC, collects the PC and any
|
||||
attached eDNC/DNC machine, and UPSERTS the results into a shared set of
|
||||
importable CSVs so a site can build its inventory PC by PC.
|
||||
|
||||
.DESCRIPTION
|
||||
Accumulating store under OutputRoot:
|
||||
vendors.csv, models.csv, operatingsystems.csv, assets.csv,
|
||||
computers.csv, machines.csv
|
||||
Rows are merged field-by-field on each table's natural key (a later run fills
|
||||
gaps and refreshes values without wiping data an earlier run captured).
|
||||
|
||||
Phase-2 data that CSV cannot carry is written per entity under OutputRoot\sidecars:
|
||||
computer_<assetnumber>.json (interfaces, installed apps)
|
||||
machine_<machinenumber>.json (PC<->Machine link, connection detail)
|
||||
|
||||
Read-only against the machine. Local reads may run elevated; publishing to a
|
||||
share runs in the current user context only.
|
||||
|
||||
.NOTES
|
||||
PS 5.1 compatible. Headers match the ShopDB importer (see CSV-SCHEMA.md).
|
||||
#>
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# =============================================================================
|
||||
# CONFIG - SET YOUR CSV DESTINATIONS HERE
|
||||
# -----------------------------------------------------------------------------
|
||||
# Two-stage flow so you can collect locally (possibly elevated) and then append
|
||||
# the captured rows to a CSV store that lives on a network share:
|
||||
#
|
||||
# 1) LocalStageRoot Fast, always-writable local folder where CSVs are built
|
||||
# and merged. Collection can run elevated here.
|
||||
# 2) NetworkStoreRoot The shared CSV store the whole site accumulates into and
|
||||
# that you later point 'flask csv import --dir' at. Leave
|
||||
# blank ('') to keep everything local. When set, the merged
|
||||
# store is copied here at the end of the run. Network
|
||||
# writes must happen as a STANDARD user - do NOT run the
|
||||
# publish step elevated.
|
||||
#
|
||||
# Example network path: '\\fileserver\shopdb$\intake'
|
||||
#
|
||||
# Command-line -OutputRoot / -PublishRoot still override these values.
|
||||
# =============================================================================
|
||||
|
||||
$LocalStageRoot = Join-Path $env:ProgramData 'ShopDB\intake'
|
||||
# Ship blank so a site that has not configured its share keeps everything local
|
||||
# instead of publishing to another site's path. Set this to your own share (or
|
||||
# pass -PublishRoot), e.g. '\\fileserver\shopdb$\intake'. The download endpoint
|
||||
# fills this in from asset_import_csv_location when the site has set it.
|
||||
$NetworkStoreRoot = ''
|
||||
|
||||
# Resolve effective destinations (command-line params win over CONFIG above).
|
||||
if (-not $OutputRoot) { $OutputRoot = $LocalStageRoot }
|
||||
if (-not $PublishRoot) { $PublishRoot = $NetworkStoreRoot }
|
||||
|
||||
# --- Store definitions (header order + natural key per importable table) -------
|
||||
|
||||
$script:Stores = [ordered]@{
|
||||
'vendors.csv' = @{ key = 'vendor'; header = @('vendor') }
|
||||
'models.csv' = @{ key = 'modelnumber'; header = @('modelnumber','vendorid','modeltypeid') }
|
||||
'operatingsystems.csv' = @{ key = 'osname'; header = @('osname') }
|
||||
'assets.csv' = @{ key = 'assetnumber'; header = @('assetnumber','assettypeid','name','statusid','serialnumber','gaugelabreference','maintenancereference','locationid','businessunitid','notes') }
|
||||
'computers.csv' = @{ key = 'assetid'; header = @('assetid','hostname','computertypeid','osid','vendorid','modelnumberid','loggedinuser','lastreporteddate','lastboottime') }
|
||||
'machines.csv' = @{ key = 'assetid'; header = @('assetid','machinetypeid','vendorid','modelnumberid','controllervendorid','controllermodelid','requiresmanualconfig','islocationonly','notes') }
|
||||
}
|
||||
|
||||
# --- Shared helpers -----------------------------------------------------------
|
||||
|
||||
function ConvertTo-CsvField {
|
||||
param([AllowNull()]$Value)
|
||||
if ($null -eq $Value) { return '' }
|
||||
$text = [string]$Value
|
||||
if ($text -match '[",\r\n]') { return '"' + ($text -replace '"', '""') + '"' }
|
||||
return $text
|
||||
}
|
||||
|
||||
function Get-Prop {
|
||||
param([AllowNull()]$Object, [Parameter(Mandatory = $true)][string]$Name)
|
||||
if ($null -eq $Object) { return $null }
|
||||
$prop = $Object.PSObject.Properties[$Name]
|
||||
if ($null -eq $prop) { return $null }
|
||||
return $prop.Value
|
||||
}
|
||||
|
||||
function Get-NonEmpty {
|
||||
param([AllowNull()]$Value)
|
||||
if ($null -ne $Value -and -not [string]::IsNullOrWhiteSpace([string]$Value)) {
|
||||
return ([string]$Value).Trim()
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-FirstNonEmpty {
|
||||
param([object[]]$Values)
|
||||
foreach ($v in $Values) { $n = Get-NonEmpty $v; if ($n) { return $n } }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Format-ShopDbDate {
|
||||
param([AllowNull()]$Value)
|
||||
if ($null -eq $Value) { return $null }
|
||||
try { return ([datetime]$Value).ToString('yyyy-MM-dd HH:mm:ss') } catch { return $null }
|
||||
}
|
||||
|
||||
function Write-Utf8NoBom {
|
||||
param([string]$Path, [string]$Content)
|
||||
$encoding = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($Path, $Content, $encoding)
|
||||
}
|
||||
|
||||
# Field-level upsert into a persistent CSV store, keyed by natural key. Existing
|
||||
# values are kept when the incoming cell is empty, so a partial run never blanks
|
||||
# out data an earlier run captured.
|
||||
function Merge-CsvStore {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string[]]$Header,
|
||||
[Parameter(Mandatory = $true)][string]$Key,
|
||||
[Parameter(Mandatory = $true)][AllowNull()][object[]]$IncomingRows
|
||||
)
|
||||
|
||||
$index = [ordered]@{}
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
foreach ($existing in @(Import-Csv -LiteralPath $Path)) {
|
||||
$keyVal = Get-NonEmpty (Get-Prop $existing $Key)
|
||||
if (-not $keyVal) { continue }
|
||||
$index[$keyVal.ToLowerInvariant()] = $existing
|
||||
}
|
||||
}
|
||||
|
||||
$changed = 0
|
||||
foreach ($row in $IncomingRows) {
|
||||
if ($null -eq $row) { continue }
|
||||
$keyVal = Get-NonEmpty (Get-Prop $row $Key)
|
||||
if (-not $keyVal) { continue }
|
||||
$lc = $keyVal.ToLowerInvariant()
|
||||
if ($index.Contains($lc)) {
|
||||
$target = $index[$lc]
|
||||
foreach ($col in $Header) {
|
||||
$incoming = Get-NonEmpty (Get-Prop $row $col)
|
||||
if ($incoming) {
|
||||
if ($target.PSObject.Properties[$col]) { $target.$col = $incoming }
|
||||
else { $target | Add-Member -NotePropertyName $col -NotePropertyValue $incoming -Force }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$fresh = [ordered]@{}
|
||||
foreach ($col in $Header) { $fresh[$col] = Get-NonEmpty (Get-Prop $row $col) }
|
||||
$index[$lc] = [pscustomobject]$fresh
|
||||
}
|
||||
$changed++
|
||||
}
|
||||
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
$lines.Add(($Header -join ','))
|
||||
foreach ($stored in $index.Values) {
|
||||
$cells = foreach ($col in $Header) { ConvertTo-CsvField (Get-Prop $stored $col) }
|
||||
$lines.Add(($cells -join ','))
|
||||
}
|
||||
Write-Utf8NoBom -Path $Path -Content (($lines -join "`r`n") + "`r`n")
|
||||
return $changed
|
||||
}
|
||||
|
||||
# --- eDNC/UDC parsing (registry, .reg export, UDC settings json) --------------
|
||||
|
||||
function Resolve-ControllerVendor {
|
||||
param([AllowNull()][string]$ControlType, [AllowNull()][string]$NcIF)
|
||||
$ct = ('' + $ControlType).Trim().ToUpperInvariant()
|
||||
$nc = ('' + $NcIF).Trim().ToUpperInvariant()
|
||||
if ($nc -eq 'EFOCAS' -or $ct -eq 'EFOCAS') { return 'FANUC' }
|
||||
if ($ct -eq 'SIEMENS') { return 'Siemens' }
|
||||
if ($ct -eq 'OKUMA') { return 'Okuma' }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Read-RegExport {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
$lines = @(Get-Content -LiteralPath $Path -Encoding Unicode -ErrorAction Stop)
|
||||
$firstMeaningful = ($lines | Where-Object { $_ -match '\S' } | Select-Object -First 1)
|
||||
if (-not ($firstMeaningful -match '^(REGEDIT4|Windows Registry Editor Version)')) {
|
||||
$lines = @(Get-Content -LiteralPath $Path -Encoding Default -ErrorAction Stop)
|
||||
}
|
||||
$data = @{}
|
||||
$currentKey = $null
|
||||
foreach ($raw in $lines) {
|
||||
$line = $raw.Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($line)) { continue }
|
||||
if ($line -match '^(REGEDIT4|Windows Registry Editor Version)') { continue }
|
||||
if ($line -match '^\s*;') { continue }
|
||||
if ($line -match '^\[(?<key>[^\]]+)\]$') {
|
||||
$currentKey = $Matches['key']
|
||||
if (-not $data.ContainsKey($currentKey)) { $data[$currentKey] = @{} }
|
||||
continue
|
||||
}
|
||||
if (-not $currentKey) { continue }
|
||||
if ($line -match '^"(?<name>[^"]+)"=(?<rhs>.+)$') {
|
||||
$name = $Matches['name']
|
||||
$rhs = $Matches['rhs'].Trim()
|
||||
if ($rhs -match '^"(?<s>.*)"$') { $val = $Matches['s'] }
|
||||
elseif ($rhs -match '^dword:(?<hex>[0-9a-fA-F]{8})$') { $val = [Convert]::ToInt32($Matches['hex'], 16) }
|
||||
else { $val = $rhs }
|
||||
$data[$currentKey][$name] = $val
|
||||
}
|
||||
}
|
||||
return $data
|
||||
}
|
||||
|
||||
function Get-RegExportValue {
|
||||
param([hashtable]$Reg, [string]$KeySuffix, [string]$Name)
|
||||
foreach ($key in $Reg.Keys) {
|
||||
if ($key -match ('\\' + [regex]::Escape($KeySuffix) + '$')) {
|
||||
if ($Reg[$key].ContainsKey($Name)) { return $Reg[$key][$Name] }
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function ConvertFrom-RegExport {
|
||||
param([hashtable]$Reg)
|
||||
[ordered]@{
|
||||
source = 'reg'
|
||||
machinenumber = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'MachineNo')
|
||||
site = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'Site')
|
||||
controltype = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'Cnc')
|
||||
ncif = Get-NonEmpty (Get-RegExportValue $Reg 'General' 'NcIF')
|
||||
dnctype = 'eDNC'
|
||||
tcpaddress = Get-NonEmpty (Get-RegExportValue $Reg 'eFocas' 'IpAddr')
|
||||
tcpport = Get-NonEmpty (Get-RegExportValue $Reg 'eFocas' 'SocketNo')
|
||||
comport = Get-NonEmpty (Get-RegExportValue $Reg 'PPDCS' 'Port Id')
|
||||
baudrate = Get-NonEmpty (Get-RegExportValue $Reg 'PPDCS' 'Baud')
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RegistryValue {
|
||||
param([string]$Path, [string]$Name)
|
||||
if (-not (Test-Path -LiteralPath $Path)) { return $null }
|
||||
return Get-Prop (Get-ItemProperty -LiteralPath $Path -ErrorAction SilentlyContinue) $Name
|
||||
}
|
||||
|
||||
function ConvertFrom-LiveRegistry {
|
||||
param([string]$Base)
|
||||
if (-not (Test-Path -LiteralPath $Base)) { return $null }
|
||||
$general = Join-Path $Base 'General'
|
||||
$efocas = Join-Path $Base 'eFocas'
|
||||
$ppdcs = Join-Path $Base 'PPDCS'
|
||||
[ordered]@{
|
||||
source = 'registry'
|
||||
machinenumber = Get-NonEmpty (Get-RegistryValue $general 'MachineNo')
|
||||
site = Get-NonEmpty (Get-RegistryValue $general 'Site')
|
||||
controltype = Get-NonEmpty (Get-RegistryValue $general 'Cnc')
|
||||
ncif = Get-NonEmpty (Get-RegistryValue $general 'NcIF')
|
||||
dnctype = 'eDNC'
|
||||
tcpaddress = Get-NonEmpty (Get-RegistryValue $efocas 'IpAddr')
|
||||
tcpport = Get-NonEmpty (Get-RegistryValue $efocas 'SocketNo')
|
||||
comport = Get-NonEmpty (Get-RegistryValue $ppdcs 'Port Id')
|
||||
baudrate = Get-NonEmpty (Get-RegistryValue $ppdcs 'Baud')
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertFrom-UdcJson {
|
||||
param([string]$Path)
|
||||
$obj = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json
|
||||
$general = Get-Prop $obj 'GeneralSettings'
|
||||
$ethernet = Get-Prop $obj 'EthernetSettings'
|
||||
$serial = Get-Prop $obj 'SerialSettings'
|
||||
[ordered]@{
|
||||
source = 'json'
|
||||
machinenumber = Get-NonEmpty (Get-Prop $general 'MachineNumber')
|
||||
site = Get-NonEmpty (Get-Prop $general 'Site')
|
||||
controltype = Get-NonEmpty (Get-Prop $general 'ControlType')
|
||||
ncif = $null
|
||||
dnctype = Get-NonEmpty (Get-Prop $general 'DncType')
|
||||
tcpaddress = Get-NonEmpty (Get-Prop $ethernet 'TCPAddress')
|
||||
tcpport = Get-NonEmpty (Get-Prop $ethernet 'TCPPort')
|
||||
comport = Get-NonEmpty (Get-Prop $serial 'ComPort')
|
||||
baudrate = Get-NonEmpty (Get-Prop $serial 'BaudRate')
|
||||
}
|
||||
}
|
||||
|
||||
function Find-FirstFile {
|
||||
param([string[]]$Roots, [string]$Filter)
|
||||
foreach ($root in $Roots) {
|
||||
if (-not $root -or -not (Test-Path -LiteralPath $root)) { continue }
|
||||
$hit = Get-ChildItem -LiteralPath $root -Filter $Filter -File -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||
if ($hit) { return $hit.FullName }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Resolve-DncSource {
|
||||
param([string]$RegPath, [string]$JsonPath)
|
||||
if ($JsonPath -and (Test-Path -LiteralPath $JsonPath)) {
|
||||
Write-Host (' machine source: JSON (explicit) ' + $JsonPath) -ForegroundColor DarkGray
|
||||
return ConvertFrom-UdcJson -Path $JsonPath
|
||||
}
|
||||
if ($RegPath -and (Test-Path -LiteralPath $RegPath)) {
|
||||
Write-Host (' machine source: .reg (explicit) ' + $RegPath) -ForegroundColor DarkGray
|
||||
return ConvertFrom-RegExport -Reg (Read-RegExport -Path $RegPath)
|
||||
}
|
||||
foreach ($base in @(
|
||||
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC',
|
||||
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC')) {
|
||||
$fromReg = ConvertFrom-LiveRegistry -Base $base
|
||||
if ($fromReg -and $fromReg.machinenumber) {
|
||||
Write-Host (' machine source: live registry ' + $base) -ForegroundColor DarkGray
|
||||
return $fromReg
|
||||
}
|
||||
}
|
||||
$localReg = Find-FirstFile -Roots @('C:\Program Files (x86)\Dnc', (Join-Path $env:SystemDrive 'GE Aerospace')) -Filter '*.reg'
|
||||
if ($localReg) {
|
||||
Write-Host (' machine source: local .reg ' + $localReg) -ForegroundColor DarkGray
|
||||
return ConvertFrom-RegExport -Reg (Read-RegExport -Path $localReg)
|
||||
}
|
||||
$localJson = Find-FirstFile -Roots @((Join-Path $env:SystemDrive 'GE Aerospace'), 'C:\Program Files (x86)\UDC', 'C:\Program Files (x86)\Dnc') -Filter 'udc_settings_*.json'
|
||||
if ($localJson) {
|
||||
Write-Host (' machine source: local UDC json ' + $localJson) -ForegroundColor DarkGray
|
||||
return ConvertFrom-UdcJson -Path $localJson
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# --- PC collection (read-only) ------------------------------------------------
|
||||
|
||||
function Get-PcFacts {
|
||||
$computerSystem = Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue
|
||||
$operatingSys = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue
|
||||
$bios = Get-CimInstance Win32_BIOS -ErrorAction SilentlyContinue
|
||||
|
||||
$interfaces = @()
|
||||
try {
|
||||
foreach ($cfg in @(Get-NetIPConfiguration -ErrorAction SilentlyContinue)) {
|
||||
$adapter = $null
|
||||
try { $adapter = Get-NetAdapter -InterfaceAlias $cfg.InterfaceAlias -ErrorAction SilentlyContinue } catch {}
|
||||
if (-not $adapter -or (Get-Prop $adapter 'HardwareInterface') -ne $true) { continue }
|
||||
$ipv4 = @(Get-Prop $cfg 'IPv4Address') | Select-Object -First 1
|
||||
$interfaces += [ordered]@{
|
||||
interfacealias = [string]$cfg.InterfaceAlias
|
||||
ipaddress = if ($ipv4) { [string](Get-Prop $ipv4 'IPAddress') } else { $null }
|
||||
macaddress = [string](Get-Prop $adapter 'MacAddress')
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
$installedApps = @()
|
||||
if (-not $SkipApps) {
|
||||
foreach ($keyPath in @(
|
||||
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
|
||||
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*')) {
|
||||
foreach ($entry in @(Get-ItemProperty -Path $keyPath -ErrorAction SilentlyContinue)) {
|
||||
$displayName = Get-NonEmpty (Get-Prop $entry 'DisplayName')
|
||||
if (-not $displayName) { continue }
|
||||
if ((Get-Prop $entry 'SystemComponent') -eq 1) { continue }
|
||||
$installedApps += [ordered]@{
|
||||
appname = $displayName
|
||||
installedversion = Get-NonEmpty (Get-Prop $entry 'DisplayVersion')
|
||||
publisher = Get-NonEmpty (Get-Prop $entry 'Publisher')
|
||||
}
|
||||
}
|
||||
}
|
||||
$installedApps = @($installedApps | Sort-Object { $_.appname } -Unique)
|
||||
}
|
||||
|
||||
[ordered]@{
|
||||
hostname = Get-FirstNonEmpty @($env:COMPUTERNAME, (Get-Prop $computerSystem 'Name'))
|
||||
vendor = Get-NonEmpty (Get-Prop $computerSystem 'Manufacturer')
|
||||
model = Get-NonEmpty (Get-Prop $computerSystem 'Model')
|
||||
osname = Get-NonEmpty (Get-Prop $operatingSys 'Caption')
|
||||
serial = Get-FirstNonEmpty @((Get-Prop $bios 'SerialNumber'), (Get-Prop $computerSystem 'SerialNumber'))
|
||||
lastboot = Format-ShopDbDate (Get-Prop $operatingSys 'LastBootUpTime')
|
||||
loggeduser = Get-NonEmpty (Get-Prop $computerSystem 'UserName')
|
||||
interfaces = $interfaces
|
||||
installedapps = $installedApps
|
||||
}
|
||||
}
|
||||
|
||||
# --- Main ---------------------------------------------------------------------
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ' ShopDB unified collector' -ForegroundColor Cyan
|
||||
Write-Host (' ' + ('-' * 56)) -ForegroundColor DarkGray
|
||||
|
||||
if (-not (Test-Path -LiteralPath $OutputRoot)) { New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null }
|
||||
$sidecarDir = Join-Path $OutputRoot 'sidecars'
|
||||
if (-not (Test-Path -LiteralPath $sidecarDir)) { New-Item -ItemType Directory -Path $sidecarDir -Force | Out-Null }
|
||||
|
||||
$collectedAt = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
|
||||
|
||||
$pc = Get-PcFacts
|
||||
if (-not $AssetNumber) { $AssetNumber = $pc.hostname }
|
||||
|
||||
# Row sets keyed to the shared stores.
|
||||
$vendorRows = @()
|
||||
$modelRows = @()
|
||||
$osRows = @()
|
||||
$assetRows = @()
|
||||
$computerRows = @()
|
||||
$machineRows = @()
|
||||
|
||||
if ($pc.vendor) { $vendorRows += [pscustomobject]@{ vendor = $pc.vendor } }
|
||||
if ($pc.model) { $modelRows += [pscustomobject]@{ modelnumber = $pc.model; vendorid = $pc.vendor; modeltypeid = $null } }
|
||||
if ($pc.osname) { $osRows += [pscustomobject]@{ osname = $pc.osname } }
|
||||
|
||||
$assetRows += [pscustomobject]@{
|
||||
assetnumber = $AssetNumber; assettypeid = $AssetType; name = $pc.hostname; statusid = $null
|
||||
serialnumber = $pc.serial; gaugelabreference = $null; maintenancereference = $null
|
||||
locationid = $null; businessunitid = $null
|
||||
notes = 'PC collected ' + $collectedAt
|
||||
}
|
||||
$computerRows += [pscustomobject]@{
|
||||
assetid = $AssetNumber; hostname = $pc.hostname; computertypeid = $ComputerType; osid = $pc.osname
|
||||
vendorid = $pc.vendor; modelnumberid = $pc.model; loggedinuser = $pc.loggeduser
|
||||
lastreporteddate = $collectedAt; lastboottime = $pc.lastboot
|
||||
}
|
||||
|
||||
# Machine (best-effort).
|
||||
$machine = $null
|
||||
if (-not $SkipMachine) {
|
||||
try { $machine = Resolve-DncSource -RegPath $RegPath -JsonPath $JsonPath } catch { $machine = $null }
|
||||
}
|
||||
if ($machine -and $machine.machinenumber) {
|
||||
$controllerVendor = Resolve-ControllerVendor -ControlType $machine.controltype -NcIF $machine.ncif
|
||||
$transport = if ($machine.tcpaddress) { 'ethernet' } elseif ($machine.comport) { 'serial' } else { 'unknown' }
|
||||
$noteBits = @("DNC=$($machine.dnctype)")
|
||||
if ($machine.controltype) { $noteBits += "control=$($machine.controltype)" }
|
||||
$noteBits += "transport=$transport"
|
||||
$machineNote = 'eDNC collected ' + $collectedAt + '; ' + ($noteBits -join '; ')
|
||||
|
||||
if ($controllerVendor) { $vendorRows += [pscustomobject]@{ vendor = $controllerVendor } }
|
||||
$assetRows += [pscustomobject]@{
|
||||
assetnumber = $machine.machinenumber; assettypeid = 'Machine'; name = $machine.machinenumber; statusid = $null
|
||||
serialnumber = $null; gaugelabreference = $null; maintenancereference = $null
|
||||
locationid = $null; businessunitid = $null; notes = $machineNote
|
||||
}
|
||||
$machineRows += [pscustomobject]@{
|
||||
assetid = $machine.machinenumber; machinetypeid = $MachineType; vendorid = $null; modelnumberid = $null
|
||||
controllervendorid = $controllerVendor; controllermodelid = $null
|
||||
requiresmanualconfig = $null; islocationonly = $null; notes = $machineNote
|
||||
}
|
||||
|
||||
$link = [ordered]@{
|
||||
pcassetnumber = $AssetNumber; machineassetnumber = $machine.machinenumber; relationship = 'controls'
|
||||
dnctype = $machine.dnctype; controltype = $machine.controltype; controllervendor = $controllerVendor
|
||||
site = $machine.site; source = $machine.source; collectedat = $collectedAt
|
||||
connection = [ordered]@{
|
||||
transport = $transport; tcpaddress = $machine.tcpaddress; tcpport = $machine.tcpport
|
||||
comport = $machine.comport; baudrate = $machine.baudrate
|
||||
}
|
||||
}
|
||||
Write-Utf8NoBom -Path (Join-Path $sidecarDir ('machine_' + $machine.machinenumber + '.json')) -Content ($link | ConvertTo-Json -Depth 6)
|
||||
Write-Host (' linked machine ' + $machine.machinenumber + ' (' + $controllerVendor + ', ' + $transport + ')') -ForegroundColor DarkGray
|
||||
} elseif (-not $SkipMachine) {
|
||||
Write-Host ' no eDNC/DNC/UDC machine config found on this PC' -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# --- Upsert into the shared store --------------------------------------------
|
||||
|
||||
$rowsByStore = @{
|
||||
'vendors.csv' = $vendorRows
|
||||
'models.csv' = $modelRows
|
||||
'operatingsystems.csv' = $osRows
|
||||
'assets.csv' = $assetRows
|
||||
'computers.csv' = $computerRows
|
||||
'machines.csv' = $machineRows
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host (' Store: ' + $OutputRoot) -ForegroundColor Green
|
||||
foreach ($fileName in $script:Stores.Keys) {
|
||||
$spec = $script:Stores[$fileName]
|
||||
$rows = @($rowsByStore[$fileName])
|
||||
if ($rows.Count -eq 0) { continue }
|
||||
$path = Join-Path $OutputRoot $fileName
|
||||
$n = Merge-CsvStore -Path $path -Header $spec.header -Key $spec.key -IncomingRows $rows
|
||||
Write-Host (' merged ' + $n + ' row(s) into ' + $fileName) -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# PC phase-2 sidecar.
|
||||
$pcSidecar = [ordered]@{
|
||||
assetnumber = $AssetNumber; hostname = $pc.hostname; collectedat = $collectedAt
|
||||
interfaces = $pc.interfaces; installedapps = $pc.installedapps
|
||||
}
|
||||
Write-Utf8NoBom -Path (Join-Path $sidecarDir ('computer_' + $AssetNumber + '.json')) -Content ($pcSidecar | ConvertTo-Json -Depth 6)
|
||||
|
||||
# --- Publish (standard-user network mirror, optional) -------------------------
|
||||
|
||||
if ($PublishRoot) {
|
||||
if (([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Host ''
|
||||
Write-Host ' WARNING: publishing while elevated. Network writes should run as the standard user.' -ForegroundColor Yellow
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $PublishRoot)) { New-Item -ItemType Directory -Path $PublishRoot -Force | Out-Null }
|
||||
|
||||
# Merge THIS PC's rows into the shared store rather than overwriting it. A
|
||||
# blind copy would erase every other PC's rows (issue: the store is meant to
|
||||
# accumulate site-wide). Merge-CsvStore upserts field-by-field, so a second
|
||||
# PC's run adds its rows and refreshes shared ones without dropping the rest.
|
||||
# A lock file serializes concurrent publishers so two PCs cannot read-modify-
|
||||
# write the same store CSV at once and lose each other's rows.
|
||||
$lockPath = Join-Path $PublishRoot '.publish.lock'
|
||||
$lockStream = $null
|
||||
for ($attempt = 0; $attempt -lt 60; $attempt++) {
|
||||
try {
|
||||
$lockStream = [System.IO.File]::Open(
|
||||
$lockPath, [System.IO.FileMode]::OpenOrCreate,
|
||||
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
|
||||
break
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
if ($null -eq $lockStream) {
|
||||
Write-Host ''
|
||||
Write-Host (' WARNING: could not acquire publish lock at ' + $lockPath + '; skipping network merge.') -ForegroundColor Yellow
|
||||
} else {
|
||||
try {
|
||||
foreach ($fileName in $script:Stores.Keys) {
|
||||
$spec = $script:Stores[$fileName]
|
||||
$localPath = Join-Path $OutputRoot $fileName
|
||||
if (-not (Test-Path -LiteralPath $localPath)) { continue }
|
||||
$localRows = @(Import-Csv -LiteralPath $localPath)
|
||||
if ($localRows.Count -eq 0) { continue }
|
||||
$sharePath = Join-Path $PublishRoot $fileName
|
||||
$n = Merge-CsvStore -Path $sharePath -Header $spec.header -Key $spec.key -IncomingRows $localRows
|
||||
Write-Host (' merged ' + $n + ' row(s) into ' + $fileName + ' on share') -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# Sidecars are uniquely named per asset, so a straight copy is safe and
|
||||
# nothing is overwritten across PCs.
|
||||
$localSidecars = Join-Path $OutputRoot 'sidecars'
|
||||
if (Test-Path -LiteralPath $localSidecars) {
|
||||
$shareSidecars = Join-Path $PublishRoot 'sidecars'
|
||||
if (-not (Test-Path -LiteralPath $shareSidecars)) { New-Item -ItemType Directory -Path $shareSidecars -Force | Out-Null }
|
||||
Copy-Item -Path (Join-Path $localSidecars '*') -Destination $shareSidecars -Recurse -Force
|
||||
}
|
||||
} finally {
|
||||
$lockStream.Close()
|
||||
$lockStream.Dispose()
|
||||
Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host ''
|
||||
Write-Host (' Published store to: ' + $PublishRoot) -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ' Done. Dry-run validate the accumulated store with:' -ForegroundColor Cyan
|
||||
Write-Host (' flask csv import --dir "' + $OutputRoot + '"') -ForegroundColor White
|
||||
Write-Host ''
|
||||
91
scripts/site_imports/assetcollector/overview.md
Normal file
91
scripts/site_imports/assetcollector/overview.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Collect-ShopDB.ps1 Overview
|
||||
|
||||
## Overview
|
||||
|
||||
- **Purpose**: A unified, read-only "site adoption" collector that runs on any
|
||||
Windows PC, gathers facts about that PC plus any attached eDNC/DNC/UDC machine,
|
||||
and merges them into a shared set of importable CSVs so a site can build its
|
||||
ShopDB inventory one PC at a time.
|
||||
- **Compatibility**: PowerShell 5.1+; CSV headers are aligned to the ShopDB
|
||||
importer (`flask csv import --dir`).
|
||||
|
||||
## Configuration and destinations
|
||||
|
||||
- **Two-stage flow**: a fast local stage folder (`%ProgramData%\ShopDB\intake`)
|
||||
where CSVs are built/merged, and an optional network store (a file share) that
|
||||
the whole site accumulates into.
|
||||
- Command-line params `-OutputRoot` and `-PublishRoot` override the in-script
|
||||
CONFIG block.
|
||||
- Other params: `-AssetNumber` (defaults to hostname),
|
||||
`-AssetType`/`-ComputerType`/`-MachineType` (resolved by name), explicit
|
||||
`-RegPath`/`-JsonPath` machine source overrides, and `-SkipMachine`/`-SkipApps`
|
||||
switches for partial runs.
|
||||
|
||||
## Data model (the stores)
|
||||
|
||||
- Six importable CSVs, each with a defined header order and natural key:
|
||||
`vendors.csv`, `models.csv`, `operatingsystems.csv`, `assets.csv`,
|
||||
`computers.csv`, `machines.csv`.
|
||||
- Column names follow ShopDB conventions (e.g., `assetid`, `modelnumberid`,
|
||||
`controllervendorid`, `lastreporteddate`).
|
||||
|
||||
## PC collection (`Get-PcFacts`, read-only)
|
||||
|
||||
- Uses CIM/WMI (`Win32_ComputerSystem`, `Win32_OperatingSystem`, `Win32_BIOS`)
|
||||
to gather hostname, vendor/manufacturer, model, OS caption, serial number,
|
||||
last boot time, and logged-in user.
|
||||
- Enumerates **network interfaces** (hardware adapters only: alias, IPv4, MAC).
|
||||
- Enumerates **installed applications** from the registry Uninstall keys (name,
|
||||
version, publisher), skipping system components - unless `-SkipApps` is set.
|
||||
|
||||
## Machine (eDNC/DNC/UDC) collection
|
||||
|
||||
- `Resolve-DncSource` searches in priority order: explicit JSON path -> explicit
|
||||
.reg path -> live registry (`HKLM\...\GE Aircraft Engines\DNC`) -> local `.reg`
|
||||
export files -> local `udc_settings_*.json` files.
|
||||
- Three parsers normalize the different source formats into one shape:
|
||||
`ConvertFrom-RegExport` (parses exported `.reg` files including dword decoding),
|
||||
`ConvertFrom-LiveRegistry`, and `ConvertFrom-UdcJson`.
|
||||
- Extracts machine number, site, control type, DNC type, and connection detail
|
||||
(TCP address/port for ethernet, COM port/baud for serial).
|
||||
- `Resolve-ControllerVendor` maps control type / NcIF to a controller vendor
|
||||
(e.g., eFOCAS -> FANUC, Siemens, Okuma).
|
||||
|
||||
## Merge / upsert logic (`Merge-CsvStore`)
|
||||
|
||||
- Field-level UPSERT keyed on each table's natural key: existing rows are matched
|
||||
case-insensitively, and empty incoming cells never overwrite previously captured
|
||||
values - so partial or repeated runs fill gaps without wiping earlier data.
|
||||
- CSV writing is UTF-8 without BOM, with proper quoting/escaping.
|
||||
|
||||
## Sidecar files (phase-2 data CSV can't carry)
|
||||
|
||||
- Per-PC `sidecars\computer_<assetnumber>.json`: interfaces + installed apps.
|
||||
- Per-machine `sidecars\machine_<machinenumber>.json`: PC-to-machine link
|
||||
(relationship "controls"), DNC/control details, and connection transport info.
|
||||
|
||||
## Publishing and output
|
||||
|
||||
- Optionally mirrors the whole local store to the network share
|
||||
(`Copy-Item -Recurse -Force`), with a warning if run elevated (network writes
|
||||
should run as standard user).
|
||||
- Prints a summary and the follow-up dry-run command:
|
||||
`flask csv import --dir "<OutputRoot>"`.
|
||||
|
||||
---
|
||||
|
||||
## Relevance to an "Asset Collector" package
|
||||
|
||||
This script is a solid template for one collector in a broader package. Notable
|
||||
patterns worth generalizing across other collectors (existing databases,
|
||||
Intune/Azure exports, etc.):
|
||||
|
||||
- **Normalized intermediate schema** (the six CSV stores + JSON sidecars) that
|
||||
all sources converge on, decoupling collection from import.
|
||||
- **Idempotent field-level merge** so multiple heterogeneous sources can
|
||||
contribute to the same asset without clobbering each other.
|
||||
- **Source-priority resolution** and per-format parsers - the same shape could
|
||||
wrap an Intune/Azure `Get-MgDevice` export or a SQL query.
|
||||
- **Machines, PCs, Applications** are already modeled here; an Intune/Azure
|
||||
source would map naturally onto `computers.csv` + the installed-apps sidecar,
|
||||
while a MES/CNC database could feed `machines.csv`.
|
||||
5
scripts/site_imports/assetcollector/sample/assets.csv
Normal file
5
scripts/site_imports/assetcollector/sample/assets.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
assetnumber,name,serialnumber,assettypeid,statusid,locationid,businessunitid,notes
|
||||
SAMPLE-PC-01,Shopfloor PC 01,DELLSN0001,computer,In Use,A1,Gage Lab,Sample collected device
|
||||
SAMPLE-PC-02,CMM PC 02,DELLSN0002,computer,In Use,A1,Gage Lab,Sample collected device
|
||||
SAMPLE-MC-01,Zeiss CMM 01,ZEISSSN0001,machine,In Use,A1,Gage Lab,Sample collected device attached to SAMPLE-PC-02
|
||||
SAMPLE-MC-02,Haas VF-2SS 02,HAASSN0002,machine,In Use,A1,Gage Lab,Sample collected standalone CNC
|
||||
|
3
scripts/site_imports/assetcollector/sample/computers.csv
Normal file
3
scripts/site_imports/assetcollector/sample/computers.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
assetid,computertypeid,hostname,osid,vendorid,modelnumberid,loggedinuser
|
||||
SAMPLE-PC-01,Shopfloor PC,WKS-SAMPLE01,Windows 10 Enterprise,Dell,OptiPlex 7090,EXAMPLE\operator1
|
||||
SAMPLE-PC-02,CMM PC,WKS-SAMPLE02,Windows 11 Enterprise,Dell,Precision 3660,EXAMPLE\operator2
|
||||
|
3
scripts/site_imports/assetcollector/sample/machines.csv
Normal file
3
scripts/site_imports/assetcollector/sample/machines.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
assetid,machinetypeid,vendorid,modelnumberid,requiresmanualconfig,islocationonly
|
||||
SAMPLE-MC-01,CMM,Zeiss,CONTURA G2,0,0
|
||||
SAMPLE-MC-02,CNC,Haas Automation,VF-2SS,0,0
|
||||
|
5
scripts/site_imports/assetcollector/sample/models.csv
Normal file
5
scripts/site_imports/assetcollector/sample/models.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
modelnumber,vendorid
|
||||
OptiPlex 7090,Dell
|
||||
Precision 3660,Dell
|
||||
VF-2SS,Haas Automation
|
||||
CONTURA G2,Zeiss
|
||||
|
@@ -0,0 +1,3 @@
|
||||
osname
|
||||
Windows 10 Enterprise
|
||||
Windows 11 Enterprise
|
||||
|
4
scripts/site_imports/assetcollector/sample/vendors.csv
Normal file
4
scripts/site_imports/assetcollector/sample/vendors.csv
Normal file
@@ -0,0 +1,4 @@
|
||||
vendor
|
||||
Dell
|
||||
Haas Automation
|
||||
Zeiss
|
||||
|
Reference in New Issue
Block a user