Printer assignment end to end, and a container that keeps a site's files
Assign printers to a MACHINE and let whichever PC controls it install them, with the observed half kept strictly apart from the assigned half: a collector report can never write an assignment row. Adoption is explicit. Adds a singular relationship type so defaultprinter can mean exactly one, client scripts that converge a bay's queues and correct a drifted one in place, and a wave gate so a fleet-wide driver rollout cannot stampede its own share. On the container side: the compose stack never persisted the instance directory, so a rebuild discarded plugins.json and every upload. It is a volume now, the image ships instance/ owned by the app user so the mountpoint is writable, and the database moves to MySQL 8.4 LTS, which the Windows runbook has recommended since 8.0 went end of life. The image also could not build at all: the frontend stage never copied the plugin staging script it runs. Adds a topology migration guide covering IIS to Docker and back.
This commit is contained in:
134
plugins/computers/frontend/views/CollectorSettings.vue
Normal file
134
plugins/computers/frontend/views/CollectorSettings.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Asset reporter</h2>
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
Shop-floor PCs report what they are to this server. The script below is
|
||||
generated with THIS site's values, so it downloads ready to deploy - there
|
||||
is nothing in it to find and edit.
|
||||
</p>
|
||||
|
||||
<div class="card form-card">
|
||||
<div v-if="message" class="settings-success">{{ message }}</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Routable ranges</label>
|
||||
<input v-model="ranges" type="text" class="form-control"
|
||||
placeholder="10.20.0.0/23,10.21.4.0/26" />
|
||||
<p class="field-hint">
|
||||
Comma-separated CIDRs for this site's corporate network. A bay with two
|
||||
NICs - a private controller NIC and a routable one - reports the
|
||||
address in these ranges. Leave it empty and the PC reports whichever
|
||||
NIC carries the default route, which is correct at most sites and needs
|
||||
no configuration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" @click="save" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<h3>Download the reporter</h3>
|
||||
<p class="field-hint">
|
||||
Stamped with this server's URL and the ranges above, and with the version
|
||||
that generated it, so a script found on a bay can be traced back here.
|
||||
Re-download after upgrading ShopDB.
|
||||
</p>
|
||||
|
||||
<button class="btn btn-secondary" @click="download" :disabled="downloading">
|
||||
{{ downloading ? 'Generating...' : 'Download Report-AssetToShopDB.ps1' }}
|
||||
</button>
|
||||
|
||||
<p v-if="digest" class="field-hint mono">
|
||||
SHA-256 {{ digest }}
|
||||
</p>
|
||||
|
||||
<p class="field-hint">
|
||||
<strong>The collector key is not in this file, deliberately.</strong> It
|
||||
lands on every shop-floor PC, and a token spread across hundreds of bays
|
||||
cannot be rotated quietly. Mint a token scoped to
|
||||
<code>collector.ingest</code> and provision it as
|
||||
<code>HKLM:\SOFTWARE\GE\ShopDB</code> value <code>CollectorKey</code> -
|
||||
the adoption guide has worked examples for Intune, DSC and GE-Enforce.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { settingsApi } from '@/api'
|
||||
import api from '@/api'
|
||||
|
||||
const RANGES_KEY = 'computers_routableranges'
|
||||
|
||||
const ranges = ref('')
|
||||
const saving = ref(false)
|
||||
const downloading = ref(false)
|
||||
const digest = ref('')
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await settingsApi.list({ category: 'computers' })
|
||||
const row = (response.data.data || []).find(entry => entry.key === RANGES_KEY)
|
||||
if (row) ranges.value = row.value || ''
|
||||
} catch (loadError) {
|
||||
error.value = 'Could not load settings'
|
||||
console.error(loadError)
|
||||
}
|
||||
})
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
message.value = ''
|
||||
error.value = ''
|
||||
try {
|
||||
await settingsApi.update(RANGES_KEY, String(ranges.value ?? ''))
|
||||
message.value = 'Saved. Re-download the script so it carries the new ranges.'
|
||||
} catch (saveError) {
|
||||
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function download() {
|
||||
downloading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
// responseType text: this is a script, not JSON, and the hash the server
|
||||
// publishes is of exactly these bytes.
|
||||
const response = await api.get('/computers/client-script', { responseType: 'text' })
|
||||
digest.value = response.headers['x-script-sha256'] || ''
|
||||
|
||||
const blob = new Blob([response.data], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'Report-AssetToShopDB.ps1'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (downloadError) {
|
||||
error.value = downloadError.response?.status === 403
|
||||
? 'Only an administrator can download the reporter'
|
||||
: 'Could not generate the script'
|
||||
console.error(downloadError)
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono { font-family: monospace; word-break: break-all; }
|
||||
.form-card h3 { margin-top: 0; }
|
||||
</style>
|
||||
@@ -245,6 +245,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printers assigned to this PC ITSELF, which replace whatever the
|
||||
machine it controls is assigned. Same component the machine form
|
||||
uses: two copies of this UI would drift, and the two ends of an
|
||||
override disagreeing is the bug nobody would spot. -->
|
||||
<PrinterAssignmentPicker ref="printerPickerRef" :assetid="currentAssetId" scope="pc" />
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
@@ -317,16 +323,21 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '@/api'
|
||||
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi,
|
||||
printersApi, relationshipTypesApi } from '@/api'
|
||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||
from '@/composables/mapConfig'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const toast = useToast()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const route = useRoute()
|
||||
@@ -338,6 +349,7 @@ const isEdit = computed(() => !!route.params.id)
|
||||
// values are keyed by the underlying asset id, captured on load / create.
|
||||
const COMPUTER_ASSETTYPEID = 2
|
||||
const customFieldsRef = ref(null)
|
||||
const printerPickerRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
|
||||
// PC Number (assetnumber) defaults to the serial number while the user hasn't
|
||||
@@ -412,6 +424,7 @@ const models = ref([])
|
||||
const locations = ref([])
|
||||
const operatingsystems = ref([])
|
||||
|
||||
|
||||
// Default PC Number to serial while the user hasn't typed their own (new PC only)
|
||||
watch(() => form.value.serialnumber, (serial) => {
|
||||
if (!isEdit.value && !manualPcNumber.value && serial) {
|
||||
@@ -438,7 +451,8 @@ onMounted(async () => {
|
||||
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
locationsApi.list({ perpage: 100 }),
|
||||
operatingsystemsApi.list({ perpage: 100 }),
|
||||
computersApi.protocols.list()
|
||||
computersApi.protocols.list(),
|
||||
// Handles its own failure: a site without printers still gets a form.
|
||||
])
|
||||
|
||||
pcTypes.value = ptRes.data.data || []
|
||||
@@ -570,6 +584,22 @@ async function savePC() {
|
||||
}
|
||||
}
|
||||
|
||||
// Printer assignment through the shared picker, which reconciles the whole
|
||||
// set in one call rather than row at a time.
|
||||
//
|
||||
// Toasted, not thrown: the PC itself is saved by now, so staying on a form
|
||||
// whose Save would create a second PC is the worse failure - but an
|
||||
// assignment that quietly did not happen is the bug this feature exists to
|
||||
// stop, so it has to be said out loud.
|
||||
if (assetId && printerPickerRef.value) {
|
||||
try {
|
||||
await printerPickerRef.value.save(assetId)
|
||||
} catch (printerError) {
|
||||
console.error('Error saving printer assignment:', printerError)
|
||||
toast.error(apiError(printerError, 'PC saved, but the printer assignment did not'))
|
||||
}
|
||||
}
|
||||
|
||||
router.push('/pcs')
|
||||
} catch (err) {
|
||||
console.error('Error saving PC:', err)
|
||||
@@ -603,6 +633,30 @@ async function savePC() {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Scrolls rather than pushing the rest of the form off screen: a site can hold
|
||||
dozens of printers. */
|
||||
.printer-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.printer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.printer-meta {
|
||||
color: var(--text-light);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.map-location-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user