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:
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "shopdb-frontend",
|
||||
"version": "0.7.0",
|
||||
"version": "0.12.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shopdb-frontend",
|
||||
"version": "0.7.0",
|
||||
"version": "0.12.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
|
||||
@@ -1027,14 +1027,26 @@ export const settingsApi = {
|
||||
// Generated CSV template (header + example row) for one table
|
||||
return api.get(`/settings/asset-import/templates/${encodeURIComponent(tablename)}`, { responseType: 'blob' })
|
||||
},
|
||||
importAssetCsvs(files, commit = false) {
|
||||
importAssetCsvs(files, commit = false, options = {}) {
|
||||
// Multipart upload of one or more CSVs. commit=false is a dry-run preview
|
||||
// that writes nothing; commit=true applies the whole set in one transaction.
|
||||
// options.source picks a foreign adapter (azure, thor); default native means
|
||||
// the files are already canonical per-table CSVs. options.computertype is
|
||||
// stamped onto every imported computer when a source assigns a PC type.
|
||||
const form = new FormData()
|
||||
for (const file of files) {
|
||||
form.append('files', file)
|
||||
}
|
||||
form.append('commit', commit ? 'true' : 'false')
|
||||
if (options.source) {
|
||||
form.append('source', options.source)
|
||||
}
|
||||
if (options.computertype) {
|
||||
form.append('computertype', options.computertype)
|
||||
}
|
||||
if (options.category) {
|
||||
form.append('category', options.category)
|
||||
}
|
||||
return api.post('/settings/asset-import/import', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
|
||||
@@ -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->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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user