backups plugin with NTLARS revision history; shopfloor dashboard upcoming cards and state chips
This commit is contained in:
21
plugins/backups/frontend/routes.js
Normal file
21
plugins/backups/frontend/routes.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Backups plugin routes.
|
||||
*
|
||||
* One route: the per-asset revision history. The asset-detail panel (ADR-010
|
||||
* Path A) lists recent revisions but the generic renderer has no per-item
|
||||
* actions, and downloading a .reg needs a per-revision control plus a dialect
|
||||
* choice. So the panel links here via its manage link rather than the renderer
|
||||
* growing a backups-shaped feature.
|
||||
*
|
||||
* meta.plugin = 'backups' so the ADR-009 guard redirects when the backend
|
||||
* plugin is disabled. requiresAuth because every backups API route is behind
|
||||
* JWT + a backups.* permission; an anonymous visit would only render errors.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'backups/asset/:assetid',
|
||||
name: 'backups-asset-history',
|
||||
component: () => import('./views/BackupHistory.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'backups' }
|
||||
}
|
||||
]
|
||||
210
plugins/backups/frontend/views/BackupHistory.vue
Normal file
210
plugins/backups/frontend/views/BackupHistory.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div class="bh">
|
||||
<div class="bh-head">
|
||||
<router-link :to="`/assets/${assetid}`" class="bh-back">← Back to asset</router-link>
|
||||
<h1>Configuration Backups</h1>
|
||||
<p v-if="assetname" class="bh-sub">{{ assetname }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="bh-note">Loading...</div>
|
||||
<div v-else-if="error" class="bh-error">{{ error }}</div>
|
||||
<div v-else-if="!revisions.length" class="bh-note">
|
||||
No backups on record for this asset yet.
|
||||
</div>
|
||||
|
||||
<table v-else class="bh-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Captured</th>
|
||||
<th>Kind</th>
|
||||
<th>From PC</th>
|
||||
<th>Hash</th>
|
||||
<th>Changes</th>
|
||||
<th class="bh-actions-col">Download</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="rev in revisions" :key="rev.backuprevisionid"
|
||||
:class="{ 'bh-latest': rev.islatest }">
|
||||
<td>
|
||||
{{ formatWhen(rev.collectedat || rev.createdat) }}
|
||||
<span v-if="rev.islatest" class="bh-badge">current</span>
|
||||
</td>
|
||||
<td>{{ rev.backupkind }}</td>
|
||||
<td>{{ rev.sourcehostname || '-' }}</td>
|
||||
<td class="bh-mono">{{ rev.shorthash }}</td>
|
||||
<td>
|
||||
<button v-if="rev.storagebackend === 'shopdb'"
|
||||
class="bh-link" @click="loadDiff(rev)">
|
||||
{{ diffs[rev.backuprevisionid] ? 'hide' : 'compare' }}
|
||||
</button>
|
||||
<span v-else class="bh-muted">-</span>
|
||||
</td>
|
||||
<td class="bh-actions">
|
||||
<!-- Share-backed revisions are not streamed by ShopDB; show the
|
||||
UNC path so the tech can open it directly. -->
|
||||
<template v-if="rev.storagebackend === 'share'">
|
||||
<code class="bh-path" :title="rev.sharepath">{{ rev.sharepath }}</code>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button v-for="fmt in rev.formats" :key="fmt.id"
|
||||
class="bh-btn" :title="fmt.hint"
|
||||
@click="download(rev, fmt)">
|
||||
{{ fmt.label }}
|
||||
</button>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-for="rev in revisions" :key="`d-${rev.backuprevisionid}`">
|
||||
<div v-if="diffs[rev.backuprevisionid]" class="bh-diff">
|
||||
<h3>
|
||||
Changes in revision {{ rev.backuprevisionid }}
|
||||
<span v-if="diffs[rev.backuprevisionid].againstid">
|
||||
vs {{ diffs[rev.backuprevisionid].againstid }}
|
||||
</span>
|
||||
</h3>
|
||||
<p v-if="diffs[rev.backuprevisionid].message" class="bh-muted">
|
||||
{{ diffs[rev.backuprevisionid].message }}
|
||||
</p>
|
||||
<table v-else-if="diffs[rev.backuprevisionid].changes.length" class="bh-table">
|
||||
<thead>
|
||||
<tr><th>Key</th><th>Value</th><th>Before</th><th>After</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(c, i) in diffs[rev.backuprevisionid].changes" :key="i">
|
||||
<td class="bh-mono">{{ c.keypath || '(root)' }}</td>
|
||||
<td class="bh-mono">{{ c.valuename }}</td>
|
||||
<td class="bh-before">{{ display(c.before) }}</td>
|
||||
<td class="bh-after">{{ display(c.after) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="bh-muted">No differences.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import api from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const assetid = route.params.assetid
|
||||
|
||||
const revisions = ref([])
|
||||
const diffs = ref({})
|
||||
const assetname = ref('')
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
function formatWhen(value) {
|
||||
if (!value) return 'unknown'
|
||||
const d = new Date(value)
|
||||
return isNaN(d) ? value : d.toLocaleString()
|
||||
}
|
||||
|
||||
function display(value) {
|
||||
if (value === null || value === undefined) return '(absent)'
|
||||
if (value === '') return '(empty)'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const response = await api.get(`/backups/asset/${assetid}`)
|
||||
revisions.value = response.data.data || []
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.error?.message || 'Failed to load backups.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
try {
|
||||
const asset = await api.get(`/assets/${assetid}`)
|
||||
assetname.value = asset.data.data?.name || ''
|
||||
} catch (e) {
|
||||
// Name is decoration; the history is still usable without it.
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiff(rev) {
|
||||
if (diffs.value[rev.backuprevisionid]) {
|
||||
delete diffs.value[rev.backuprevisionid]
|
||||
diffs.value = { ...diffs.value }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/backups/revisions/${rev.backuprevisionid}/diff`)
|
||||
diffs.value = { ...diffs.value, [rev.backuprevisionid]: response.data.data }
|
||||
} catch (e) {
|
||||
diffs.value = {
|
||||
...diffs.value,
|
||||
[rev.backuprevisionid]: {
|
||||
changes: [],
|
||||
message: e.response?.data?.error?.message || 'Could not build a diff.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function download(rev, fmt) {
|
||||
// responseType blob so the UTF-16LE .reg is not mangled by JSON parsing;
|
||||
// the Content-Disposition filename is not readable cross-origin, so rebuild
|
||||
// it here to match what the server sends.
|
||||
const response = await api.get(
|
||||
`/backups/revisions/${rev.backuprevisionid}/download?format=${fmt.id}`,
|
||||
{ responseType: 'blob' })
|
||||
const suffix = fmt.id === 'wow6432node' ? '-wow6432node' : ''
|
||||
const name = `${rev.sourcefilename || rev.backupkind}${suffix}${fmt.ext}`
|
||||
.replace(/(\.reg)+$/, '.reg')
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(response.data)
|
||||
link.download = name
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bh { padding: 1.5rem; }
|
||||
.bh-head { margin-bottom: 1rem; }
|
||||
.bh-back { font-size: 0.85rem; }
|
||||
.bh-sub { color: #666; margin: 0.2rem 0 0; }
|
||||
.bh-note, .bh-muted { color: #666; }
|
||||
.bh-error { color: #c00; }
|
||||
.bh-table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; }
|
||||
.bh-table th, .bh-table td {
|
||||
text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid #e0e0e0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.bh-latest { background: #f4f9ff; }
|
||||
.bh-badge {
|
||||
font-size: 0.7rem; background: #0d6efd; color: #fff;
|
||||
border-radius: 3px; padding: 0.05rem 0.35rem; margin-left: 0.4rem;
|
||||
}
|
||||
.bh-mono, .bh-path { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.82rem; }
|
||||
.bh-path { word-break: break-all; }
|
||||
.bh-actions-col { width: 22rem; }
|
||||
.bh-btn {
|
||||
font-size: 0.78rem; margin: 0 0.3rem 0.3rem 0; padding: 0.25rem 0.5rem;
|
||||
border: 1px solid #0d6efd; background: #fff; color: #0d6efd;
|
||||
border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
.bh-btn:hover { background: #0d6efd; color: #fff; }
|
||||
.bh-link {
|
||||
background: none; border: none; color: #0d6efd; cursor: pointer;
|
||||
padding: 0; font-size: 0.82rem; text-decoration: underline;
|
||||
}
|
||||
.bh-diff { margin: 0 0 1.5rem; }
|
||||
.bh-before { color: #b00; }
|
||||
.bh-after { color: #070; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user