Retire the legacy Machine model (ADR-001 cutover)

The asset/computer model is now the single source of truth. Remove the Machine
instance layer end to end:

- Delete models Machine, MachineStatus, PCType, MachineRelationship,
  InstalledApp, PrinterData; keep MachineType (models.machinetypeid still
  references it).
- Delete the /api/machines, /api/statuses, /api/pctypes blueprints and the
  legacy /api/printers/legacy (PrinterData) blueprint.
- Drop the deprecated communications.machineid column and its FK.
- Migration 7c01 drops tables machines, machinestatuses, pctypes,
  machinerelationships, installedapps, printerdata (idempotent).
- Fix remaining readers (applications install counts) to ComputerInstalledApp.
- Frontend: remove dead machinesApi/statusesApi/pctypesApi wrappers; repoint
  the PC Types settings page at computer types.

143 tests pass; all asset/computer/dashboard/report/collector endpoints 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 09:59:17 -04:00
parent cb47b9087e
commit b0a0670fcd
23 changed files with 344 additions and 2047 deletions

View File

@@ -66,10 +66,10 @@
<option value="">Select type...</option>
<option
v-for="pt in pcTypes"
:key="pt.machinetypeid"
:value="pt.machinetypeid"
:key="pt.computertypeid"
:value="pt.computertypeid"
>
{{ pt.machinetype }}
{{ pt.computertype }}
</option>
</select>
</div>
@@ -268,7 +268,7 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { machinesApi, machinetypesApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api'
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme'
@@ -316,17 +316,10 @@ const operatingsystems = ref([])
// Filter models by selected vendor and PC type
const filteredModels = computed(() => {
return models.value.filter(m => {
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
return false
}
// only exclude models that have a type set and it differs; most models
// have no machinetypeid, so a strict check hides the PC's own model
if (form.value.machinetypeid && m.machinetypeid && m.machinetypeid !== form.value.machinetypeid) {
return false
}
return true
})
// filter by vendor only (PC type now maps to computertypeid, a different id
// space than a model's machinetypeid)
if (!form.value.vendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.vendorid)
})
onMounted(async () => {
@@ -334,7 +327,7 @@ onMounted(async () => {
// Load reference data
// perpage 100 so dropdowns aren't truncated to the default 20-row page
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes] = await Promise.all([
machinetypesApi.list({ category: 'PC', perpage: 100 }),
computersApi.types.list({ perpage: 100 }),
assetsApi.statuses.list(),
vendorsApi.list({ perpage: 100 }),
modelsApi.listAll(), // backend caps perpage at 100; page through all
@@ -349,28 +342,28 @@ onMounted(async () => {
locations.value = locRes.data.data || []
operatingsystems.value = osRes.data.data || []
// Load PC if editing
// Load PC if editing (asset-based shape: extension under pc.computer)
if (isEdit.value) {
const response = await machinesApi.get(route.params.id)
const response = await computersApi.get(route.params.id)
const pc = response.data.data
const ext = pc.computer || {}
// Get IP from communications
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
form.value = {
machinenumber: pc.machinenumber || '',
alias: pc.alias || '',
hostname: pc.hostname || '',
machinenumber: pc.assetnumber || '',
alias: pc.name && pc.name.toUpperCase() !== 'NONE' ? pc.name : '',
hostname: ext.hostname || '',
serialnumber: pc.serialnumber || '',
machinetypeid: pc.machinetype?.machinetypeid || '',
statusid: pc.status?.statusid || '',
vendorid: pc.vendor?.vendorid || '',
modelnumberid: pc.model?.modelnumberid || '',
locationid: pc.location?.locationid || '',
osid: pc.operatingsystem?.osid || '',
loggedinuser: pc.loggedinuser || '',
isvnc: pc.isvnc || false,
iswinrm: pc.iswinrm || false,
machinetypeid: ext.computertypeid || '',
statusid: pc.statusid || '',
vendorid: ext.vendorid || '',
modelnumberid: ext.modelnumberid || '',
locationid: pc.locationid || '',
osid: ext.osid || '',
loggedinuser: ext.loggedinuser || '',
isvnc: ext.isvnc || false,
iswinrm: ext.iswinrm || false,
notes: pc.notes || '',
mapx: pc.mapx ?? null,
mapy: pc.mapy ?? null,
@@ -408,40 +401,35 @@ async function savePC() {
saving.value = true
try {
const machineData = {
machinenumber: form.value.machinenumber,
alias: form.value.alias,
hostname: form.value.hostname,
serialnumber: form.value.serialnumber,
machinetypeid: form.value.machinetypeid || null,
// One payload for the computers plugin (asset core + computer extension +
// primary IP). "PC Number" is the business identifier (assetnumber).
const payload = {
assetnumber: form.value.machinenumber,
hostname: form.value.hostname || null,
serialnumber: form.value.serialnumber || null,
computertypeid: form.value.machinetypeid || null,
statusid: form.value.statusid || null,
vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null,
locationid: form.value.locationid || null,
osid: form.value.osid || null,
loggedinuser: form.value.loggedinuser,
loggedinuser: form.value.loggedinuser || null,
isvnc: form.value.isvnc,
iswinrm: form.value.iswinrm,
notes: form.value.notes,
notes: form.value.notes || null,
ipaddress: form.value.ipaddress || null,
mapx: form.value.mapx,
mapy: form.value.mapy
}
let machineId
if (isEdit.value) {
await machinesApi.update(route.params.id, machineData)
machineId = route.params.id
} else {
const response = await machinesApi.create(machineData)
machineId = response.data.data.machineid
// only set display name when an alias is given, so we don't clobber it
if (form.value.alias) {
payload.name = form.value.alias
}
// Handle IP address - update communication record
if (form.value.ipaddress) {
await machinesApi.updateCommunication(machineId, {
ipaddress: form.value.ipaddress,
isprimary: true
})
if (isEdit.value) {
await computersApi.update(route.params.id, payload)
} else {
await computersApi.create(payload)
}
router.push('/pcs')

View File

@@ -1,177 +1,126 @@
<template>
<div>
<div class="page-header">
<h2>PC Types</h2>
<button class="btn btn-primary" @click="openModal()">+ Add PC Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>PC Type</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="pt in pcTypes" :key="pt.pctypeid">
<td>{{ pt.pctype }}</td>
<td class="cell-truncate" :title="pt.description">{{ pt.description || '-' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(pt)">Delete</button>
</td>
</tr>
<tr v-if="pcTypes.length === 0">
<td colspan="3" style="text-align: center; color: var(--text-light);">
No PC types found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editing ? 'Edit PC Type' : 'Add PC Type' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="pctype">PC Type *</label>
<input id="pctype" v-model="form.pctype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header"><h3>Delete PC Type</h3></div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ toDelete?.pctype }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteItem">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { pctypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const pcTypes = ref([])
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const toDelete = ref(null)
const form = ref({ pctype: '', description: '' })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await pctypesApi.list({ page: page.value, perpage: perPage.value })
pcTypes.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading PC types:', err)
} finally {
loading.value = false
}
}
function goToPage(p) { page.value = p; loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadData()
}
function openModal(item = null) {
editing.value = item
form.value = item ? { pctype: item.pctype || '', description: item.description || '' } : { pctype: '', description: '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await pctypesApi.update(editing.value.pctypeid, form.value)
} else {
await pctypesApi.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
async function deleteItem() {
try {
await pctypesApi.delete(toDelete.value.pctypeid)
showDeleteModal.value = false
toDelete.value = null
loadData()
} catch (err) {
alert('Failed to delete')
}
}
</script>
<template>
<div>
<div class="page-header">
<h2>PC Types</h2>
<button class="btn btn-primary" @click="openModal()">+ Add PC Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>PC Type</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="pt in pcTypes" :key="pt.computertypeid">
<td>{{ pt.computertype }}</td>
<td class="cell-truncate" :title="pt.description">{{ pt.description || '-' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
</td>
</tr>
<tr v-if="pcTypes.length === 0">
<td colspan="3" style="text-align: center; color: var(--text-light);">
No PC types found
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editing ? 'Edit PC Type' : 'Add PC Type' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="computertype">PC Type *</label>
<input id="computertype" v-model="form.computertype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { computersApi } from '../../api'
const pcTypes = ref([])
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ computertype: '', description: '' })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await computersApi.types.list({ perpage: 100 })
pcTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading PC types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { computertype: item.computertype || '', description: item.description || '' }
: { computertype: '', description: '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await computersApi.types.update(editing.value.computertypeid, form.value)
} else {
await computersApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
</script>