add Entra device identity to computers and Azure/Thor import sources

PC records now carry Entra/Azure device identity so imported machines keep a durable link back to their directory object instead of matching on hostname alone. Adds azuredeviceid, azureobjectid, and azuredevicecategory to the Computer model with matching migrations, surfaces them read-only in PCDetail as an Entra Device Info card, and lets PCForm edit the device id directly. The computers plugin registers a machineassetnumber import hook that reconciles PC-to-machine links during bulk import and warns on unresolved or conflicting relationships.

Adds importsources adapters that normalize Azure device-group and Thor PC inventory exports into canonical ShopDB CSVs via hostname alignment, exposes register_import_hook and RowWarning on the shopdb.api contract surface (contract 0.21.0), and refreshes the Import Site Assets page with clearer source instructions. Includes reconcile_locations maintenance script and coverage for the new import paths.
This commit is contained in:
benjamin.baker
2026-08-25 16:46:00 -04:00
parent f4d8868463
commit bdef21da50
21 changed files with 2064 additions and 86 deletions

View File

@@ -91,32 +91,6 @@
</template>
</div>
<div class="card">
<h3 class="section-title">CSV Import Templates</h3>
<p class="muted">
These are early-draft import templates generated from the live ShopDB
schema. Each file has a header row and a commented example. Fill one out
(or let the collector script populate them) and import them in the order
below - a table never references something that has not been loaded yet.
Foreign keys accept the referenced row's name or its numeric id, and
re-importing a file updates matching rows instead of duplicating them.
</p>
<p v-if="templatesLoading" class="muted">Loading...</p>
<ol v-else class="templates">
<li v-for="t in templates" :key="t.table">
<div class="template-row">
<code>{{ t.filename }}</code>
<button class="btn btn-secondary btn-sm" @click="downloadTemplate(t)">
Download Template
</button>
</div>
</li>
</ol>
<p v-if="templatesError" class="error">{{ templatesError }}</p>
</div>
<div class="card">
<h3 class="section-title">Import CSVs</h3>
<p class="muted">
@@ -128,6 +102,108 @@
nothing is written.
</p>
<label class="field">
<span>Source format</span>
<div class="source-cards">
<button
v-for="s in IMPORT_SOURCES"
:key="s.key"
type="button"
:class="['source-card', { active: importSource === s.key }]"
@click="selectSource(s.key)"
>
<span class="source-card-title">{{ s.label }}</span>
<span class="source-card-desc">{{ s.description }}</span>
</button>
</div>
<small class="muted">
ShopDB native expects the canonical per-table CSVs below. Azure and
Thor accept those tools' own exports and convert them for you.
</small>
</label>
<div v-if="importSource === 'azure'" class="source-help">
<p class="muted">
An Azure device-group export lists device hostnames plus their Entra
deviceId. The device displayName is matched to an existing PC by
hostname (case-insensitive): a match updates that PC in place and
stores its deviceId, object id, and category; a device with no
matching hostname creates a new computer (assettype computer, status
In Use). It carries no model, OS, location, or user data - enrich
those later with the collector or a Thor import.
</p>
<label class="field">
<span>Assign computer type (optional)</span>
<select v-model="azureComputerType">
<option value="">No type</option>
<option v-for="t in computerTypes" :key="t.computertypeid" :value="t.computertype">
{{ t.computertype }}
</option>
<option value="__custom__">Custom (add a new type)...</option>
</select>
<small class="muted">
Stamped onto every device in this file. For example import the SHP
group with "Shopfloor PC" and the BUS group with "Inspection PC".
Picking from the list prevents typos labelling hundreds of machines.
</small>
</label>
<label v-if="azureComputerType === '__custom__'" class="field">
<span>New computer type name</span>
<div class="custom-type-row">
<input
v-model="customComputerType"
type="text"
placeholder="e.g. Inspection PC"
/>
<button
class="btn btn-secondary btn-sm"
:disabled="!customComputerType.trim() || addingType"
@click="addComputerType"
>
{{ addingType ? 'Adding...' : 'Add type' }}
</button>
</div>
<small v-if="customTypeError" class="error">{{ customTypeError }}</small>
<small v-else class="muted">
Adds the type to your settings now and selects it for this import.
</small>
</label>
<label class="field">
<span>Azure device category (optional)</span>
<input
v-model="azureDeviceCategory"
type="text"
placeholder="e.g. SFLD_DYN_Device_Group_Wilmington_BUS_NC_COUNTRY:USA"
/>
<small class="muted">
The Entra group/category these devices belong to. Stored on every
imported device and shown under Entra Device Info on the PC card.
</small>
</label>
<details class="instructions">
<summary>How to export group members from Azure</summary>
<ol>
<li>Open the Microsoft Entra admin center (entra.microsoft.com).</li>
<li>Go to Groups, then open the device group you want to import.</li>
<li>Select Members in the left menu.</li>
<li>Click Bulk Operations-&gt;Download Members to export the CSV.</li>
<li>Upload that CSV here with the source set to Azure device group.</li>
</ol>
</details>
</div>
<div v-else-if="importSource === 'thor'" class="source-help">
<p class="muted">
A Thor PC-inventory export is a wide, fixed-format dump. This maps the
hostname, BIOS manufacturer, model, OS, logged-in user, last-reported
and boot times, and location into ShopDB. Each host is matched to an
existing PC by hostname (case-insensitive): a match updates that PC in
place; a host with no matching hostname creates a new computer.
Network details (MAC, switch, VLAN) and the machine relationship are
not imported in this pass.
</p>
</div>
<div class="import-controls">
<input
ref="fileInput"
@@ -198,6 +274,12 @@
Review the matches below before committing.
</p>
<p v-if="report.warning_count" class="report-banner amber">
{{ report.warning_count }} warning(s): these rows still import, but
something needs a look - most often a machine link that could not be
made. Review them below; each says what to do.
</p>
<table v-if="report.files && report.files.length" class="report-table">
<thead>
<tr>
@@ -206,6 +288,7 @@
<th>Created</th>
<th>Updated</th>
<th>Reconciled</th>
<th>Warnings</th>
<th>Problems</th>
</tr>
</thead>
@@ -218,6 +301,9 @@
<td :class="{ 'has-reconcile': (f.reconciliations || []).length }">
{{ (f.reconciliations || []).length }}
</td>
<td :class="{ 'has-warnings': (f.warnings || []).length }">
{{ (f.warnings || []).length }}
</td>
<td :class="{ 'has-problems': f.problems.length }">
{{ f.problems.length }}
</td>
@@ -244,6 +330,23 @@
</ul>
</div>
<div v-if="allWarnings.length" class="warnings">
<h4>Warnings</h4>
<p class="muted">
Each row below imported, but something could not be completed - most
often a PC's machine link. Nothing here blocks the import; fix what
you can and re-import to complete the links.
</p>
<ul>
<li v-for="(w, i) in allWarnings" :key="i">
<code>{{ w.filename }}</code>
line {{ w.line }}<template v-if="w.column">, column
<code>{{ w.column }}</code></template>:
{{ w.reason }}<template v-if="w.action"> - <em>{{ w.action }}</em></template>
</li>
</ul>
</div>
<div v-if="allProblems.length" class="problems">
<h4>Problems to fix</h4>
<ul>
@@ -261,23 +364,57 @@
</div>
</div>
<div class="card">
<h3 class="section-title">CSV Import Templates</h3>
<p class="muted">
These import templates are generated from the live ShopDB schema. Each
file has a header row plus a small worked example - commented rows you
can uncomment and edit. The examples are internally consistent across
every file (the same locations, vendors, and types are referenced), so
you can download the full set, keep it as a starter workbook, and fill
in your own assets right away. Import them in the order below - a table
never references something that has not been loaded yet. Foreign keys
accept the referenced row's name or its numeric id, and re-importing a
file updates matching rows instead of duplicating them.
</p>
<p v-if="templatesLoading" class="muted">Loading...</p>
<ol v-else class="templates">
<li v-for="t in templates" :key="t.table">
<div class="template-row">
<code>{{ t.filename }}</code>
<button class="btn btn-secondary btn-sm" @click="downloadTemplate(t)">
Download Template
</button>
</div>
</li>
</ol>
<p v-if="templatesError" class="error">{{ templatesError }}</p>
</div>
<div class="card">
<h3 class="section-title">PC to Machine Relationships</h3>
<p class="muted">
A PC that drives a machine (a CMM PC, a DNC/eDNC host) is linked to that
machine so the two show up connected across ShopDB. That link is built
automatically by the Asset Collector, not by this CSV import: the script
reads the four-digit workstation number from the PC it runs on and
reports it, and ShopDB matches that number to a machine's
<code>assetnumber</code> to create the connection.
machine so the two show up connected across ShopDB. You can build that
link two ways. The Asset Collector builds it automatically: the script
reads the four-digit workstation number from the PC it runs on, reports
it, and ShopDB matches that number to a machine's
<code>assetnumber</code>.
</p>
<p class="muted">
What this means for import order: load your machines first (so their
<code>assetnumber</code> values exist), then let the collector run on
each PC. A machine number the collector reports that does not match an
existing machine is flagged rather than guessed, so nothing links to the
wrong asset. Building this relationship straight from a CSV column is
planned as a follow-up.
Or build it straight from the spreadsheet: put the machine's
<code>assetnumber</code> in the <code>machineassetnumber</code> column on
<code>computers.csv</code>, and the import creates the link for you.
</p>
<p class="muted">
Either way, load your machines first (so their
<code>assetnumber</code> values exist), then the PCs. A machine number
that does not match an existing machine - or one already driven by
another PC - is reported as a warning and skipped, not guessed, so
nothing links to the wrong asset. The PC still imports; fix the number
and re-import to complete the link.
</p>
</div>
</div>
@@ -285,7 +422,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { settingsApi } from '@/api'
import { settingsApi, computersApi } from '@/api'
import { apiError } from '../../utils/apiError'
const LOCATION_KEY = 'asset_import_csv_location'
@@ -308,6 +445,24 @@ const committing = ref(false)
const importError = ref('')
const report = ref(null)
// Which format the selected files are in. 'native' means the uploads are
// already canonical per-table CSVs; 'azure' and 'thor' route through a
// server-side adapter that transforms the foreign export first.
const IMPORT_SOURCES = [
{ key: 'native', label: 'ShopDB native', description: 'The canonical per-table CSVs below (assets, computers, vendors, ...).' },
{ key: 'azure', label: 'Azure / Intune device group', description: 'A device-group member export from Entra. Creates PCs by hostname and stores the deviceId.' },
{ key: 'thor', label: 'Thor PC inventory', description: 'A Thor inventory export. Maps hostname, vendor, model, OS, user, and location.' }
]
const importSource = ref('native')
// Optional computer type stamped onto every computer an Azure import creates or
// updates, so a SHP group becomes Shopfloor PCs and a BUS group Inspection PCs.
const azureComputerType = ref('')
const customComputerType = ref('')
const customTypeError = ref('')
const addingType = ref(false)
const computerTypes = ref([])
const azureDeviceCategory = ref('')
// Commit is allowed only after a clean preview of the CURRENT selection: a
// report with no problems and no request-level error that has not already been
// committed.
@@ -329,6 +484,17 @@ const allProblems = computed(() => {
return out
})
const allWarnings = computed(() => {
if (!report.value?.files) return []
const out = []
for (const file of report.value.files) {
for (const warning of (file.warnings || [])) {
out.push({ filename: file.filename, ...warning })
}
}
return out
})
const allReconciliations = computed(() => {
if (!report.value?.files) return []
const out = []
@@ -440,10 +606,26 @@ function clearSelection() {
if (fileInput.value) fileInput.value.value = ''
}
function selectSource(key) {
importSource.value = key
// Switching format invalidates a preview built against the previous adapter.
report.value = null
importError.value = ''
}
async function runImport(commit) {
importError.value = ''
try {
const response = await settingsApi.importAssetCsvs(selectedFiles.value, commit)
const options = { source: importSource.value }
if (importSource.value === 'azure') {
if (azureComputerType.value && azureComputerType.value !== '__custom__') {
options.computertype = azureComputerType.value.trim()
}
if (azureDeviceCategory.value.trim()) {
options.category = azureDeviceCategory.value.trim()
}
}
const response = await settingsApi.importAssetCsvs(selectedFiles.value, commit, options)
report.value = response.data?.data || response.data || null
} catch (err) {
importError.value = apiError(err, commit ? 'Import failed.' : 'Preview failed.')
@@ -469,9 +651,38 @@ async function commit() {
}
}
async function addComputerType() {
const name = customComputerType.value.trim()
if (!name) return
customTypeError.value = ''
addingType.value = true
try {
const response = await computersApi.types.create({ computertype: name })
const created = response.data?.data || response.data
await loadComputerTypes()
// Select the freshly added type by its name.
azureComputerType.value = created?.computertype || name
customComputerType.value = ''
} catch (err) {
customTypeError.value = apiError(err, 'Could not add the computer type.')
} finally {
addingType.value = false
}
}
async function loadComputerTypes() {
try {
const response = await computersApi.types.list({ per_page: 200 })
computerTypes.value = response.data?.data || response.data || []
} catch {
computerTypes.value = []
}
}
onMounted(async () => {
await loadLocation()
await loadTemplates()
await loadComputerTypes()
})
</script>
@@ -540,6 +751,75 @@ onMounted(async () => {
color: var(--text);
font-family: var(--font-mono, monospace);
}
.field select {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.custom-type-row {
display: flex;
gap: 8px;
align-items: center;
}
.custom-type-row input {
flex: 1;
}
.source-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
margin: 4px 0;
}
.source-card {
display: flex;
flex-direction: column;
gap: 4px;
text-align: left;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
cursor: pointer;
}
.source-card:hover {
border-color: var(--primary);
}
.source-card.active {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary) inset;
}
.source-card-title {
font-weight: 600;
}
.source-card-desc {
font-size: 0.85rem;
color: var(--text-light);
}
.source-help {
margin-top: 12px;
border-left: 3px solid var(--border);
padding-left: 12px;
}
.source-help .instructions {
margin-top: 8px;
}
.source-help .instructions summary {
cursor: pointer;
font-weight: 600;
color: var(--text);
}
.source-help .instructions ol {
margin: 8px 0 0;
padding-left: 20px;
color: var(--text-light);
font-size: 0.9rem;
}
.source-help .instructions li {
margin: 4px 0;
}
.actions {
display: flex;
align-items: center;
@@ -641,6 +921,33 @@ onMounted(async () => {
margin: 4px 0;
font-size: 0.9rem;
}
.report-table td.has-warnings {
color: var(--amber, #d18b16);
font-weight: 700;
}
.report-banner.amber {
background: var(--amber-bg, rgba(209, 139, 22, 0.15));
color: var(--amber, #d18b16);
}
.warnings {
margin-top: 12px;
}
.warnings h4 {
margin: 0 0 6px;
color: var(--amber, #d18b16);
}
.warnings ul {
margin: 6px 0 0;
padding-left: 20px;
}
.warnings li {
margin: 4px 0;
font-size: 0.9rem;
}
.warnings li em {
color: var(--text-muted, #9aa4b2);
font-style: normal;
}
.problems {
margin-top: 12px;
}