Applied on top ofaf13337so nothing already on wilm is rewritten. AUTHORIZATION. The six new policy GETs were @jwt_required(optional=True) with no permission at all, so an anonymous caller could list configuration and business policies - gated only by a feature flag that 404s while off. They now require computers.view. The six policy write routes stacked @jwt_required() above @require_permission, the same pattern removed from nine endpoints routes in this very push: the outer decorator answers an anonymous request 401 before require_permission can consult the guest role, so a grant to guests silently does nothing. Removed, and the now-unused import with them. tests/test_core/test_public_read_surface.py failed ataf13337with seven ungated GETs. It passes now. The seventh, endpoints.claim_next_command, is genuinely agent-facing - authenticated in the handler by the endpoints collector key, because a bay has no user session - so it joins PUBLIC_ENDPOINTS with its reason written down, exactly as report_command_result already does in test_authz.py. Note which half is public: claiming a queued command is the agent's business, while ENQUEUEING one is the real authority and stays gated on endpoints.command. INSTALLER. -BaseUrl and -CollectorKey are now mandatory with no defaults, and https is enforced unless -AllowInsecureHttp is passed for a local test box. The defaults were a real hostname (ADR-015) and a live collector key: a key in source is a key in git history, and that key is also the command channel's kill switch. Removing it here does not undo the disclosure - it must be rotated on the server, and purged fromaf13337- but it stops the next clone carrying it. IMPORT. A committed import wrote thousands of rows and left no audit trail; it now writes one AuditLog row inside the same transaction, so it cannot survive a rollback or go missing from a commit. The computer-type dropdown asked for per_page: 200, which the API ignores in favour of perpage and clamps to 100 anyway - it uses a new computersApi.types.listAll(), the pattern already applied to the other selectors. And the comment claiming the template example rows are "emitted as commented rows the engine skips" was the opposite of the truth: they import as live data, the emitted instructions say so, and a test depends on it. Not fixed here, deliberately: rotating the leaked key, purging it from history, and the endpoint agent's own hardening (per-host credentials, a protected process denylist so stop cannot kill security tooling, signing before the PowerShell runner is enabled, registry ACLs, and the 25-second long poll that will pin a worker thread per bay).
967 lines
29 KiB
Vue
967 lines
29 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h1>Import Site Assets</h1>
|
|
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<p class="lead">
|
|
There are several ways to seed this facility's inventory - Machines, PCs,
|
|
Applications, and their properties - into ShopDB. You can combine them:
|
|
each source writes to the same set of importable CSVs, and a later run
|
|
fills gaps without wiping data an earlier run captured.
|
|
</p>
|
|
|
|
<div class="methods">
|
|
<div class="method is-primary">
|
|
<div class="method-head">
|
|
<span class="badge">Recommended</span>
|
|
<h3>Asset Collector PowerShell script</h3>
|
|
</div>
|
|
<p>
|
|
Deploy <code>Collect-ShopDB.ps1</code> to your existing endpoints in a
|
|
way of your choosing (logon script, scheduled task, remote execution,
|
|
or a manual run). It runs read-only, extracts each PC and any attached
|
|
eDNC/DNC machine, and extrapolates vendor, model, OS, network, and
|
|
installed-application details. Every run UPSERTs rows into the shared
|
|
CSV store below, so a site builds its inventory one PC at a time.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="method">
|
|
<div class="method-head">
|
|
<h3>Existing databases</h3>
|
|
</div>
|
|
<p>
|
|
Export from an existing MES, CMDB, or asset spreadsheet into the same
|
|
CSV schema and drop the files in the collection location.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="method">
|
|
<div class="method-head">
|
|
<h3>Intune / Azure exports</h3>
|
|
</div>
|
|
<p>
|
|
Map a device or application export (for example from Microsoft Graph)
|
|
onto the computers and applications CSVs, then place them alongside the
|
|
script output.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3 class="section-title">Asset Collector Script</h3>
|
|
<p class="muted">
|
|
Download <code>Collect-ShopDB.ps1</code> and deploy it to your endpoints
|
|
(logon script, scheduled task, remote execution, or a manual run). The CSV
|
|
Collections Location you save below is baked into the script's
|
|
<code>$NetworkStoreRoot</code> variable, so every endpoint publishes into
|
|
the same store without editing PowerShell by hand.
|
|
</p>
|
|
|
|
<div v-if="loading" class="muted">Loading...</div>
|
|
<template v-else>
|
|
<label class="field">
|
|
<span>CSV Collections Location</span>
|
|
<input
|
|
v-model="location"
|
|
type="text"
|
|
placeholder="\\server.fqdn\share\folder"
|
|
/>
|
|
<small class="muted">
|
|
UNC path or folder where the script deposits its CSVs (e.g.
|
|
\\server.fqdn\share\folder). Saved here and embedded into the script
|
|
download.
|
|
</small>
|
|
</label>
|
|
|
|
<div class="actions">
|
|
<button class="btn btn-primary" :disabled="saving" @click="save">
|
|
{{ saving ? 'Saving...' : 'Save Location' }}
|
|
</button>
|
|
<button class="btn btn-secondary" :disabled="downloadingScript" @click="downloadScript">
|
|
{{ downloadingScript ? 'Preparing...' : 'Download Script' }}
|
|
</button>
|
|
<span v-if="saved" class="muted">Saved.</span>
|
|
<span v-if="error" class="error">{{ error }}</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3 class="section-title">Import CSVs</h3>
|
|
<p class="muted">
|
|
Select one or more filled-in CSVs. They are ordered by dependency
|
|
automatically, so you can pick the whole set at once. Preview first: it
|
|
writes nothing and reports exactly what each file would create or update,
|
|
plus any rows that need fixing. Nothing is imported until you press
|
|
Commit, and the whole set is applied together - if any row has a problem,
|
|
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_<Site>_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>In the top command bar, 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"
|
|
type="file"
|
|
accept=".csv"
|
|
multiple
|
|
@change="onFilesPicked"
|
|
/>
|
|
<div class="actions">
|
|
<button
|
|
class="btn btn-secondary"
|
|
:disabled="!selectedFiles.length || previewing || committing"
|
|
@click="preview"
|
|
>
|
|
{{ previewing ? 'Checking...' : 'Preview (dry run)' }}
|
|
</button>
|
|
<button
|
|
class="btn btn-primary"
|
|
:disabled="!canCommit"
|
|
@click="commit"
|
|
>
|
|
{{ committing ? 'Importing...' : 'Commit Import' }}
|
|
</button>
|
|
<button
|
|
v-if="selectedFiles.length"
|
|
class="btn btn-secondary btn-sm"
|
|
:disabled="previewing || committing"
|
|
@click="clearSelection"
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="selectedFiles.length" class="muted">
|
|
{{ selectedFiles.length }} file(s) selected:
|
|
{{ selectedFiles.map(f => f.name).join(', ') }}
|
|
</p>
|
|
|
|
<p v-if="importError" class="error">{{ importError }}</p>
|
|
|
|
<div v-if="report" class="report">
|
|
<p v-if="report.request_error" class="error">
|
|
{{ report.request_error }}
|
|
</p>
|
|
|
|
<p
|
|
v-else
|
|
:class="['report-banner', report.committed ? 'ok' : (report.problem_count ? 'warn' : 'info')]"
|
|
>
|
|
<template v-if="report.committed">
|
|
Imported: {{ report.total_created }} created,
|
|
{{ report.total_updated }} updated.
|
|
</template>
|
|
<template v-else-if="report.problem_count">
|
|
{{ report.problem_count }} problem(s) found - nothing was written.
|
|
Fix the rows below and preview again.
|
|
</template>
|
|
<template v-else>
|
|
Looks good: {{ report.total_created }} would be created,
|
|
{{ report.total_updated }} updated. Press Commit Import to apply.
|
|
</template>
|
|
</p>
|
|
|
|
<p v-if="report.reconciled_count" class="report-banner reconcile">
|
|
{{ report.reconciled_count }} name(s) reconciled to an existing record
|
|
within the similarity threshold ({{ report.reconcile_threshold }}).
|
|
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>
|
|
<th>File</th>
|
|
<th>Table</th>
|
|
<th>Created</th>
|
|
<th>Updated</th>
|
|
<th>Reconciled</th>
|
|
<th>Warnings</th>
|
|
<th>Problems</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="f in report.files" :key="f.table">
|
|
<td><code>{{ f.filename }}</code></td>
|
|
<td>{{ f.table }}</td>
|
|
<td>{{ f.created }}</td>
|
|
<td>{{ f.updated }}</td>
|
|
<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>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<div v-if="allReconciliations.length" class="reconciliations">
|
|
<h4>Reconciled matches</h4>
|
|
<p class="muted">
|
|
These names did not match exactly but were close enough to bind to an
|
|
existing record. Confirm each is correct; if any is wrong, add the
|
|
exact name to the matching CSV (or lower/raise the reconciliation
|
|
threshold in Site settings) and preview again.
|
|
</p>
|
|
<ul>
|
|
<li v-for="(r, i) in allReconciliations" :key="i">
|
|
<code>{{ r.filename }}</code>
|
|
line {{ r.line }}<template v-if="r.column">, column
|
|
<code>{{ r.column }}</code></template>:
|
|
"{{ r.input }}" matched <strong>{{ r.matched }}</strong>
|
|
({{ r.table }}, {{ Math.round(r.score * 100) }}%)
|
|
</li>
|
|
</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>
|
|
<li v-for="(p, i) in allProblems" :key="i">
|
|
<code>{{ p.filename }}</code>
|
|
line {{ p.line }}<template v-if="p.column">, column
|
|
<code>{{ p.column }}</code></template>: {{ p.message }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<p v-if="report.skipped && report.skipped.length" class="muted warn">
|
|
Skipped (not importable): {{ report.skipped.join(', ') }}
|
|
</p>
|
|
</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. 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">
|
|
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>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { settingsApi, computersApi } from '@/api'
|
|
import { apiError } from '../../utils/apiError'
|
|
|
|
const LOCATION_KEY = 'asset_import_csv_location'
|
|
|
|
const location = ref('')
|
|
const loading = ref(true)
|
|
const saving = ref(false)
|
|
const saved = ref(false)
|
|
const error = ref('')
|
|
const downloadingScript = ref(false)
|
|
|
|
const templates = ref([])
|
|
const templatesLoading = ref(false)
|
|
const templatesError = ref('')
|
|
|
|
const fileInput = ref(null)
|
|
const selectedFiles = ref([])
|
|
const previewing = ref(false)
|
|
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.
|
|
const canCommit = computed(() => {
|
|
if (!report.value || committing.value || previewing.value) return false
|
|
if (report.value.committed) return false
|
|
if (report.value.request_error) return false
|
|
return report.value.problem_count === 0
|
|
})
|
|
|
|
const allProblems = computed(() => {
|
|
if (!report.value?.files) return []
|
|
const out = []
|
|
for (const file of report.value.files) {
|
|
for (const problem of file.problems) {
|
|
out.push({ filename: file.filename, ...problem })
|
|
}
|
|
}
|
|
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 = []
|
|
for (const file of report.value.files) {
|
|
for (const event of (file.reconciliations || [])) {
|
|
out.push({ filename: file.filename, ...event })
|
|
}
|
|
}
|
|
return out
|
|
})
|
|
|
|
async function loadLocation() {
|
|
loading.value = true
|
|
try {
|
|
const response = await settingsApi.get(LOCATION_KEY).catch(nullOn404)
|
|
location.value = readValue(response) || ''
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Could not load the CSV location.')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function nullOn404(err) {
|
|
if (err.response?.status === 404) return null
|
|
throw err
|
|
}
|
|
|
|
function readValue(response) {
|
|
if (!response) return ''
|
|
const setting = response.data?.data || response.data
|
|
return setting?.value ?? ''
|
|
}
|
|
|
|
async function save() {
|
|
saving.value = true
|
|
saved.value = false
|
|
error.value = ''
|
|
try {
|
|
await settingsApi.update(LOCATION_KEY, location.value)
|
|
saved.value = true
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Save failed.')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
function saveBlob(data, filename) {
|
|
const url = window.URL.createObjectURL(new Blob([data]))
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = filename
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
link.remove()
|
|
window.URL.revokeObjectURL(url)
|
|
}
|
|
|
|
async function downloadScript() {
|
|
downloadingScript.value = true
|
|
error.value = ''
|
|
try {
|
|
const response = await settingsApi.downloadCollectorScript()
|
|
saveBlob(response.data, 'Collect-ShopDB.ps1')
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Script download failed.')
|
|
} finally {
|
|
downloadingScript.value = false
|
|
}
|
|
}
|
|
|
|
async function loadTemplates() {
|
|
templatesLoading.value = true
|
|
templatesError.value = ''
|
|
try {
|
|
const response = await settingsApi.listImportTemplates()
|
|
const data = response.data?.data || response.data || {}
|
|
templates.value = data.tables || []
|
|
} catch (err) {
|
|
templatesError.value = apiError(err, 'Could not list import templates.')
|
|
} finally {
|
|
templatesLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function downloadTemplate(t) {
|
|
templatesError.value = ''
|
|
try {
|
|
const response = await settingsApi.downloadImportTemplate(t.table)
|
|
saveBlob(response.data, t.filename)
|
|
} catch (err) {
|
|
templatesError.value = apiError(err, 'Template download failed.')
|
|
}
|
|
}
|
|
|
|
function onFilesPicked(event) {
|
|
selectedFiles.value = Array.from(event.target.files || [])
|
|
// A new selection invalidates any previous preview so a stale clean report
|
|
// cannot be committed against different files.
|
|
report.value = null
|
|
importError.value = ''
|
|
}
|
|
|
|
function clearSelection() {
|
|
selectedFiles.value = []
|
|
report.value = null
|
|
importError.value = ''
|
|
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 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.')
|
|
report.value = null
|
|
}
|
|
}
|
|
|
|
async function preview() {
|
|
previewing.value = true
|
|
try {
|
|
await runImport(false)
|
|
} finally {
|
|
previewing.value = false
|
|
}
|
|
}
|
|
|
|
async function commit() {
|
|
committing.value = true
|
|
try {
|
|
await runImport(true)
|
|
} finally {
|
|
committing.value = false
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// listAll returns the rows themselves, already paged through.
|
|
computerTypes.value = await computersApi.types.listAll()
|
|
} catch {
|
|
computerTypes.value = []
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadLocation()
|
|
await loadTemplates()
|
|
await loadComputerTypes()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.lead {
|
|
margin-top: 0;
|
|
color: var(--text);
|
|
}
|
|
.methods {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
gap: 16px;
|
|
margin-top: 12px;
|
|
}
|
|
.method {
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 14px 16px;
|
|
background: var(--bg);
|
|
}
|
|
.method.is-primary {
|
|
border-color: var(--primary);
|
|
}
|
|
.method-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.method-head h3 {
|
|
margin: 0;
|
|
font-size: 1rem;
|
|
}
|
|
.method p {
|
|
margin: 8px 0 0;
|
|
color: var(--text-light);
|
|
font-size: 0.9rem;
|
|
}
|
|
.badge {
|
|
background: var(--primary);
|
|
color: #fff;
|
|
border-radius: 10px;
|
|
padding: 2px 8px;
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.section-title {
|
|
margin: 0 0 8px;
|
|
}
|
|
.field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
max-width: 640px;
|
|
margin-top: 12px;
|
|
}
|
|
.field > span {
|
|
font-weight: 600;
|
|
}
|
|
.field input[type='text'] {
|
|
padding: 8px 10px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
background: var(--bg);
|
|
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;
|
|
gap: 12px;
|
|
margin-top: 12px;
|
|
}
|
|
.templates {
|
|
margin: 8px 0 0;
|
|
padding-left: 20px;
|
|
}
|
|
.templates li {
|
|
margin: 6px 0;
|
|
}
|
|
.template-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
.template-row code {
|
|
min-width: 200px;
|
|
}
|
|
.btn-sm {
|
|
padding: 4px 10px;
|
|
font-size: 0.8rem;
|
|
}
|
|
.muted {
|
|
color: var(--text-light);
|
|
font-size: 0.9rem;
|
|
}
|
|
.muted.warn {
|
|
color: var(--warning, #c47f00);
|
|
}
|
|
.error {
|
|
color: var(--danger);
|
|
margin-top: 8px;
|
|
}
|
|
.import-controls {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 10px;
|
|
margin-top: 10px;
|
|
}
|
|
.report {
|
|
margin-top: 14px;
|
|
}
|
|
.report-banner {
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
font-weight: 600;
|
|
}
|
|
.report-banner.ok {
|
|
background: var(--success-bg, rgba(40, 160, 80, 0.15));
|
|
color: var(--success, #2e7d32);
|
|
}
|
|
.report-banner.info {
|
|
background: var(--info-bg, rgba(60, 120, 200, 0.15));
|
|
color: var(--text);
|
|
}
|
|
.report-banner.warn {
|
|
background: var(--warning-bg, rgba(196, 127, 0, 0.15));
|
|
color: var(--warning, #c47f00);
|
|
}
|
|
.report-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-top: 12px;
|
|
}
|
|
.report-table th,
|
|
.report-table td {
|
|
text-align: left;
|
|
padding: 6px 10px;
|
|
border-bottom: 1px solid var(--border);
|
|
font-size: 0.9rem;
|
|
}
|
|
.report-table td.has-problems {
|
|
color: var(--danger);
|
|
font-weight: 700;
|
|
}
|
|
.report-table td.has-reconcile {
|
|
color: var(--warning, #c47f00);
|
|
font-weight: 700;
|
|
}
|
|
.report-banner.reconcile {
|
|
background: var(--warning-bg, rgba(196, 127, 0, 0.15));
|
|
color: var(--warning, #c47f00);
|
|
}
|
|
.reconciliations {
|
|
margin-top: 12px;
|
|
}
|
|
.reconciliations h4 {
|
|
margin: 0 0 6px;
|
|
color: var(--warning, #c47f00);
|
|
}
|
|
.reconciliations ul {
|
|
margin: 6px 0 0;
|
|
padding-left: 20px;
|
|
}
|
|
.reconciliations li {
|
|
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;
|
|
}
|
|
.problems h4 {
|
|
margin: 0 0 6px;
|
|
color: var(--danger);
|
|
}
|
|
.problems ul {
|
|
margin: 0;
|
|
padding-left: 20px;
|
|
}
|
|
.problems li {
|
|
margin: 4px 0;
|
|
font-size: 0.9rem;
|
|
}
|
|
</style>
|