ADR-013 Phase 4: relocate 4 self-contained plugin frontends

Relocate applications, geenforce, knowledgebase, and machines - each owns only
its own views dir, so a clean move to plugins/<name>/frontend/ (views/ +
routes.js, core imports rewritten to @/). geenforce's entryForm.js helper + its
vitest spec move with it (ManifestEditor imports it as a sibling).

Machinery fixes this batch surfaced:
- routes.gen.js codegen uses namespace imports (import * as p_x). A route file
  without a `toplevel` export is undefined on the namespace instead of a strict-
  ESM missing-binding build error.
- vitest gains a `pretest` stage so plugin-frontend specs (now under
  plugins/<name>/frontend/) run from their staged copy in src/.plugins-staged/.

Verified live: GE-Enforce (the most complex, uses the entryForm sibling helper)
renders fully from its staged frontend. Build + 58 vitest + naming green.
This commit is contained in:
cproudlock
2026-07-18 23:51:23 -04:00
parent af9a3b190b
commit 23dc9fa379
21 changed files with 52 additions and 49 deletions

View File

@@ -10,6 +10,7 @@
"prebuild": "npm run stage",
"build": "vite build",
"preview": "vite preview",
"pretest": "npm run stage",
"test": "vitest run",
"test:watch": "vitest"
},

View File

@@ -1,27 +0,0 @@
/**
* Applications routes (core feature)
*/
export default [
{
path: 'applications',
name: 'applications',
component: () => import('../../views/applications/ApplicationsList.vue')
},
{
path: 'applications/new',
name: 'application-new',
component: () => import('../../views/applications/ApplicationForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'applications/:id',
name: 'application-detail',
component: () => import('../../views/applications/ApplicationDetail.vue')
},
{
path: 'applications/:id/edit',
name: 'application-edit',
component: () => import('../../views/applications/ApplicationForm.vue'),
meta: { requiresAuth: true }
}
]

View File

@@ -1,30 +0,0 @@
/**
* GE-Enforce plugin routes.
*
* A top-level section (not under /settings) - the manifest editor + fleet
* reports are a large operational surface, so they get their own full-width
* shell with tabs. meta.plugin = 'geenforce' so the ADR-009 guard hides the
* section when the plugin is disabled. Admin-only.
*/
export default [
{
path: 'geenforce',
component: () => import('../../views/geenforce/GeEnforceLayout.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' },
children: [
{ path: '', redirect: '/geenforce/manifests' },
{
path: 'manifests',
name: 'geenforce-manifests',
component: () => import('../../views/geenforce/ManifestEditor.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
},
{
path: 'reports',
name: 'geenforce-reports',
component: () => import('../../views/geenforce/EnforcementReports.vue'),
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
}
]
}
]

View File

@@ -1,29 +0,0 @@
/**
* Knowledge Base plugin routes
*/
export default [
{
path: 'knowledgebase',
name: 'knowledgebase',
component: () => import('../../views/knowledgebase/KnowledgeBaseList.vue'),
meta: { plugin: 'knowledgebase' }
},
{
path: 'knowledgebase/new',
name: 'knowledgebase-new',
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
meta: { requiresAuth: true, plugin: 'knowledgebase' }
},
{
path: 'knowledgebase/:id',
name: 'knowledgebase-detail',
component: () => import('../../views/knowledgebase/KnowledgeBaseDetail.vue'),
meta: { plugin: 'knowledgebase' }
},
{
path: 'knowledgebase/:id/edit',
name: 'knowledgebase-edit',
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
meta: { requiresAuth: true, plugin: 'knowledgebase' }
}
]

View File

@@ -1,29 +0,0 @@
/**
* Machines plugin routes
*/
export default [
{
path: 'machines',
name: 'machines',
component: () => import('../../views/machines/MachinesList.vue'),
meta: { plugin: 'machines' }
},
{
path: 'machines/new',
name: 'machine-new',
component: () => import('../../views/machines/MachineForm.vue'),
meta: { requiresAuth: true, plugin: 'machines' }
},
{
path: 'machines/:id',
name: 'machine-detail',
component: () => import('../../views/machines/MachineDetail.vue'),
meta: { plugin: 'machines' }
},
{
path: 'machines/:id/edit',
name: 'machine-edit',
component: () => import('../../views/machines/MachineForm.vue'),
meta: { requiresAuth: true, plugin: 'machines' }
}
]

View File

@@ -1,366 +0,0 @@
<template>
<div class="detail-page">
<div class="page-header">
<h2>Application Details</h2>
<div class="header-actions">
<router-link :to="`/applications/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/applications" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="app">
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-image" v-if="app.image">
<img :src="`/images/applications/${app.image}`" :alt="app.appname" @error="handleImageError" />
</div>
<div class="hero-image placeholder" v-else>
<span class="placeholder-icon">&#x1F4E6;</span>
</div>
<div class="hero-content">
<div class="hero-title">
<h1>{{ app.appname }}</h1>
</div>
<p class="hero-description" v-if="app.appdescription">{{ app.appdescription }}</p>
<div class="hero-meta">
<span v-if="app.isinstallable" class="badge badge-lg badge-info">Installable</span>
<span v-if="app.islicenced" class="badge badge-lg badge-warning">Licensed</span>
<span v-if="app.isprinter" class="badge badge-lg badge-secondary">Printer App</span>
<span v-if="app.ishidden" class="badge badge-lg badge-dark">Hidden</span>
</div>
<div class="hero-links" v-if="app.applicationlink || app.installpath || app.documentationpath">
<a v-if="app.applicationlink" :href="app.applicationlink" target="_blank" class="hero-link">
<span class="link-icon">&#x1F517;</span> Launch Application
</a>
<a v-if="app.installpath" :href="app.installpath" target="_blank" class="hero-link">
<span class="link-icon">&#x2B07;</span> Download Files
</a>
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" class="hero-link">
<span class="link-icon">&#x1F4C4;</span> Documentation
</a>
</div>
</div>
</div>
<!-- Main Content Grid -->
<div class="content-grid">
<!-- Left Column -->
<div class="content-column">
<!-- Support -->
<div class="section-card">
<h3 class="section-title">Support</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Support Team</span>
<span class="info-value">
<a v-if="app.teamurl" :href="app.teamurl" target="_blank">
{{ app.supportteamname || '-' }}
</a>
<span v-else>{{ app.supportteamname || '-' }}</span>
</span>
</div>
<div class="info-row" v-if="app.contacts && app.contacts.length">
<span class="info-label">Contacts</span>
<span class="info-value">
<span v-for="(contact, index) in app.contacts" :key="index" class="contact-line">
{{ contact.name }}<span v-if="contact.sso" class="mono"> ({{ contact.sso }})</span>
<template v-if="contact.sso && contactEmailDomain">
<a class="contact-action" :href="`mailto:${contactEmail(contact)}`" title="Email">Email</a>
<a class="contact-action" :href="`https://teams.microsoft.com/l/chat/0/0?users=${contactEmail(contact)}`" target="_blank" rel="noopener" title="Teams chat">Teams</a>
</template>
</span>
</span>
</div>
</div>
</div>
<!-- Application Notes -->
<div class="section-card" v-if="app.applicationnotes">
<h3 class="section-title">Application Notes</h3>
<div class="notes-text">{{ app.applicationnotes }}</div>
</div>
<!-- Versions -->
<div class="section-card" v-if="versions.length > 0">
<h3 class="section-title">Available Versions</h3>
<div class="version-list">
<div v-for="ver in versions" :key="ver.appversionid" class="version-item">
<span class="version-number">v{{ ver.version }}</span>
<span class="version-date" v-if="ver.releasedate">{{ formatDate(ver.releasedate) }}</span>
<span class="version-notes" v-if="ver.notes">{{ ver.notes }}</span>
</div>
</div>
</div>
<!-- Related Knowledge Base -->
<div class="section-card" v-if="app.knowledgebase && app.knowledgebase.length">
<h3 class="section-title">Knowledge Base ({{ app.knowledgebase.length }})</h3>
<div class="kb-list">
<a
v-for="kb in app.knowledgebase"
:key="kb.linkid"
:href="kb.linkurl"
target="_blank"
class="kb-item"
>
<span class="kb-title">{{ kb.shortdescription }}</span>
<span class="kb-keywords" v-if="kb.keywords">{{ kb.keywords }}</span>
</a>
</div>
</div>
</div>
<!-- Right Column -->
<div class="content-column">
<!-- Installed On PCs -->
<div class="section-card">
<h3 class="section-title">Installed On ({{ installedOn.length }} PCs)</h3>
<div v-if="installedOn.length > 0" class="pc-list">
<router-link
v-for="install in installedOn"
:key="install.id"
:to="`/pcs/${install.computerid}`"
class="pc-item"
>
<div class="pc-info">
<span class="pc-name">{{ install.computer?.hostname || install.computer?.assetnumber || `PC #${install.computerid}` }}</span>
<span class="pc-alias" v-if="install.computer?.assetnumber">{{ install.computer.assetnumber }}</span>
</div>
<div class="pc-version" v-if="install.version">
v{{ install.version }}
</div>
</router-link>
</div>
<div v-else class="empty-state">
<p>Not installed on any PCs</p>
</div>
</div>
</div>
</div>
<!-- Audit Footer -->
<div class="audit-footer">
<span>Created {{ formatDate(app.createddate) }}</span>
<span>Modified {{ formatDate(app.modifieddate) }}</span>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Application not found</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { applicationsApi } from '../../api'
import { getContactEmailDomain } from '../../utils/siteSettings'
const route = useRoute()
const loading = ref(true)
const app = ref(null)
const versions = ref([])
const installedOn = ref([])
const contactEmailDomain = ref('')
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
function contactEmail(contact) {
return `${contact.sso}@${contactEmailDomain.value}`
}
onMounted(async () => {
try {
contactEmailDomain.value = await getContactEmailDomain()
// Load application details
const response = await applicationsApi.get(route.params.id)
app.value = response.data.data
// Load versions
try {
const versionsRes = await applicationsApi.getVersions(route.params.id)
versions.value = versionsRes.data.data || []
} catch (e) {
}
// Load installed on which PCs
try {
const installedRes = await applicationsApi.getInstalledOn(route.params.id)
installedOn.value = installedRes.data.data || []
} catch (e) {
}
} catch (error) {
console.error('Error loading application:', error)
} finally {
loading.value = false
}
})
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString()
}
function handleImageError(e) {
e.target.style.display = 'none'
}
</script>
<style scoped>
/* Application-specific styles - shared styles are in global style.css */
/* Hero description (app-specific) */
.hero-description {
color: var(--text-light);
margin: 0;
font-size: 1.125rem;
line-height: 1.5;
}
/* Hero links (app-specific) */
.hero-links {
display: flex;
gap: 1rem;
margin-top: auto;
flex-wrap: wrap;
}
.hero-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: var(--bg);
border-radius: 8px;
text-decoration: none;
color: var(--text);
font-weight: 500;
font-size: 1.125rem;
transition: background 0.15s;
}
.hero-link:hover {
background: var(--border);
}
.link-icon {
font-size: 1.25rem;
}
/* Placeholder image */
.hero-image.placeholder {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.placeholder-icon {
font-size: 5rem;
opacity: 0.5;
}
/* Version List */
.version-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.version-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--bg);
border-radius: 6px;
}
.version-number {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-weight: 600;
font-size: 1.125rem;
color: var(--link);
}
.version-date {
font-size: 1rem;
color: var(--text-light);
}
.version-notes {
font-size: 1rem;
color: var(--text-light);
margin-left: auto;
}
/* PC List */
.pc-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: 400px;
overflow-y: auto;
}
.pc-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: var(--bg);
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: background 0.15s;
}
.pc-item:hover {
background: var(--border);
}
.pc-info {
display: flex;
flex-direction: column;
}
.pc-name {
font-weight: 500;
font-size: 1.125rem;
color: var(--text);
}
.pc-alias {
font-size: 1rem;
color: var(--text-light);
}
.pc-version {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 1rem;
color: var(--primary);
background: var(--bg-card);
padding: 0.375rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--border);
}
/* Empty state */
.empty-state {
text-align: center;
padding: 2.5rem;
color: var(--text-light);
font-size: 1.125rem;
}
/* Support contacts stack one per line */
.contact-line {
display: block;
}
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
.notes-text {
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@@ -1,279 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Application' : 'New Application' }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveApplication">
<div class="form-row">
<div class="form-group">
<label for="appname">Application Name *</label>
<input
id="appname"
v-model="form.appname"
type="text"
class="form-control"
required
/>
</div>
<div class="form-group">
<label for="supportteamid">Support Team</label>
<select
id="supportteamid"
v-model="form.supportteamid"
class="form-control"
>
<option value="">Select team...</option>
<option
v-for="team in supportTeams"
:key="team.supportteamid"
:value="team.supportteamid"
>
{{ team.teamname }}
</option>
</select>
</div>
</div>
<div class="form-group">
<label for="appdescription">Description</label>
<textarea
id="appdescription"
v-model="form.appdescription"
class="form-control"
rows="2"
></textarea>
</div>
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Application Flags</h4>
<div class="form-row">
<div class="form-group checkbox-group">
<label>
<input type="checkbox" v-model="form.isinstallable" />
Installable
</label>
<label>
<input type="checkbox" v-model="form.islicenced" />
Licensed
</label>
<label>
<input type="checkbox" v-model="form.isprinter" />
Printer App
</label>
<label>
<input type="checkbox" v-model="form.isrequired" />
Required on all PCs
</label>
<label>
<input type="checkbox" v-model="form.ishidden" />
Hidden
</label>
</div>
</div>
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Links & Paths</h4>
<div class="form-row">
<div class="form-group">
<label for="applicationlink">Application Link</label>
<input
id="applicationlink"
v-model="form.applicationlink"
type="text"
class="form-control"
placeholder="https://..."
/>
</div>
<div class="form-group">
<label for="documentationpath">Documentation Path</label>
<input
id="documentationpath"
v-model="form.documentationpath"
type="text"
class="form-control"
placeholder="URL or file path"
/>
</div>
</div>
<div class="form-group">
<label for="installpath">Install Path</label>
<input
id="installpath"
v-model="form.installpath"
type="text"
class="form-control"
placeholder="Network path or URL to install files"
/>
</div>
<div class="form-group">
<label for="image">Image Filename</label>
<input
id="image"
v-model="form.image"
type="text"
class="form-control"
placeholder="e.g., myapp.png"
/>
<small class="form-hint">Image should be placed in /images/applications/</small>
</div>
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Notes</h4>
<div class="form-group">
<label for="applicationnotes">Application Notes (HTML supported)</label>
<textarea
id="applicationnotes"
v-model="form.applicationnotes"
class="form-control"
rows="6"
placeholder="Enter notes... HTML tags like <BR>, <a>, <strong> are supported"
></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save Application' }}
</button>
<router-link to="/applications" class="btn btn-secondary">Cancel</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { applicationsApi, supportteamsApi } from '../../api'
import { apiError } from '../../utils/apiError'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const form = ref({
appname: '',
appdescription: '',
supportteamid: '',
isinstallable: false,
islicenced: false,
isprinter: false,
isrequired: false,
ishidden: false,
applicationlink: '',
documentationpath: '',
installpath: '',
image: '',
applicationnotes: ''
})
const supportTeams = ref([])
onMounted(async () => {
try {
// Load support teams
const teamsRes = await supportteamsApi.list()
supportTeams.value = teamsRes.data.data || []
// Load application if editing
if (isEdit.value) {
const response = await applicationsApi.get(route.params.id)
const app = response.data.data
form.value = {
appname: app.appname || '',
appdescription: app.appdescription || '',
supportteamid: app.supportteamid || '',
isinstallable: app.isinstallable || false,
islicenced: app.islicenced || false,
isprinter: app.isprinter || false,
isrequired: app.isrequired || false,
ishidden: app.ishidden || false,
applicationlink: app.applicationlink || '',
documentationpath: app.documentationpath || '',
installpath: app.installpath || '',
image: app.image || '',
applicationnotes: app.applicationnotes || ''
}
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
} finally {
loading.value = false
}
})
async function saveApplication() {
error.value = ''
saving.value = true
try {
const appData = {
appname: form.value.appname,
appdescription: form.value.appdescription || null,
supportteamid: form.value.supportteamid || null,
isinstallable: form.value.isinstallable,
islicenced: form.value.islicenced,
isprinter: form.value.isprinter,
isrequired: form.value.isrequired,
ishidden: form.value.ishidden,
applicationlink: form.value.applicationlink || null,
documentationpath: form.value.documentationpath || null,
installpath: form.value.installpath || null,
image: form.value.image || null,
applicationnotes: form.value.applicationnotes || null
}
if (isEdit.value) {
await applicationsApi.update(route.params.id, appData)
} else {
await applicationsApi.create(appData)
}
router.push('/applications')
} catch (err) {
console.error('Error saving application:', err)
error.value = apiError(err, 'Failed to save application')
} finally {
saving.value = false
}
}
</script>
<style scoped>
.checkbox-group {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light, #666);
}
</style>

View File

@@ -1,213 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>Applications</h2>
<router-link to="/applications/new" class="btn btn-primary">Add Application</router-link>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search applications..."
@input="debouncedSearch"
/>
<select v-model="filter" class="form-control" @change="loadApplications">
<option value="installable">Installable Applications</option>
<option value="all">All Applications</option>
<option value="hidden">Hidden Applications</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th style="width: 50px;">Files</th>
<th style="width: 50px;">Docs</th>
<th>Application Name</th>
<th>Description</th>
<th>Support Team</th>
<th>Contacts</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="app in applications" :key="app.appid" class="clickable-row" @click="$router.push(`/applications/${app.appid}`)">
<td class="icon-cell">
<a v-if="app.installpath" :href="app.installpath" target="_blank" title="Download Installation Files" class="icon-download">
&#x2B07;
</a>
<a v-else-if="app.applicationlink" :href="app.applicationlink" target="_blank" title="Application Link" class="icon-link">
&#x1F517;
</a>
</td>
<td class="icon-cell">
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" title="View Documentation" class="icon-docs">
&#x1F4C4;
</a>
</td>
<td>
<router-link :to="`/applications/${app.appid}`">
{{ app.appname }}
</router-link>
<span class="app-flags">
<span v-if="app.isinstallable" class="badge badge-info badge-sm">Installable</span>
<span v-if="app.islicenced" class="badge badge-warning badge-sm">Licensed</span>
<span v-if="app.isprinter" class="badge badge-secondary badge-sm">Printer</span>
</span>
</td>
<td class="description">{{ app.appdescription || '-' }}</td>
<td>{{ app.supportteamname || '-' }}</td>
<td>{{ app.contacts && app.contacts.length ? app.contacts.map(c => c.name).join(', ') : '-' }}</td>
<td class="actions" @click.stop>
<router-link
:to="`/applications/${app.appid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="applications.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);">
No applications found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { applicationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const applications = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadApplications })
const filter = ref('installable')
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null
onMounted(() => {
loadApplications()
})
async function loadApplications() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value,
search: search.value || undefined
}
// Apply filter
if (filter.value === 'installable') {
params.installable = true
} else if (filter.value === 'hidden') {
params.hidden = true
}
const response = await applicationsApi.list(params)
applications.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading applications:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadApplications()
}, 300)
}
function goToPage(p) {
setPage(p)
loadApplications()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadApplications()
}
</script>
<style scoped>
/* Application list specific styles */
.icon-cell {
text-align: center;
width: 50px;
}
.icon-cell a {
font-size: 1.25rem;
text-decoration: none;
}
.icon-download {
color: var(--success);
}
.icon-link {
color: var(--link);
}
.icon-docs {
color: var(--secondary);
}
.icon-cell a:hover {
opacity: 0.7;
}
.app-flags {
display: inline-flex;
gap: 0.375rem;
flex-wrap: wrap;
margin-left: 0.5rem;
vertical-align: middle;
}
.badge-sm {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
}
.description {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-light);
}
</style>

View File

@@ -1,161 +0,0 @@
<template>
<div class="enforcement-reports">
<div class="page-header">
<h2>Enforcement Reports</h2>
</div>
<p class="setting-description">
Latest GE-Enforce result reported by each PC. "Received" means the PC picked
up the current published manifest; status shows self-heal and failures.
</p>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="filters">
<input
type="text"
class="form-control"
v-model="filterHost"
placeholder="Filter hostname"
@keyup.enter="load"
/>
<input
type="text"
class="form-control"
v-model="filterScope"
placeholder="Filter PC type"
@keyup.enter="load"
/>
<button class="btn btn-primary" @click="load">Filter</button>
<button class="btn btn-secondary" @click="clearFilters">Clear</button>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Host</th><th>PC type</th><th>Received</th><th>Version</th>
<th>Status</th><th>Installed</th><th>Skipped</th><th>Failed</th>
<th>Last check-in</th><th></th>
</tr>
</thead>
<tbody>
<tr v-for="report in reports" :key="report.reportid">
<td>{{ report.hostname }}</td>
<td>{{ report.scopename }}</td>
<td>
<span class="badge" :class="report.receivedlatest ? 'badge-success' : 'badge-warning'">
{{ report.receivedlatest ? 'yes' : 'behind' }}
</span>
</td>
<td class="muted">{{ report.appliedversion ?? '-' }} / {{ report.latestversion ?? '-' }}</td>
<td><span class="badge" :class="statusClass(report.status)">{{ report.status }}</span></td>
<td>{{ report.installed }}</td>
<td class="muted">{{ report.skipped }}</td>
<td :class="{ 'fail-count': report.failed }">{{ report.failed }}</td>
<td class="muted">{{ formatDate(report.lastcheckin || report.receivedat) }}</td>
<td class="actions">
<button class="btn btn-sm btn-secondary" @click="openDetail(report.reportid)">Detail</button>
</td>
</tr>
<tr v-if="!reports.length"><td colspan="10" class="empty">No reports yet.</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Detail modal -->
<div v-if="detail" class="modal-overlay" @click.self="detail = null">
<div class="modal modal-report">
<div class="modal-header">
<h3>{{ detail.hostname }} - {{ detail.scopename }}</h3>
<button class="modal-close" @click="detail = null">x</button>
</div>
<div class="modal-body">
<p class="setting-description">
Applied v{{ detail.appliedversion ?? '-' }} of v{{ detail.latestversion ?? '-' }};
status {{ detail.status }}
</p>
<div class="table-container">
<table>
<thead>
<tr><th>Entry</th><th>Action</th><th>Self-heal</th><th>Exit</th><th>Message</th></tr>
</thead>
<tbody>
<tr v-for="(result, index) in detail.results" :key="index">
<td>{{ result.entryname }}</td>
<td><span class="badge" :class="actionClass(result.action)">{{ result.action }}</span></td>
<td>{{ result.selfhealed ? 'yes' : '' }}</td>
<td class="muted">{{ result.exitcode ?? '' }}</td>
<td class="muted">{{ result.message }}</td>
</tr>
<tr v-if="!detail.results.length"><td colspan="5" class="empty">No per-entry detail.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="detail = null">Close</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import api from '../../api'
const reports = ref([])
const detail = ref(null)
const error = ref('')
const filterHost = ref('')
const filterScope = ref('')
function payload(response) { return response.data.data }
async function load() {
const params = {}
if (filterHost.value) params.hostname = filterHost.value
if (filterScope.value) params.scopename = filterScope.value
try {
reports.value = payload(await api.get('/geenforce/reports', { params }))
error.value = ''
} catch (e) { error.value = 'Failed to load reports' }
}
function clearFilters() { filterHost.value = ''; filterScope.value = ''; load() }
async function openDetail(reportid) {
try {
detail.value = payload(await api.get(`/geenforce/reports/${reportid}`))
} catch (e) { error.value = 'Failed to load report detail' }
}
function statusClass(status) {
return { ok: 'badge-success', selfhealed: 'badge-info', failed: 'badge-danger' }[status] || ''
}
function actionClass(action) {
return { installed: 'badge-info', skipped: 'badge-success', failed: 'badge-danger',
filtered: '' }[action] || ''
}
function formatDate(value) { return value ? new Date(value).toLocaleString() : '' }
load()
</script>
<style scoped>
.enforcement-reports { max-width: 1100px; }
.muted { color: var(--text-light); }
.fail-count { color: var(--danger); font-weight: 600; }
.empty { color: var(--text-light); text-align: center; padding: 1rem; }
.modal-report { max-width: 640px; }
.modal-close {
background: transparent;
border: none;
color: var(--text-light);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 0.25rem 0.5rem;
}
.modal-close:hover { color: var(--text); }
</style>

View File

@@ -1,43 +0,0 @@
<template>
<div class="geenforce-section">
<div class="section-header">
<h1>GE-Enforce</h1>
<p class="section-sub">Desired-state install manifests for imaging PC types, and the fleet's reported results.</p>
</div>
<nav class="section-tabs">
<router-link to="/geenforce/manifests" class="tab">Manifests</router-link>
<router-link to="/geenforce/reports" class="tab">Enforcement Reports</router-link>
</nav>
<router-view />
</div>
</template>
<script setup>
// Tabbed shell for the GE-Enforce section. Children render the manifest editor
// and the fleet-compliance reports full-width (not squeezed into the settings rail).
</script>
<style scoped>
.geenforce-section { max-width: 1400px; }
.section-header { margin-bottom: 0.5rem; }
.section-header h1 { margin: 0; }
.section-sub { color: var(--text-light); margin: 0.25rem 0 0; }
.section-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border);
margin: 1rem 0 1.25rem;
}
.tab {
padding: 0.5rem 0.9rem;
text-decoration: none;
color: var(--text-light);
border-bottom: 2px solid transparent;
font-weight: 500;
}
.tab:hover { color: var(--text); }
.tab.router-link-active {
color: var(--primary);
border-bottom-color: var(--primary);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,183 +0,0 @@
// Pure, framework-free helpers for the GE-Enforce manifest editor.
//
// ManifestEditor.vue imports these directly (the component no longer keeps its
// own copies), so the unit tests in entryForm.spec.js exercise the shipped
// code path. Change the editor logic HERE.
//
// Everything here is a plain function of its inputs. No Vue, no reactivity,
// no network. That is the whole point - deterministic logic we can pin down.
export const ENTRY_TYPES = ['MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry']
export const REG_TYPES = ['String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary']
export const DETECTION_METHODS = ['Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile',
'ValueMatches', 'pnputil', 'Always']
export const INUSE_BEHAVIORS = ['Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot']
// Plain-language description of each detection method, shown under the Detection
// method dropdown so a first-time site admin understands what "present" means
// for the method they picked. Key '' is the no-detection case.
export const DETECTION_METHOD_HINTS = {
'': 'No detection rule: the action runs every cycle.',
Registry: 'Already correct if the registry value at Detection path/name exists (and equals Detection value when one is set).',
File: 'Already correct if the file at Detection path exists.',
FileVersion: 'Already correct if the file at Detection path is at Detection value or newer. This target feeds the Compliance panel.',
Hash: 'Already correct if the file at Detection path matches the SHA256 in Detection value. Re-copies when the file changed.',
MarkerFile: 'Already correct if the marker file at Detection path exists. Installs once, then the marker suppresses reruns.',
ValueMatches: 'Already correct if the registry value at Detection path/name equals Detection value exactly.',
pnputil: 'Already correct if a driver matching Detection pattern is staged in the Windows driver store. For INF entries.',
Always: 'Never counts as present, so the action runs every cycle. Same effect as no detection rule.',
}
// Description for the currently selected detection method, or '' if unknown.
export function detectionMethodHint(method) {
return DETECTION_METHOD_HINTS[method || ''] || ''
}
// One blank entry form, matching the shape ManifestEditor seeds for a new entry.
export function blankEntry() {
return { Type: 'MSI', DetectionMethod: '', RegType: 'String',
inuseBehavior: '', inuseProcesses: [], appid: null,
PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false }
}
// Split a comma list into trimmed non-empty parts.
export function splitList(value) {
return (value || '').split(',').map(item => item.trim()).filter(Boolean)
}
// Turn an edit-form object into the API body sent to create/update an entry.
//
// Rules that matter (and that the specs pin):
// - appid is always present (null unlinks). It is shopdb metadata; the backend
// keeps it OFF the manifest JSON, so it is NOT one of the manifest scalars.
// - empty/undefined/null scalars are dropped.
// - Registry DWord/QWord values become real numbers; other reg types stay strings.
// - comma lists become arrays, and are dropped when empty.
// - preinstall flags only appear when truthy.
// - InUseCheck carries per-process Name, optional ExePath, optional numeric timeout.
export function buildEntryPayload(form) {
// appid is shopdb metadata, always sent (null unlinks); the backend keeps it
// off the manifest JSON.
const out = { Name: form.Name, Type: form.Type, appid: form.appid ?? null }
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
'LogFile', 'ApplyMode', 'UpdateWindow', '_comment']
for (const key of scalars) {
if (form[key] !== undefined && form[key] !== '' && form[key] !== null) out[key] = form[key]
}
if (form.DetectionMethod) out.DetectionMethod = form.DetectionMethod
if (form.WaitTimeoutSec) out.WaitTimeoutSec = form.WaitTimeoutSec
if (form.Type === 'Registry' && form.RegValue !== undefined && form.RegValue !== '') {
out.RegValue = ['DWord', 'QWord'].includes(form.RegType) ? Number(form.RegValue) : form.RegValue
}
const pctypes = splitList(form.PCTypes)
if (pctypes.length) out.PCTypes = pctypes
const hostnames = splitList(form.TargetHostnames)
if (hostnames.length) out.TargetHostnames = hostnames
const machinenumbers = splitList(form.TargetMachineNumbers)
if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers
for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) {
if (form[flag]) out[flag] = true
}
if (form.inuseBehavior) {
out.InUseCheck = {
Behavior: form.inuseBehavior,
Processes: (form.inuseProcesses || []).filter(process => process.name).map(process => {
const processData = { Name: process.name }
if (process.exepath) processData.ExePath = process.exepath
if (process.timeout !== null && process.timeout !== '' && process.timeout !== undefined) {
processData.GracefulCloseTimeoutSec = Number(process.timeout)
}
return processData
}),
}
}
return out
}
// Which entry types the type dropdown offers, given the scope phase. Preinstall
// supports MSI/EXE only, but keeps a current out-of-set value visible.
export function availableEntryTypes(isPreinstall, currentType) {
if (!isPreinstall) return ENTRY_TYPES
const allowed = ['MSI', 'EXE']
return currentType && !allowed.includes(currentType) ? [...allowed, currentType] : allowed
}
// Which detection methods the dropdown offers. Preinstall supports Registry/File
// only, but keeps a current out-of-set value visible.
export function availableDetectionMethods(isPreinstall, currentMethod) {
if (!isPreinstall) return DETECTION_METHODS
const allowed = ['Registry', 'File']
return currentMethod && !allowed.includes(currentMethod) ? [...allowed, currentMethod] : allowed
}
// Which targeting gates a scope actually surfaces by default (before "Show all").
export function targetingGates(scope) {
if (!scope) {
return { pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false }
}
const name = (scope.scopename || '').toLowerCase()
const entries = scope.entries || []
const fleetwide = scope.iscommon || scope.phase === 'preinstall' || name === 'common'
return {
pctypes: fleetwide || entries.some(e => (e.PCTypes || []).length),
cmmversion: /cmm/.test(name) || entries.some(e => e._CmmVersion),
// Data-driven (a loose name match would wrongly flag 'nocollections').
machinenumbers: entries.some(e => (e.TargetMachineNumbers || []).length),
hostnames: entries.some(e => (e.TargetHostnames || []).length),
}
}
// The contextual hint under the Targeting header.
export function targetingHint(scope) {
if (!scope) return ''
if (scope.iscommon || scope.scopename === 'common') {
return 'Fleet-wide manifest: use PC types to target which types get this entry.'
}
if (scope.phase === 'preinstall') {
return 'Preinstall manifest: use PC types to target; preinstall flags apply here.'
}
return `This manifest already runs only on ${scope.scopename} PCs. `
+ 'Targeting below narrows within that (version, bay, or subtype).'
}
// One-line summary of what a scope installs (installer entries only).
export function scopeSummary(scope) {
if (!scope || !scope.entries || !scope.entries.length) return ''
const installers = scope.entries
.filter(e => ['MSI', 'EXE', 'CMD', 'BAT'].includes(e.Type))
.map(e => e.Name)
const shown = installers.slice(0, 6).join(', ')
const more = installers.length > 6 ? `, +${installers.length - 6} more` : ''
const apps = installers.length ? `Installs ${shown}${more}. ` : ''
return `${apps}${scope.entries.length} entries; runs after common.`
}
// Verb table for describeEntry.
export const ACTION_VERB = {
MSI: 'Installs', EXE: 'Installs', CMD: 'Runs', BAT: 'Runs',
PS1: 'Runs a script for', INF: 'Installs a driver for',
File: 'Copies a file for', Registry: 'Sets a registry value for',
}
// Plain-English one-liner: what an entry does, when it self-heals, who it hits.
export function describeEntry(entry) {
let text = `${ACTION_VERB[entry.Type] || 'Applies'} ${entry.Name}`
const method = entry.DetectionMethod
if (!method || method === 'Always') text += '; runs every cycle'
else if (method === 'FileVersion') text += `; reinstalls unless version is ${entry.DetectionValue || 'set'}`
else if (method === 'Hash') text += '; re-copies if the file changed'
else if (method === 'MarkerFile') text += '; installs once'
else text += '; reinstalls if not detected'
if (entry._CmmVersion) text += `; CMM ${entry._CmmVersion} bays only`
else if (entry.TargetMachineNumbers && entry.TargetMachineNumbers.length) {
text += `; ${entry.TargetMachineNumbers.length} specific bay(s)`
} else if (entry.TargetHostnames && entry.TargetHostnames.length) {
text += '; specific hostname(s)'
} else if (entry.PCTypes && entry.PCTypes.length) {
text += `; ${entry.PCTypes.length} PC type(s)`
}
if (entry.appname) text += `; tracked: ${entry.appname}`
return text
}

View File

@@ -1,321 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
blankEntry,
splitList,
buildEntryPayload,
availableEntryTypes,
availableDetectionMethods,
targetingGates,
targetingHint,
scopeSummary,
describeEntry,
detectionMethodHint,
ENTRY_TYPES,
DETECTION_METHODS,
} from './entryForm.js'
// These specs pin the deterministic logic that turns the manifest editor form
// into an API body, and the various display helpers. This is the code most
// likely to silently regress (numeric coercion, dropped-vs-kept fields, the
// appid-must-never-be-a-manifest-scalar rule).
describe('splitList', () => {
it('trims, drops empties, and returns an array', () => {
expect(splitList('a, b ,, c ')).toEqual(['a', 'b', 'c'])
})
it('returns an empty array for null/empty', () => {
expect(splitList('')).toEqual([])
expect(splitList(null)).toEqual([])
expect(splitList(undefined)).toEqual([])
})
})
describe('buildEntryPayload - appid handling', () => {
it('always emits appid as a top-level key, defaulting to null', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'MSI' })
expect(out.appid).toBeNull()
expect('appid' in out).toBe(true)
})
it('passes a linked appid through unchanged', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', appid: 41 })
expect(out.appid).toBe(41)
})
it('never lets appid become a manifest scalar (it is metadata, not a manifest key)', () => {
// appid lives at the top level as metadata; it must not appear nested in
// any manifest sub-structure. Guard against a future refactor smuggling it
// into e.g. InUseCheck or a detection block.
const out = buildEntryPayload({
Name: 'x', Type: 'Registry', appid: 7, RegType: 'DWord', RegValue: '1',
DetectionMethod: 'Registry', inuseBehavior: 'ForceClose',
inuseProcesses: [{ name: 'p', exepath: '', timeout: null }],
})
// Only the top-level appid key carries it.
const serialized = JSON.stringify({ ...out, appid: undefined })
expect(serialized.includes('appid')).toBe(false)
expect(serialized.toLowerCase().includes('"appid"')).toBe(false)
})
})
describe('buildEntryPayload - scalar filtering', () => {
it('drops empty-string, null, and undefined scalars', () => {
const out = buildEntryPayload({
Name: 'x', Type: 'MSI', Installer: '', InstallArgs: null,
Script: undefined, LogFile: 'setup.log',
})
expect('Installer' in out).toBe(false)
expect('InstallArgs' in out).toBe(false)
expect('Script' in out).toBe(false)
expect(out.LogFile).toBe('setup.log')
})
it('keeps the underscore-prefixed scalars (_CmmVersion, _comment)', () => {
const out = buildEntryPayload({
Name: 'x', Type: 'MSI', _CmmVersion: '2019', _comment: 'note',
})
expect(out._CmmVersion).toBe('2019')
expect(out._comment).toBe('note')
})
it('only emits DetectionMethod when set', () => {
expect('DetectionMethod' in buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: '' })).toBe(false)
expect(buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: 'FileVersion' }).DetectionMethod).toBe('FileVersion')
})
it('drops a zero/falsy WaitTimeoutSec but keeps a real one', () => {
expect('WaitTimeoutSec' in buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 0 })).toBe(false)
expect(buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 300 }).WaitTimeoutSec).toBe(300)
})
})
describe('buildEntryPayload - RegValue coercion', () => {
it('coerces DWord to a Number', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '1' })
expect(out.RegValue).toBe(1)
expect(typeof out.RegValue).toBe('number')
})
it('coerces QWord to a Number', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'QWord', RegValue: '42' })
expect(out.RegValue).toBe(42)
})
it('leaves String reg values as strings', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'String', RegValue: 'hello' })
expect(out.RegValue).toBe('hello')
expect(typeof out.RegValue).toBe('string')
})
it('does not emit RegValue for a non-Registry type', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', RegType: 'DWord', RegValue: '1' })
expect('RegValue' in out).toBe(false)
})
it('drops an empty RegValue even for Registry', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '' })
expect('RegValue' in out).toBe(false)
})
})
describe('buildEntryPayload - comma lists to arrays', () => {
it('splits PCTypes / TargetHostnames / TargetMachineNumbers', () => {
const out = buildEntryPayload({
Name: 'x', Type: 'MSI',
PCTypes: 'gea-shopfloor-cmm, gea-shopfloor-collections',
TargetHostnames: 'host-a, host-b',
TargetMachineNumbers: '0101, 0102',
})
expect(out.PCTypes).toEqual(['gea-shopfloor-cmm', 'gea-shopfloor-collections'])
expect(out.TargetHostnames).toEqual(['host-a', 'host-b'])
expect(out.TargetMachineNumbers).toEqual(['0101', '0102'])
})
it('omits the array keys when the list is empty', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', PCTypes: '', TargetHostnames: ' ' })
expect('PCTypes' in out).toBe(false)
expect('TargetHostnames' in out).toBe(false)
})
})
describe('buildEntryPayload - preinstall flags', () => {
it('emits only the truthy flags', () => {
const out = buildEntryPayload({
Name: 'x', Type: 'MSI',
PreEnrollment: true, KillAfterDetection: false, PCTypesStrict: true,
})
expect(out.PreEnrollment).toBe(true)
expect(out.PCTypesStrict).toBe(true)
expect('KillAfterDetection' in out).toBe(false)
})
})
describe('buildEntryPayload - InUseCheck processes', () => {
it('carries Name, optional ExePath, and a numeric timeout per process', () => {
const out = buildEntryPayload({
Name: 'x', Type: 'MSI', inuseBehavior: 'CloseAndReopen',
inuseProcesses: [
{ name: 'pcdmis', exepath: 'C:\\pcd\\pcdmis.exe', timeout: 30 },
{ name: 'nodmis', exepath: '', timeout: null },
],
})
expect(out.InUseCheck.Behavior).toBe('CloseAndReopen')
expect(out.InUseCheck.Processes).toEqual([
{ Name: 'pcdmis', ExePath: 'C:\\pcd\\pcdmis.exe', GracefulCloseTimeoutSec: 30 },
{ Name: 'nodmis' },
])
})
it('drops processes with no name', () => {
const out = buildEntryPayload({
Name: 'x', Type: 'MSI', inuseBehavior: 'ForceClose',
inuseProcesses: [{ name: '', exepath: 'x', timeout: 5 }, { name: 'keep', timeout: 5 }],
})
expect(out.InUseCheck.Processes).toEqual([{ Name: 'keep', GracefulCloseTimeoutSec: 5 }])
})
it('coerces a string timeout to a number', () => {
const out = buildEntryPayload({
Name: 'x', Type: 'MSI', inuseBehavior: 'Defer',
inuseProcesses: [{ name: 'p', exepath: '', timeout: '15' }],
})
expect(out.InUseCheck.Processes[0].GracefulCloseTimeoutSec).toBe(15)
})
it('emits no InUseCheck when there is no behavior', () => {
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', inuseBehavior: '' })
expect('InUseCheck' in out).toBe(false)
})
})
describe('buildEntryPayload - a blank new entry round-trips', () => {
it('produces a minimal body from blankEntry (name/type + null appid + default RegType)', () => {
const out = buildEntryPayload({ ...blankEntry(), Name: 'Fresh' })
// Type MSI, appid null. RegType is default String but non-Registry, so it
// is still carried as a scalar (it is in the scalar list). Confirm the shape.
expect(out.Name).toBe('Fresh')
expect(out.Type).toBe('MSI')
expect(out.appid).toBeNull()
expect('InUseCheck' in out).toBe(false)
expect('PCTypes' in out).toBe(false)
})
})
describe('availableEntryTypes', () => {
it('returns the full list for a non-preinstall scope', () => {
expect(availableEntryTypes(false, 'File')).toEqual(ENTRY_TYPES)
})
it('restricts to MSI/EXE for preinstall', () => {
expect(availableEntryTypes(true, 'MSI')).toEqual(['MSI', 'EXE'])
})
it('keeps an out-of-set current value visible in preinstall', () => {
expect(availableEntryTypes(true, 'Registry')).toEqual(['MSI', 'EXE', 'Registry'])
})
})
describe('availableDetectionMethods', () => {
it('returns the full list for a non-preinstall scope', () => {
expect(availableDetectionMethods(false, 'Hash')).toEqual(DETECTION_METHODS)
})
it('restricts to Registry/File for preinstall', () => {
expect(availableDetectionMethods(true, 'File')).toEqual(['Registry', 'File'])
})
it('keeps an out-of-set current value visible in preinstall', () => {
expect(availableDetectionMethods(true, 'FileVersion')).toEqual(['Registry', 'File', 'FileVersion'])
})
})
describe('targetingGates', () => {
it('defaults to pctypes-only when there is no scope', () => {
expect(targetingGates(null)).toEqual({
pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false,
})
})
it('shows pctypes for a fleetwide/common/preinstall scope', () => {
expect(targetingGates({ scopename: 'common', entries: [] }).pctypes).toBe(true)
expect(targetingGates({ scopename: 'x', phase: 'preinstall', entries: [] }).pctypes).toBe(true)
expect(targetingGates({ scopename: 'x', iscommon: true, entries: [] }).pctypes).toBe(true)
})
it('shows the cmm gate for a cmm-named scope', () => {
expect(targetingGates({ scopename: 'gea-shopfloor-cmm', entries: [] }).cmmversion).toBe(true)
})
it('does not name-match machinenumbers (data-driven only)', () => {
// 'nocollections' must not trip a loose name match; machinenumbers is purely
// data-driven off the entries.
const gates = targetingGates({ scopename: 'nocollections', entries: [] })
expect(gates.machinenumbers).toBe(false)
})
it('surfaces gates from entry data', () => {
const gates = targetingGates({
scopename: 'per-type', entries: [
{ PCTypes: ['a'] }, { TargetMachineNumbers: ['0101'] }, { TargetHostnames: ['h'] },
],
})
expect(gates.pctypes).toBe(true)
expect(gates.machinenumbers).toBe(true)
expect(gates.hostnames).toBe(true)
})
})
describe('targetingHint', () => {
it('is empty with no scope', () => {
expect(targetingHint(null)).toBe('')
})
it('describes a fleet-wide common manifest', () => {
expect(targetingHint({ scopename: 'common' })).toMatch(/Fleet-wide/)
})
it('describes a preinstall manifest', () => {
expect(targetingHint({ scopename: 'x', phase: 'preinstall' })).toMatch(/Preinstall/)
})
it('names a per-type scope', () => {
expect(targetingHint({ scopename: 'gea-shopfloor-cmm' })).toMatch(/gea-shopfloor-cmm/)
})
})
describe('scopeSummary', () => {
it('is empty with no entries', () => {
expect(scopeSummary({ entries: [] })).toBe('')
expect(scopeSummary(null)).toBe('')
})
it('lists installer names and the entry count', () => {
const summary = scopeSummary({
entries: [
{ Type: 'MSI', Name: 'eDNC' },
{ Type: 'Registry', Name: 'reg' },
],
})
expect(summary).toBe('Installs eDNC. 2 entries; runs after common.')
})
it('truncates past six installers with a +N more', () => {
const entries = Array.from({ length: 8 }, (_, i) => ({ Type: 'MSI', Name: `app${i}` }))
const summary = scopeSummary({ entries })
expect(summary).toMatch(/\+2 more/)
})
})
describe('describeEntry', () => {
it('describes an MSI with FileVersion detection using the expected version', () => {
const text = describeEntry({ Type: 'MSI', Name: 'PC-DMIS', DetectionMethod: 'FileVersion', DetectionValue: '2019 R1' })
expect(text).toBe('Installs PC-DMIS; reinstalls unless version is 2019 R1')
})
it('says runs every cycle for Always / no detection', () => {
expect(describeEntry({ Type: 'CMD', Name: 'x' })).toBe('Runs x; runs every cycle')
expect(describeEntry({ Type: 'CMD', Name: 'x', DetectionMethod: 'Always' })).toBe('Runs x; runs every cycle')
})
it('appends a CMM gate note', () => {
const text = describeEntry({ Type: 'MSI', Name: 'x', _CmmVersion: '2016' })
expect(text).toMatch(/CMM 2016 bays only/)
})
it('appends the tracked app name when present', () => {
const text = describeEntry({ Type: 'MSI', Name: 'x', appname: 'PC-DMIS' })
expect(text).toMatch(/tracked: PC-DMIS/)
})
it('falls back to Applies for an unknown type', () => {
expect(describeEntry({ Type: 'Weird', Name: 'x' })).toMatch(/^Applies x/)
})
})
describe('detectionMethodHint', () => {
it('has a non-empty hint for every detection method', () => {
for (const method of DETECTION_METHODS) {
expect(detectionMethodHint(method).length).toBeGreaterThan(0)
}
})
it('describes the no-detection case for empty/undefined', () => {
expect(detectionMethodHint('')).toMatch(/every cycle/)
expect(detectionMethodHint(undefined)).toMatch(/every cycle/)
})
it('ties FileVersion to the compliance panel', () => {
expect(detectionMethodHint('FileVersion')).toMatch(/Compliance/)
})
it('returns empty string for an unknown method', () => {
expect(detectionMethodHint('Nonsense')).toBe('')
})
})

View File

@@ -1,212 +0,0 @@
<template>
<div class="detail-page">
<div class="page-header">
<h2>Knowledge Base Article</h2>
<div class="header-actions">
<router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="article">
<div class="card article-card">
<table class="info-table">
<tbody>
<tr>
<th>Description:</th>
<td>{{ article.shortdescription }}</td>
</tr>
<tr v-if="article.application">
<th>Topic:</th>
<td>
<router-link :to="`/applications/${article.application.appid}`">
{{ article.application.appname }}
</router-link>
</td>
</tr>
<tr v-if="article.linkurl">
<th>URL:</th>
<td>
<a href="#" @click.prevent="openArticle">
{{ article.linkurl }}
</a>
</td>
</tr>
<tr v-if="article.keywords">
<th>Keywords:</th>
<td>{{ article.keywords }}</td>
</tr>
<tr>
<th>Clicks:</th>
<td>{{ article.clicks }}</td>
</tr>
<tr>
<th>Last Updated:</th>
<td>{{ formatDate(article.lastupdated) }}</td>
</tr>
</tbody>
</table>
<hr />
<div class="actions-center">
<a
v-if="article.linkurl"
href="#"
class="btn btn-primary btn-lg"
@click.prevent="openArticle"
>
<span class="btn-icon">&#x2197;</span> Open Article
</a>
<div v-else class="alert alert-warning">
This article does not have a URL link defined. Please edit the article to add one.
</div>
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
<span class="btn-icon">&#x270E;</span> Edit
</router-link>
</div>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Article not found</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { knowledgebaseApi } from '../../api'
const route = useRoute()
const loading = ref(true)
const article = ref(null)
onMounted(async () => {
try {
const response = await knowledgebaseApi.get(route.params.id)
article.value = response.data.data
} catch (error) {
console.error('Error loading article:', error)
} finally {
loading.value = false
}
})
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
async function openArticle() {
try {
await knowledgebaseApi.trackClick(article.value.linkid)
article.value.clicks = (article.value.clicks || 0) + 1
// Open the URL after tracking
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
} catch (error) {
console.error('Error tracking click:', error)
// Still open even if tracking fails
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
}
}
</script>
<style scoped>
.detail-page {
max-width: 800px;
margin: 0 auto;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.article-card {
padding: 1.5rem;
}
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table th,
.info-table td {
padding: 0.75rem;
text-align: left;
border: none;
}
.info-table th {
width: 150px;
font-weight: 600;
color: var(--text-light, #666);
vertical-align: top;
}
.info-table td a {
color: var(--primary, #1976d2);
text-decoration: none;
word-break: break-all;
overflow-wrap: break-word;
}
.info-table td a:hover {
text-decoration: underline;
}
.info-table td {
word-break: break-word;
overflow-wrap: break-word;
max-width: 500px;
}
hr {
margin: 1.5rem 0;
border: none;
border-top: 1px solid var(--border-color, #e5e5e5);
}
.actions-center {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1rem;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.btn-icon {
margin-right: 0.5rem;
}
.alert {
padding: 1rem;
border-radius: 4px;
}
.alert-warning {
background: #fff3cd;
color: #856404;
}
@media (prefers-color-scheme: dark) {
.alert-warning {
background: #3d3200;
color: #ffc107;
}
}
</style>

View File

@@ -1,167 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Article' : 'Add Knowledge Base Article' }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveArticle">
<div class="form-group">
<label for="shortdescription">Description *</label>
<input
id="shortdescription"
v-model="form.shortdescription"
type="text"
class="form-control"
required
maxlength="500"
placeholder="Brief description of the article"
/>
</div>
<div class="form-group">
<label for="linkurl">URL *</label>
<input
id="linkurl"
v-model="form.linkurl"
type="url"
class="form-control"
required
maxlength="2000"
placeholder="https://..."
/>
</div>
<div class="form-group">
<label for="keywords">Keywords</label>
<input
id="keywords"
v-model="form.keywords"
type="text"
class="form-control"
maxlength="500"
placeholder="Space-separated keywords"
/>
<small class="form-hint">Keywords help with search - separate with spaces</small>
</div>
<div class="form-group">
<label for="appid">Topic (Application)</label>
<select
id="appid"
v-model="form.appid"
class="form-control"
>
<option value="">-- Select Topic (Optional) --</option>
<option
v-for="app in applications"
:key="app.appid"
:value="app.appid"
>
{{ app.appname }}
</option>
</select>
<small class="form-hint">Select the application/topic this article relates to</small>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Update Article' : 'Add Article') }}
</button>
<router-link to="/knowledgebase" class="btn btn-secondary">Cancel</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { knowledgebaseApi, applicationsApi } from '../../api'
import { apiError } from '../../utils/apiError'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const form = ref({
shortdescription: '',
linkurl: '',
keywords: '',
appid: ''
})
const applications = ref([])
onMounted(async () => {
try {
// Load applications for topic dropdown
const appsRes = await applicationsApi.list({ perpage: 1000 })
applications.value = appsRes.data.data || []
// Load article if editing
if (isEdit.value) {
const response = await knowledgebaseApi.get(route.params.id)
const article = response.data.data
form.value = {
shortdescription: article.shortdescription || '',
linkurl: article.linkurl || '',
keywords: article.keywords || '',
appid: article.application?.appid || ''
}
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
} finally {
loading.value = false
}
})
async function saveArticle() {
error.value = ''
saving.value = true
try {
const articleData = {
shortdescription: form.value.shortdescription,
linkurl: form.value.linkurl,
keywords: form.value.keywords || null,
appid: form.value.appid || null
}
if (isEdit.value) {
await knowledgebaseApi.update(route.params.id, articleData)
} else {
await knowledgebaseApi.create(articleData)
}
router.push('/knowledgebase')
} catch (err) {
console.error('Error saving article:', err)
error.value = apiError(err, 'Failed to save article')
} finally {
saving.value = false
}
}
</script>
<style scoped>
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light, #666);
}
</style>

View File

@@ -1,263 +0,0 @@
<template>
<div>
<div class="page-header">
<div class="header-left">
<h2>Knowledge Base</h2>
<span v-if="stats" class="stats-badge">
{{ stats.totalarticles }} articles | {{ stats.totalclicks.toLocaleString() }} total clicks
</span>
</div>
<router-link to="/knowledgebase/new" class="btn btn-primary">Add Article</router-link>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search articles..."
@input="debouncedSearch"
/>
<select v-model="topicFilter" class="form-control" @change="loadArticles">
<option value="">All Topics</option>
<option
v-for="app in topics"
:key="app.appid"
:value="app.appid"
>
{{ app.appname }}
</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th class="sortable" @click="toggleSort('topic')">
Topic
<span v-if="sort === 'topic'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span>
</th>
<th class="sortable" @click="toggleSort('description')">
Description
<span v-if="sort === 'description'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span>
</th>
<th class="sortable" style="width: 100px;" @click="toggleSort('clicks')">
Clicks
<span v-if="sort === 'clicks'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span>
</th>
<th style="width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="article in articles" :key="article.linkid">
<td>
<router-link
v-if="article.application"
:to="`/applications/${article.application.appid}`"
>
{{ article.application.appname }}
</router-link>
<span v-else class="text-muted">-</span>
</td>
<td>
<a
href="#"
class="article-link"
@click.prevent="openArticle(article)"
:title="article.linkurl"
>
{{ truncate(article.shortdescription, 95) }}
</a>
</td>
<td style="text-align: center; font-weight: 500;">{{ article.clicks }}</td>
<td class="actions">
<router-link :to="`/knowledgebase/${article.linkid}`" class="btn btn-sm btn-secondary">
View
</router-link>
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-sm btn-secondary">
Edit
</router-link>
</td>
</tr>
<tr v-if="articles.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No articles found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { knowledgebaseApi, applicationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const loading = ref(true)
const articles = ref([])
const topics = ref([])
const stats = ref(null)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles })
const perPage = ref(20)
const totalPages = ref(1)
const topicFilter = ref('')
const sort = ref('clicks')
const order = ref('desc')
let searchTimeout = null
onMounted(async () => {
await Promise.all([
loadArticles(),
loadTopics(),
loadStats()
])
})
async function loadArticles() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value,
sort: sort.value,
order: order.value
}
if (search.value) params.search = search.value
if (topicFilter.value) params.appid = topicFilter.value
const response = await knowledgebaseApi.list(params)
articles.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading articles:', error)
} finally {
loading.value = false
}
}
async function loadTopics() {
try {
const response = await applicationsApi.list({ perpage: 1000 })
topics.value = response.data.data || []
} catch (error) {
console.error('Error loading topics:', error)
}
}
async function loadStats() {
try {
const response = await knowledgebaseApi.getStats()
stats.value = response.data.data
} catch (error) {
console.error('Error loading stats:', error)
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadArticles()
}, 300)
}
function goToPage(p) {
setPage(p)
loadArticles()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadArticles()
}
function toggleSort(column) {
if (sort.value === column) {
order.value = order.value === 'desc' ? 'asc' : 'desc'
} else {
sort.value = column
order.value = 'desc'
}
loadArticles()
}
function truncate(text, length) {
if (!text) return ''
if (text.length <= length) return text
return text.substring(0, length) + '...'
}
async function openArticle(article) {
try {
await knowledgebaseApi.trackClick(article.linkid)
article.clicks = (article.clicks || 0) + 1
if (stats.value) {
stats.value.totalclicks++
}
if (article.linkurl) {
window.open(article.linkurl, '_blank')
}
} catch (error) {
console.error('Error tracking click:', error)
if (article.linkurl) {
window.open(article.linkurl, '_blank')
}
}
}
</script>
<style scoped>
/* Knowledge Base specific styles only */
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.header-left h2 {
margin: 0;
}
.stats-badge {
background: var(--bg);
color: var(--link);
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
}
.article-link {
color: var(--text);
}
.article-link:hover {
color: var(--link);
}
.text-muted {
color: var(--text-light);
}
</style>

View File

@@ -1,321 +0,0 @@
<template>
<div class="detail-page">
<div class="page-header">
<h2>Machine Details</h2>
<div class="header-actions">
<router-link :to="`/print/machine-badge/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
Print Badge
</router-link>
<router-link :to="`/print/asset-label/machine/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
Print Label
</router-link>
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
Edit
</router-link>
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="machine">
<!-- Dual-bay sibling banner: a Dualpath pair is one physical machine -->
<div v-if="machine.dualpathpartner" class="dualpath-banner">
Dual-bay machine - sibling bay:
<router-link
v-if="machine.dualpathpartner.machineid"
:to="`/machines/${machine.dualpathpartner.machineid}`"
>{{ machine.dualpathpartner.assetnumber }}</router-link>
<span v-else>{{ machine.dualpathpartner.assetnumber }}</span>
</div>
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-image" v-if="machine.machine?.imageurl">
<img :src="machine.machine.imageurl" :alt="machine.machine.modelname || 'Model photo'" />
</div>
<div class="hero-content">
<div class="hero-title">
<h1>{{ machine.assetnumber }}</h1>
<span v-if="machine.name" class="hero-alias">{{ machine.name }}</span>
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-primary">
{{ machine.assettypename || 'Machine' }}
</span>
<span class="badge badge-lg" :class="getStatusClass(machine.statusname)">
{{ machine.statusname || 'Unknown' }}
</span>
<span v-if="heroWarranty" class="badge badge-lg" :style="{ background: heroWarranty.statuscolor, color: '#fff' }"
:title="heroWarranty.enddate ? `Warranty ends ${warrantyDate(heroWarranty.enddate)}` : 'Warranty'">
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
</span>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="machine.machine?.machinetypename">
<span class="hero-detail-label">Type</span>
<span class="hero-detail-value">{{ machine.machine.machinetypename }}</span>
</div>
<div class="hero-detail" v-if="machine.machine?.vendorname">
<span class="hero-detail-label">Vendor</span>
<span class="hero-detail-value">{{ machine.machine.vendorname }}</span>
</div>
<div class="hero-detail" v-if="machine.machine?.modelname">
<span class="hero-detail-label">Model</span>
<span class="hero-detail-value">{{ machine.machine.modelname }}</span>
</div>
<div class="hero-detail" v-if="machine.locationname">
<span class="hero-detail-label">Location</span>
<span class="hero-detail-value">{{ machine.locationname }}</span>
</div>
</div>
</div>
</div>
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
<!-- Main Content Grid -->
<div class="content-grid">
<!-- Left Column -->
<div class="content-column">
<!-- Identity Section -->
<div class="section-card">
<h3 class="section-title">Identity</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Asset Number</span>
<span class="info-value">{{ machine.assetnumber }}</span>
</div>
<div class="info-row" v-if="machine.name">
<span class="info-label">Name</span>
<span class="info-value">{{ machine.name }}</span>
</div>
<div class="info-row" v-if="isEnabled('gaugelabreference', 'machine') && machine.gaugelabreference">
<span class="info-label">Gauge Lab Reference</span>
<span class="info-value mono">{{ machine.gaugelabreference }}</span>
</div>
<div class="info-row" v-if="isEnabled('maintenancereference', 'machine') && machine.maintenancereference">
<span class="info-label">Maintenance Reference</span>
<span class="info-value mono">{{ machine.maintenancereference }}</span>
</div>
<div class="info-row" v-if="machine.serialnumber">
<span class="info-label">Serial Number</span>
<span class="info-value mono">{{ machine.serialnumber }}</span>
</div>
</div>
</div>
<!-- Hardware Section -->
<div class="section-card">
<h3 class="section-title">Hardware</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Type</span>
<span class="info-value">{{ machine.machine?.machinetypename || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ machine.machine?.vendorname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Model</span>
<span class="info-value">{{ machine.machine?.modelname || '-' }}</span>
</div>
</div>
</div>
<!-- Controller Section (for CNC machines) -->
<div class="section-card" v-if="machine.machine?.controllervendorname || machine.machine?.controllermodelname">
<h3 class="section-title">Controller</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ machine.machine?.controllervendorname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Model</span>
<span class="info-value">{{ machine.machine?.controllermodelname || '-' }}</span>
</div>
</div>
</div>
</div>
<!-- Right Column -->
<div class="content-column">
<!-- Location & Organization -->
<div class="section-card">
<h3 class="section-title">Location & Organization</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Location</span>
<span class="info-value">
<LocationMapTooltip
v-if="machine.mapx != null && machine.mapy != null"
:left="machine.mapx"
:top="machine.mapy"
:machineName="machine.assetnumber"
>
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
</LocationMapTooltip>
<span v-else>{{ machine.locationname || '-' }}</span>
</span>
</div>
<div class="info-row">
<span class="info-label">Business Unit</span>
<span class="info-value">{{ machine.businessunitname || '-' }}</span>
</div>
</div>
</div>
<!-- Machine Configuration -->
<div class="section-card">
<h3 class="section-title">Configuration</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Requires Manual Config</span>
<span class="info-value">
<span class="feature-tag" :class="{ active: machine.machine?.requiresmanualconfig }">
{{ machine.machine?.requiresmanualconfig ? 'Yes' : 'No' }}
</span>
</span>
</div>
</div>
</div>
<!-- Maintenance Section -->
<div class="section-card" v-if="machine.machine?.lastmaintenancedate || machine.machine?.nextmaintenancedate">
<h3 class="section-title">Maintenance</h3>
<div class="info-list">
<div class="info-row" v-if="machine.machine?.lastmaintenancedate">
<span class="info-label">Last Maintenance</span>
<span class="info-value">{{ formatDate(machine.machine.lastmaintenancedate) }}</span>
</div>
<div class="info-row" v-if="machine.machine?.nextmaintenancedate">
<span class="info-label">Next Maintenance</span>
<span class="info-value">{{ formatDate(machine.machine.nextmaintenancedate) }}</span>
</div>
<div class="info-row" v-if="machine.machine?.maintenanceintervaldays">
<span class="info-label">Interval</span>
<span class="info-value">{{ machine.machine.maintenanceintervaldays }} days</span>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="machine.assetid" />
<!-- Plugin-contributed detail panels (ADR-010), incl. Warranty -->
<PluginAssetPanels :assetid="machine.assetid" />
<!-- All relationships (dualpath, controls, ...) -->
<AssetRelationships v-if="machine.assetid" :assetid="machine.assetid" />
<!-- Notes -->
<div class="section-card" v-if="machine.notes">
<h3 class="section-title">Notes</h3>
<p class="notes-text">{{ machine.notes }}</p>
</div>
</div>
</div>
<!-- Audit Footer -->
<div class="audit-footer">
<span>Created {{ formatDate(machine.createddate) }}<template v-if="machine.createdby"> by {{ machine.createdby }}</template></span>
<span>Modified {{ formatDate(machine.modifieddate) }}<template v-if="machine.modifiedby"> by {{ machine.modifiedby }}</template></span>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Machine not found</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { machinesApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import PluginAssetPanels from '../../components/PluginAssetPanels.vue'
import AssetRelationships from '../../components/AssetRelationships.vue'
import { useWarrantyBadge } from '../../composables/warrantyBadge'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute()
const { isEnabled } = useIdentifierFlags()
const loading = ref(true)
const machine = ref(null)
const { heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.assetid)
// relationships render via the shared AssetRelationships card
onMounted(async () => {
try {
const response = await machinesApi.get(route.params.id)
machine.value = response.data.data
} catch (error) {
console.error('Error loading machine:', error)
} finally {
loading.value = false
}
})
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair' || s === 'spare') return 'badge-warning'
if (s === 'retired' || s === 'disposed') return 'badge-danger'
return 'badge-info'
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.dualpath-banner {
margin-bottom: 16px;
padding: 10px 14px;
border-radius: 6px;
background: var(--bg-card);
border: 1px solid var(--border);
border-left: 4px solid var(--primary);
color: var(--text);
font-size: 0.9rem;
}
.dualpath-banner a {
color: var(--link);
font-weight: 600;
}
.feature-tag {
display: inline-block;
padding: 0.3rem 0.625rem;
font-size: 0.875rem;
border-radius: 5px;
background: var(--bg);
color: var(--text-light);
}
.feature-tag.active {
background: #e3f2fd;
color: #1976d2;
}
@media (prefers-color-scheme: dark) {
.feature-tag.active {
background: #1e3a5f;
color: #60a5fa;
}
}
</style>

View File

@@ -1,706 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Machine' : 'New Machine' }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveMachine">
<!-- Identity Section -->
<h3 class="form-section-title">Identity</h3>
<div class="form-row">
<div class="form-group">
<label for="assetnumber">Asset Number *</label>
<input
id="assetnumber"
v-model="form.assetnumber"
type="text"
class="form-control"
required
/>
</div>
<div class="form-group">
<label for="name">Name / Alias</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
/>
<small class="form-help">Layperson-friendly label</small>
</div>
</div>
<div class="form-row" v-if="isEnabled('gaugelabreference', 'machine') || isEnabled('maintenancereference', 'machine')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'machine')">
<label for="gaugelabreference">Gauge Lab Reference</label>
<input
id="gaugelabreference"
v-model="form.gaugelabreference"
type="text"
class="form-control"
/>
<small class="form-help">Authoritative gauge lab asset reference (if tracked)</small>
</div>
<div class="form-group" v-if="isEnabled('maintenancereference', 'machine')">
<label for="maintenancereference">Maintenance Reference</label>
<input
id="maintenancereference"
v-model="form.maintenancereference"
type="text"
class="form-control"
/>
<small class="form-help">Maintenance system asset reference (if tracked)</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="serialnumber">Serial Number</label>
<input
id="serialnumber"
v-model="form.serialnumber"
type="text"
class="form-control"
/>
</div>
<div class="form-group">
<label for="statusid">Status</label>
<select
id="statusid"
v-model="form.statusid"
class="form-control"
>
<option value="">Select status...</option>
<option
v-for="s in statuses"
:key="s.statusid"
:value="s.statusid"
>
{{ s.status }}
</option>
</select>
</div>
</div>
<!-- Machine Hardware Section -->
<h3 class="form-section-title">Machine Hardware</h3>
<div class="form-row">
<div class="form-group">
<label for="machinetypeid">Machine Type</label>
<select
id="machinetypeid"
v-model="form.machinetypeid"
class="form-control"
>
<option value="">Select type...</option>
<option
v-for="mt in machineTypes"
:key="mt.machinetypeid"
:value="mt.machinetypeid"
>
{{ mt.machinetype }}
</option>
</select>
</div>
<div class="form-group">
<label for="vendorid">Vendor</label>
<select
id="vendorid"
v-model="form.vendorid"
class="form-control"
>
<option value="">Select vendor...</option>
<option
v-for="v in vendors"
:key="v.vendorid"
:value="v.vendorid"
>
{{ v.vendor }}
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="modelnumberid">Model</label>
<select
id="modelnumberid"
v-model="form.modelnumberid"
class="form-control"
>
<option value="">Select model...</option>
<option
v-for="m in filteredModels"
:key="m.modelnumberid"
:value="m.modelnumberid"
>
{{ m.modelnumber }}
</option>
</select>
<small class="form-help" v-if="form.vendorid">Filtered by selected vendor</small>
</div>
<div class="form-group"></div>
</div>
<!-- Controller Section (for CNC machines) -->
<h3 class="form-section-title">Controller</h3>
<div class="form-row">
<div class="form-group">
<label for="controllervendorid">Controller Vendor</label>
<select
id="controllervendorid"
v-model="form.controllervendorid"
class="form-control"
>
<option value="">Select vendor...</option>
<option
v-for="v in vendors"
:key="v.vendorid"
:value="v.vendorid"
>
{{ v.vendor }}
</option>
</select>
<small class="form-help">e.g., Fanuc, Siemens, Allen-Bradley</small>
</div>
<div class="form-group">
<label for="controllermodelid">Controller Model</label>
<select
id="controllermodelid"
v-model="form.controllermodelid"
class="form-control"
>
<option value="">Select model...</option>
<option
v-for="m in filteredControllerModels"
:key="m.modelnumberid"
:value="m.modelnumberid"
>
{{ m.modelnumber }}
</option>
</select>
<small class="form-help" v-if="form.controllervendorid">Filtered by controller vendor</small>
</div>
</div>
<!-- Location Section -->
<h3 class="form-section-title">Location & Organization</h3>
<div class="form-row">
<div class="form-group">
<label for="locationid">Location</label>
<select
id="locationid"
v-model="form.locationid"
class="form-control"
>
<option value="">Select location...</option>
<option
v-for="l in locations"
:key="l.locationid"
:value="l.locationid"
>
{{ l.locationname }}
</option>
</select>
</div>
<div class="form-group">
<label for="businessunitid">Business Unit</label>
<select
id="businessunitid"
v-model="form.businessunitid"
class="form-control"
>
<option value="">Select business unit...</option>
<option
v-for="bu in businessunits"
:key="bu.businessunitid"
:value="bu.businessunitid"
>
{{ bu.businessunit }}
</option>
</select>
</div>
</div>
<!-- Map Location Picker -->
<div class="form-group">
<label>Map Location</label>
<div class="map-location-control">
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
Position: {{ form.mapx }}, {{ form.mapy }}
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
</div>
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
Set Location on Map
</button>
</div>
</div>
<!-- Map Picker Modal -->
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
<div class="map-modal-content">
<ShopFloorMap
:pickerMode="true"
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
:theme="currentTheme"
@positionPicked="handlePositionPicked"
/>
</div>
<template #footer>
<button class="btn btn-secondary" @click="showMapPicker = false">Cancel</button>
<button class="btn btn-primary" @click="confirmMapPosition">Confirm Location</button>
</template>
</Modal>
<!-- Configuration Section -->
<h3 class="form-section-title">Configuration</h3>
<div class="form-row">
<div class="form-group checkbox-group">
<label>
<input type="checkbox" v-model="form.requiresmanualconfig" />
Requires Manual Config
</label>
<small class="form-help">Machine a tech must configure by hand (e.g. driven by multiple PCs)</small>
</div>
</div>
<!-- Controlling PC Selection -->
<h3 class="form-section-title">Connected PC</h3>
<div class="form-row">
<div class="form-group">
<label for="controllingpc">Controlling PC</label>
<select
id="controllingpc"
v-model="controllingPcId"
class="form-control"
>
<option :value="null">None (standalone)</option>
<option
v-for="pc in pcs"
:key="pc.assetid"
:value="pc.assetid"
>
{{ pc.assetnumber }}{{ pc.name ? ` (${pc.name})` : '' }}
</option>
</select>
<small class="form-help">Select the PC that controls this machine</small>
</div>
<div class="form-group" v-if="controllingPcId">
<label for="connectiontype">Connection Type</label>
<select
id="connectiontype"
v-model="relationshipTypeId"
class="form-control"
>
<option
v-for="rt in relationshipTypes"
:key="rt.relationshiptypeid"
:value="rt.relationshiptypeid"
>
{{ rt.relationshiptype }}
</option>
</select>
<small class="form-help">How the PC connects to this machine</small>
</div>
</div>
<!-- Notes Section -->
<h3 class="form-section-title">Notes</h3>
<div class="form-group">
<textarea
id="notes"
v-model="form.notes"
class="form-control"
rows="3"
placeholder="Additional notes..."
></textarea>
</div>
<!-- Site-defined custom fields for machines -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="MACHINE_ASSETTYPEID" :assetid="currentAssetId" />
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save Machine' }}
</button>
<router-link to="/machines" class="btn btn-secondary">Cancel</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi, relationshipTypesApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
import { currentTheme } from '../../stores/theme'
import { useIdentifierFlags } from '../../composables/identifierSettings'
import { apiError } from '../../utils/apiError'
const { isEnabled } = useIdentifierFlags()
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for machines (see /api/assets/types).
const MACHINE_ASSETTYPEID = 1
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const showMapPicker = ref(false)
const tempMapPosition = ref(null)
const form = ref({
assetnumber: '',
name: '',
gaugelabreference: '',
maintenancereference: '',
serialnumber: '',
statusid: 1,
machinetypeid: '',
vendorid: '',
modelnumberid: '',
controllervendorid: '',
controllermodelid: '',
locationid: '',
businessunitid: '',
requiresmanualconfig: false,
islocationonly: false,
notes: '',
mapx: null,
mapy: null
})
const machineTypes = ref([])
const statuses = ref([])
const vendors = ref([])
const locations = ref([])
const models = ref([])
const businessunits = ref([])
const pcs = ref([])
const relationshipTypes = ref([])
const controllingPcId = ref(null)
const relationshipTypeId = ref(null)
const existingRelationshipId = ref(null)
const currentMachine = ref(null)
// Filter models by selected machine vendor
const filteredModels = computed(() => {
if (!form.value.vendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.vendorid)
})
// Filter models by selected controller vendor
const filteredControllerModels = computed(() => {
if (!form.value.controllervendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.controllervendorid)
})
// Clear model selection when vendor changes
watch(() => form.value.vendorid, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) {
const currentModel = models.value.find(m => m.modelnumberid === form.value.modelnumberid)
if (currentModel && currentModel.vendorid !== newVal) {
form.value.modelnumberid = ''
}
}
})
// Clear controller model when controller vendor changes
watch(() => form.value.controllervendorid, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) {
const currentModel = models.value.find(m => m.modelnumberid === form.value.controllermodelid)
if (currentModel && currentModel.vendorid !== newVal) {
form.value.controllermodelid = ''
}
}
})
onMounted(async () => {
try {
// Load reference data in parallel
const [typesRes, statusRes, vendorRes, locRes, allModels, buRes, pcsRes, relTypesRes] = await Promise.all([
machinesApi.types.list(),
assetsApi.statuses.list(),
vendorsApi.list({ perpage: 500 }),
locationsApi.list({ perpage: 500 }),
modelsApi.listAll(), // backend caps perpage at 100; page through all
businessunitsApi.list({ perpage: 500 }),
computersApi.list({ perpage: 500 }),
assetsApi.types.list() // Used for relationship types, will fix below
])
machineTypes.value = typesRes.data.data || []
statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || []
locations.value = locRes.data.data || []
models.value = allModels
businessunits.value = buRes.data.data || []
pcs.value = pcsRes.data.data || []
// Load relationship types separately
try {
const relRes = await relationshipTypesApi.list()
relationshipTypes.value = relRes.data.data || []
} catch (e) {
// Fallback - use hardcoded Controls type
relationshipTypes.value = [{ relationshiptypeid: 1, relationshiptype: 'Controls' }]
}
// Set default relationship type to "Controls" if available (name is data, compare folded)
const controlsType = relationshipTypes.value.find(t => (t.relationshiptype || '').toLowerCase() === 'controls')
if (controlsType) {
relationshipTypeId.value = controlsType.relationshiptypeid
}
// Load machine if editing
if (isEdit.value) {
const response = await machinesApi.get(route.params.id)
const data = response.data.data
currentAssetId.value = data.assetid || null
currentMachine.value = data
form.value = {
assetnumber: data.assetnumber || '',
name: data.name || '',
gaugelabreference: data.gaugelabreference || '',
maintenancereference: data.maintenancereference || '',
serialnumber: data.serialnumber || '',
statusid: data.statusid || 1,
machinetypeid: data.machine?.machinetypeid || '',
vendorid: data.machine?.vendorid || '',
modelnumberid: data.machine?.modelnumberid || '',
controllervendorid: data.machine?.controllervendorid || '',
controllermodelid: data.machine?.controllermodelid || '',
locationid: data.locationid || '',
businessunitid: data.businessunitid || '',
requiresmanualconfig: data.machine?.requiresmanualconfig || false,
islocationonly: data.machine?.islocationonly || false,
notes: data.notes || '',
mapx: data.mapx ?? null,
mapy: data.mapy ?? null
}
// Load existing relationships to find controlling PC
if (data.assetid) {
try {
const relResponse = await assetsApi.getRelationships(data.assetid)
const relationships = relResponse.data.data || { incoming: [], outgoing: [] }
// Check incoming relationships for a controlling PC
for (const rel of relationships.incoming || []) {
if (rel.sourceasset?.assettypename === 'computer' && (rel.relationshiptypename || '').toLowerCase() === 'controls') {
controllingPcId.value = rel.sourceasset.assetid
relationshipTypeId.value = rel.relationshiptypeid
existingRelationshipId.value = rel.assetrelationshipid
break
}
}
} catch (e) {
}
}
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
} finally {
loading.value = false
}
})
function handlePositionPicked(position) {
tempMapPosition.value = position
}
function confirmMapPosition() {
if (tempMapPosition.value) {
form.value.mapx = tempMapPosition.value.left
form.value.mapy = tempMapPosition.value.top
}
showMapPicker.value = false
}
function clearMapPosition() {
form.value.mapx = null
form.value.mapy = null
tempMapPosition.value = null
}
async function saveMachine() {
error.value = ''
saving.value = true
try {
const data = {
assetnumber: form.value.assetnumber,
name: form.value.name || null,
gaugelabreference: form.value.gaugelabreference || null,
maintenancereference: form.value.maintenancereference || null,
serialnumber: form.value.serialnumber || null,
statusid: form.value.statusid || 1,
machinetypeid: form.value.machinetypeid || null,
vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null,
controllervendorid: form.value.controllervendorid || null,
controllermodelid: form.value.controllermodelid || null,
locationid: form.value.locationid || null,
businessunitid: form.value.businessunitid || null,
requiresmanualconfig: form.value.requiresmanualconfig,
islocationonly: form.value.islocationonly,
notes: form.value.notes || null,
mapx: form.value.mapx,
mapy: form.value.mapy
}
let savedMachine
let assetId
if (isEdit.value) {
const response = await machinesApi.update(route.params.id, data)
savedMachine = response.data.data
assetId = savedMachine.assetid
} else {
const response = await machinesApi.create(data)
savedMachine = response.data.data
assetId = savedMachine.assetid
}
// Handle relationship (controlling PC)
await saveRelationship(assetId)
// Persist custom-field values against the asset id.
if (assetId && customFieldsRef.value) {
try {
await customFieldsRef.value.save(assetId)
} catch (cfErr) {
console.error('Error saving custom fields:', cfErr)
}
}
router.push(`/machines/${savedMachine.machine?.machineid || route.params.id}`)
} catch (err) {
console.error('Error saving machine:', err)
error.value = apiError(err, 'Failed to save machine')
} finally {
saving.value = false
}
}
async function saveRelationship(assetId) {
// If no PC selected and no existing relationship, nothing to do
if (!controllingPcId.value && !existingRelationshipId.value) {
return
}
// If clearing the relationship
if (!controllingPcId.value && existingRelationshipId.value) {
await assetsApi.deleteRelationship(existingRelationshipId.value)
return
}
// If PC is selected
if (controllingPcId.value) {
// If there's an existing relationship, delete it first
if (existingRelationshipId.value) {
await assetsApi.deleteRelationship(existingRelationshipId.value)
}
// Create new relationship (PC controls Machine, so PC is source, Machine is target)
await assetsApi.createRelationship({
sourceassetid: controllingPcId.value,
targetassetid: assetId,
relationshiptypeid: relationshipTypeId.value
})
}
}
</script>
<style scoped>
.form-section-title {
font-size: 1rem;
font-weight: 600;
color: var(--text);
margin: 1.5rem 0 0.75rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.form-section-title:first-of-type {
margin-top: 0;
}
.checkbox-group {
display: flex;
flex-direction: column;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
}
.map-location-control {
display: flex;
align-items: center;
gap: 1rem;
}
.current-position {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--bg);
border-radius: 4px;
font-family: monospace;
}
.map-modal-content {
height: calc(90vh - 140px);
}
.map-modal-content :deep(.shopfloor-map) {
height: 100%;
}
.map-modal-content :deep(.map-container) {
height: calc(100% - 50px);
}
.form-help {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
</style>

View File

@@ -1,147 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>Machines</h2>
<router-link to="/print/asset-label-batch/machine" class="btn btn-secondary" target="_blank">Print Labels</router-link>
<router-link to="/machines/new" class="btn btn-primary">Add Machine</router-link>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search machines..."
@input="debouncedSearch"
/>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Machine #</th>
<th>Name</th>
<th>Serial Number</th>
<th>Type</th>
<th>Vendor</th>
<th>Status</th>
<th>Location</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in machines" :key="item.assetid" class="clickable-row" @click="$router.push(`/machines/${item.machine?.machineid || item.assetid}`)">
<td>
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
</td>
<td>{{ item.name || '-' }}</td>
<td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.machine?.machinetypename || '-' }}</td>
<td>{{ item.machine?.vendorname || '-' }}</td>
<td>
<span class="badge" :style="colorStyle(item.statuscolor)">
{{ item.statusname || 'Unknown' }}
</span>
</td>
<td>{{ item.locationname || '-' }}</td>
<td class="actions" @click.stop>
<router-link
:to="`/machines/${item.machine?.machineid || item.assetid}`"
class="btn btn-secondary btn-sm"
>
View
</router-link>
</td>
</tr>
<tr v-if="machines.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">
No machines found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { machinesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery'
const machines = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadMachines })
const totalPages = ref(1)
const perPage = ref(20)
let searchTimeout = null
onMounted(() => {
loadMachines()
})
async function loadMachines() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
if (search.value) params.search = search.value
const response = await machinesApi.list(params)
machines.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (error) {
console.error('Error loading machines:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadMachines()
}, 300)
}
function goToPage(p) {
setPage(p)
loadMachines()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadMachines()
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
</style>

View File

@@ -14,6 +14,8 @@ export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
// Core specs plus plugin-frontend specs, which the pretest stage copies
// into src/.plugins-staged/ (under root, so this glob finds them).
include: ['src/**/*.spec.js'],
},
})