ADR-013 Phase 4: extract the 11 plugin routes embedded in core.js

core.js still routed plugin-owned pages directly. Extracted all 11 into the
owning plugin's route file + moved their views into plugins/<name>/frontend/:
- computers: reports/pc-relationships, settings/pctypemapping
- printers: reports/toner, settings/printertypes, settings/zabbix (toner/supply
  monitoring)
- machines: settings/machinetypes
- network: settings/networktypes
- warranty: settings/dellwarranty
- slides: settings/slides (its route file gains a default export; it was
  toplevel-only)
- employees: NEW plugin frontend (employees/:sso + settings/employeedirectory) -
  employees had no route file before; its pages lived only in core.js.

core.js now holds only core routes; all 14 bundled plugins are self-contained
under plugins/<name>/frontend/. Verified live: the extracted Machine Types
settings page renders in the settings rail from the machines plugin frontend.
Build + 58 vitest + naming green.
This commit is contained in:
cproudlock
2026-07-19 00:02:32 -04:00
parent ebca0b00b0
commit 592ff49abe
19 changed files with 93 additions and 84 deletions

View File

@@ -1,144 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>PC-Machine Relationships</h2>
<div class="header-actions">
<button class="btn btn-primary" @click="copyTable">Copy Table</button>
<button class="btn btn-secondary" @click="copyCSV">Copy CSV</button>
<button class="btn btn-secondary" @click="copyJSON">Copy JSON</button>
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
</div>
<p class="text-muted">PCs with relationships to shop floor machines</p>
<div v-if="copied" class="copy-toast">{{ copiedFormat }} copied to clipboard!</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table id="relationshipsTable">
<thead>
<tr>
<th>Machine #</th>
<th>Vendor</th>
<th>Model</th>
<th>PC Hostname</th>
<th>PC IP</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in data" :key="idx">
<td>{{ row.machine_number }}</td>
<td>{{ row.vendor }}</td>
<td>{{ row.model }}</td>
<td class="mono">{{ row.hostname }}</td>
<td class="mono">{{ row.ip }}</td>
</tr>
<tr v-if="data.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No relationships found
</td>
</tr>
</tbody>
</table>
</div>
<p class="record-count">{{ data.length }} records found</p>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { reportsApi } from '../../api'
const loading = ref(true)
const data = ref([])
const copied = ref(false)
const copiedFormat = ref('')
onMounted(async () => {
try {
const response = await reportsApi.pcRelationships()
data.value = response.data.data?.data || []
} catch (error) {
console.error('Error loading report:', error)
} finally {
loading.value = false
}
})
function showCopied(format) {
copiedFormat.value = format
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
function copyTable() {
const headers = ['Machine #', 'Vendor', 'Model', 'PC Hostname', 'PC IP']
let text = headers.join('\t') + '\n'
for (const row of data.value) {
text += [row.machine_number, row.vendor, row.model, row.hostname, row.ip].join('\t') + '\n'
}
navigator.clipboard.writeText(text).then(() => showCopied('Table'))
}
function copyCSV() {
const headers = ['Machine #', 'Vendor', 'Model', 'PC Hostname', 'PC IP']
let csv = headers.map(h => `"${h}"`).join(',') + '\n'
for (const row of data.value) {
csv += [row.machine_number, row.vendor, row.model, row.hostname, row.ip]
.map(v => `"${v}"`)
.join(',') + '\n'
}
navigator.clipboard.writeText(csv).then(() => showCopied('CSV'))
}
function copyJSON() {
const jsonData = data.value.map(row => ({
Name: row.hostname,
IpAddress: row.ip,
Group: null
}))
navigator.clipboard.writeText(JSON.stringify(jsonData, null, 2)).then(() => showCopied('JSON'))
}
</script>
<style scoped>
.text-muted {
color: var(--text-light);
margin-bottom: 1rem;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.record-count {
color: var(--text-light);
margin-top: 1rem;
font-size: 0.875rem;
}
.copy-toast {
position: fixed;
top: 20px;
right: 20px;
background: #28a745;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 6px;
font-weight: 500;
z-index: 9999;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -1,304 +0,0 @@
<template>
<div>
<div class="page-header">
<h1>Toner Report</h1>
<div class="header-actions">
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
:columns="emailColumns" :rows="emailRows" />
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading supply data...</div>
<div v-else-if="error" class="card">
<p class="text-danger">{{ error }}</p>
</div>
<template v-else>
<!-- Summary Bar -->
<div class="summary-bar">
<div class="summary-stat card">
<div class="stat-value">{{ summary.total_checked }}</div>
<div class="stat-label">Printers Checked</div>
</div>
<div class="summary-stat card stat-low">
<div class="stat-value">{{ summary.low }}</div>
<div class="stat-label">Low Supply</div>
</div>
<div class="summary-stat card stat-critical">
<div class="stat-value">{{ summary.critical }}</div>
<div class="stat-label">Critical Supply</div>
</div>
</div>
<!-- Filter Buttons -->
<div class="filters">
<button
v-for="f in filterOptions"
:key="f.value"
class="btn"
:class="filter === f.value ? 'btn-primary' : 'btn-secondary'"
@click="filter = f.value"
>
{{ f.label }}
</button>
</div>
<!-- Printers Table -->
<div class="card">
<div class="table-container">
<table v-if="filteredPrinters.length">
<thead>
<tr>
<th>Printer Name</th>
<th>Asset #</th>
<th>Location</th>
<th>IP Address</th>
<th>Supplies</th>
</tr>
</thead>
<tbody>
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
<td>
<router-link :to="`/printers/${printer.printerid}`">
{{ printer.printername || 'Unknown' }}
</router-link>
</td>
<td>{{ printer.assetnumber }}</td>
<td>{{ printer.location || '-' }}</td>
<td>{{ printer.ipaddress }}</td>
<td class="supplies-cell">
<div
v-for="(supply, idx) in printer.supplies"
:key="idx"
class="supply-row"
>
<span class="supply-name">{{ supply.name }}</span>
<div class="supply-bar-track">
<div
class="supply-bar-fill"
:class="'supply-' + supply.status"
:style="{ width: Math.max(supply.level, 2) + '%' }"
></div>
</div>
<span class="supply-level" :class="'supply-text-' + supply.status">
{{ supply.level }}%
</span>
</div>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty-state">No printers match the selected filter.</p>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { printersApi } from '@/api'
import EmailReportButton from '../../components/EmailReportButton.vue'
const emailColumns = [
{ key: 'printer', label: 'Printer' },
{ key: 'assetnumber', label: 'Asset #' },
{ key: 'location', label: 'Location' },
{ key: 'ipaddress', label: 'IP Address' },
{ key: 'supply', label: 'Supply' },
{ key: 'level', label: 'Level' },
{ key: 'status', label: 'Status' },
]
const loading = ref(true)
const error = ref(null)
const printers = ref([])
const summary = ref({ total_checked: 0, low: 0, critical: 0 })
const filter = ref('all')
const filterOptions = [
{ label: 'All', value: 'all' },
{ label: 'Critical', value: 'critical' },
{ label: 'Low', value: 'low' }
]
const filteredPrinters = computed(() => {
if (filter.value === 'all') return printers.value
return printers.value.filter(p =>
p.supplies.some(s => s.status === filter.value)
)
})
// One row per supply, honoring the active filter, for the emailed table.
const emailRows = computed(() => {
const rows = []
for (const printer of filteredPrinters.value) {
for (const supply of printer.supplies || []) {
rows.push({
printer: printer.printername || '',
assetnumber: printer.assetnumber || '',
location: printer.location || '',
ipaddress: printer.ipaddress || '',
supply: supply.name || '',
level: supply.level + '%',
status: supply.status || '',
})
}
}
return rows
})
function exportCSV() {
// one row per supply, honoring the active filter
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
const rows = [['printer', 'assetnumber', 'location', 'ipaddress', 'supply', 'level', 'status']]
for (const printer of filteredPrinters.value) {
for (const supply of printer.supplies || []) {
rows.push([
printer.printername || '', printer.assetnumber || '', printer.location || '',
printer.ipaddress || '', supply.name || '', supply.level, supply.status || ''
])
}
}
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
const link = document.createElement('a')
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
link.download = 'toner_report.csv'
link.click()
URL.revokeObjectURL(link.href)
}
onMounted(async () => {
try {
const response = await printersApi.lowSupplies()
const data = response.data.data
printers.value = data.printers || []
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
} catch (err) {
console.error('Error loading toner report:', err)
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
} finally {
loading.value = false
}
})
</script>
<style scoped>
.summary-bar {
display: flex;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.summary-stat {
flex: 1;
text-align: center;
padding: 1.25rem;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--text);
}
.stat-label {
color: var(--text-light);
margin-top: 0.25rem;
}
.stat-low .stat-value {
color: var(--warning);
}
.stat-critical .stat-value {
color: var(--danger);
}
.supplies-cell {
min-width: 280px;
}
.supply-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.supply-row:last-child {
margin-bottom: 0;
}
.supply-name {
flex: 0 0 120px;
font-size: 0.85rem;
color: var(--text-light);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.supply-bar-track {
flex: 1;
height: 8px;
background: var(--border);
border-radius: 4px;
overflow: hidden;
}
.supply-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.supply-ok {
background: var(--success);
}
.supply-low {
background: var(--warning);
}
.supply-critical {
background: var(--danger);
}
.supply-level {
flex: 0 0 40px;
text-align: right;
font-size: 0.85rem;
font-weight: 600;
}
.supply-text-ok {
color: var(--success);
}
.supply-text-low {
color: var(--warning);
}
.supply-text-critical {
color: var(--danger);
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-light);
}
.text-danger {
color: var(--danger);
}
.header-actions {
display: flex;
gap: 0.5rem;
}
</style>