Remediate the 2026-09-03 feature-test findings
This commit is contained in:
@@ -7,7 +7,7 @@ from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, Audit
|
||||
|
||||
from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess
|
||||
|
||||
from shopdb.api import require_permission, require_role, apply_import_timestamps
|
||||
from shopdb.api import require_permission, require_role, apply_import_timestamps, intarg
|
||||
|
||||
computers_bp = Blueprint('computers', __name__)
|
||||
|
||||
@@ -258,13 +258,42 @@ def _computer_access_links(comp, domain=None):
|
||||
return links
|
||||
|
||||
|
||||
def _ip_comtype():
|
||||
"""The 'IP' communication type, created if this site has none.
|
||||
|
||||
Both the create and the update path did `if ip_comtype:` and silently did
|
||||
nothing when the row was missing - so a person typed an address, saved, got
|
||||
a 200, and the address was gone. A missing reference row is a seeding gap,
|
||||
not a reason to discard what somebody typed.
|
||||
"""
|
||||
comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
||||
if comtype is None:
|
||||
comtype = CommunicationType(comtype='IP', description='IP address')
|
||||
db.session.add(comtype)
|
||||
db.session.flush()
|
||||
return comtype
|
||||
|
||||
|
||||
def _sync_access_methods(comp, data):
|
||||
"""Replace a computer's enabled protocols from data['accessmethods'] (a list
|
||||
of {protocolid, portoverride?}). No-op if the key is absent, so callers that
|
||||
don't touch access aren't affected."""
|
||||
don't touch access aren't affected.
|
||||
|
||||
Returns a {'old': [...], 'new': [...]} change entry when the set actually
|
||||
moved, or None. The caller records it: which remote-access protocols reach
|
||||
a shop-floor PC, and on which ports, is exactly the kind of change an audit
|
||||
log exists to remember, and this wrote nothing at all.
|
||||
"""
|
||||
if 'accessmethods' not in data:
|
||||
return
|
||||
return None
|
||||
desired = data.get('accessmethods') or []
|
||||
|
||||
def describe(rows):
|
||||
return sorted('%s:%s' % (row.protocolid, row.portoverride or 'default')
|
||||
for row in rows)
|
||||
|
||||
before = describe(ComputerAccess.query.filter_by(
|
||||
computerid=comp.computerid).all())
|
||||
ComputerAccess.query.filter_by(computerid=comp.computerid).delete()
|
||||
seen = set()
|
||||
for m in desired:
|
||||
@@ -287,6 +316,13 @@ def _sync_access_methods(comp, data):
|
||||
isactive=True,
|
||||
))
|
||||
|
||||
db.session.flush()
|
||||
after = describe(ComputerAccess.query.filter_by(
|
||||
computerid=comp.computerid).all())
|
||||
if before != after:
|
||||
return {'old': before, 'new': after}
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Computers CRUD
|
||||
@@ -380,20 +416,20 @@ def list_computers():
|
||||
)
|
||||
|
||||
# Computer type filter
|
||||
if type_id := request.args.get('typeid', request.args.get('type_id')):
|
||||
query = query.filter(Computer.computertypeid == int(type_id))
|
||||
if (type_id := intarg('typeid', intarg('type_id'))) is not None:
|
||||
query = query.filter(Computer.computertypeid == type_id)
|
||||
|
||||
# OS filter
|
||||
if os_id := request.args.get('osid', request.args.get('os_id')):
|
||||
query = query.filter(Computer.osid == int(os_id))
|
||||
if (os_id := intarg('osid', intarg('os_id'))) is not None:
|
||||
query = query.filter(Computer.osid == os_id)
|
||||
|
||||
# Location filter
|
||||
if location_id := request.args.get('locationid', request.args.get('location_id')):
|
||||
query = query.filter(Asset.locationid == int(location_id))
|
||||
if (location_id := intarg('locationid', intarg('location_id'))) is not None:
|
||||
query = query.filter(Asset.locationid == location_id)
|
||||
|
||||
# Business unit filter
|
||||
if bu_id := request.args.get('businessunitid', request.args.get('businessunit_id')):
|
||||
query = query.filter(Asset.businessunitid == int(bu_id))
|
||||
if (bu_id := intarg('businessunitid', intarg('businessunit_id'))) is not None:
|
||||
query = query.filter(Asset.businessunitid == bu_id)
|
||||
|
||||
# Shopfloor filter (by the Shopfloor computer type)
|
||||
if shopfloor := request.args.get('shopfloor'):
|
||||
@@ -587,7 +623,7 @@ def create_computer():
|
||||
|
||||
# Optional primary IP communication
|
||||
if data.get('ipaddress'):
|
||||
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
||||
ip_comtype = _ip_comtype()
|
||||
if ip_comtype:
|
||||
db.session.add(Communication(
|
||||
assetid=asset.assetid,
|
||||
@@ -596,7 +632,9 @@ def create_computer():
|
||||
isprimary=True,
|
||||
))
|
||||
|
||||
# Remote-access protocols
|
||||
# Remote-access protocols. The return value is not recorded here: a create
|
||||
# is already described in full by its 'created' entry, and there is no
|
||||
# previous state to compare against.
|
||||
_sync_access_methods(comp, data)
|
||||
|
||||
# Preserve legacy timestamps in import mode (no-op otherwise)
|
||||
@@ -697,20 +735,27 @@ def update_computer(computer_id: int):
|
||||
ip = (data.get('ipaddress') or '').strip()
|
||||
primary = Communication.query.filter_by(
|
||||
assetid=asset.assetid, isprimary=True).first()
|
||||
# Recorded, like every other field on this form. The address a PC
|
||||
# answers on is how somebody finds it later; changing it wrote no audit
|
||||
# entry at all, so a machine that moved had no history of moving.
|
||||
oldip = primary.ipaddress if primary else None
|
||||
if (oldip or None) != (ip or None):
|
||||
changes['ipaddress'] = {'old': oldip, 'new': ip or None}
|
||||
if ip:
|
||||
if primary:
|
||||
primary.ipaddress = ip
|
||||
else:
|
||||
ip_comtype = CommunicationType.query.filter_by(comtype='IP').first()
|
||||
if ip_comtype:
|
||||
db.session.add(Communication(
|
||||
assetid=asset.assetid, comtypeid=ip_comtype.comtypeid,
|
||||
ipaddress=ip, isprimary=True))
|
||||
db.session.add(Communication(
|
||||
assetid=asset.assetid,
|
||||
comtypeid=_ip_comtype().comtypeid,
|
||||
ipaddress=ip, isprimary=True))
|
||||
elif primary:
|
||||
primary.ipaddress = None
|
||||
|
||||
# Remote-access protocols
|
||||
_sync_access_methods(comp, data)
|
||||
accesschange = _sync_access_methods(comp, data)
|
||||
if accesschange:
|
||||
changes['accessmethods'] = accesschange
|
||||
|
||||
# Audit log if there were changes
|
||||
if changes:
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Site Policies</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Site Policy</button>
|
||||
<button v-if="!featureOff" class="btn btn-primary" @click="openModal()">+ Add Site Policy</button>
|
||||
</div>
|
||||
|
||||
<div v-if="featureOff" class="card feature-off-notice">
|
||||
<strong>Site policies are not enabled on this site.</strong>
|
||||
<p>Nothing can be created here until the feature is turned on.
|
||||
The list below is empty because the feature is off, not
|
||||
because nothing has been configured.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -110,6 +117,7 @@ const toast = useToast()
|
||||
const policies = ref([])
|
||||
const configPolicies = ref([])
|
||||
const loading = ref(true)
|
||||
const featureOff = ref(false)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
@@ -135,6 +143,10 @@ async function loadData() {
|
||||
configPolicies.value = config.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading business policies:', err)
|
||||
// The API answers 404 'Policy feature is not enabled' when the
|
||||
// flag is off. Without this the page rendered its empty table
|
||||
// and an Add button for a feature that cannot accept one.
|
||||
featureOff.value = err?.response?.status === 404
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -214,6 +226,13 @@ async function remove(p) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feature-off-notice {
|
||||
border-left: 3px solid var(--warning);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.feature-off-notice p {
|
||||
margin: 0.4rem 0 0 0;
|
||||
}
|
||||
.item-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
||||
.order-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Configuration Policies</h2>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Policy</button>
|
||||
<button v-if="!featureOff" class="btn btn-primary" @click="openModal()">+ Add Policy</button>
|
||||
</div>
|
||||
|
||||
<div v-if="featureOff" class="card feature-off-notice">
|
||||
<strong>Configuration policies are not enabled on this site.</strong>
|
||||
<p>Nothing can be created here until the feature is turned on. The
|
||||
list is empty because the feature is off, not because nothing
|
||||
has been configured.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -123,6 +130,7 @@ const apps = ref([])
|
||||
const actionCatalog = ref([])
|
||||
const monitors = ref([])
|
||||
const loading = ref(true)
|
||||
const featureOff = ref(false)
|
||||
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
@@ -145,8 +153,8 @@ async function loadData() {
|
||||
// 500'd, which looked like data loss.
|
||||
const [pol, types, appList, actions, monitorList] = await Promise.allSettled([
|
||||
computersApi.policies.configuration.list(),
|
||||
computersApi.types.list({ perpage: 200 }),
|
||||
applicationsApi.listAll({ perpage: 200 }),
|
||||
computersApi.types.listAll(),
|
||||
applicationsApi.listAll(),
|
||||
computersApi.policies.actions(),
|
||||
endpointsApi.monitors.list()
|
||||
])
|
||||
@@ -155,8 +163,13 @@ async function loadData() {
|
||||
|
||||
if (pol.status === 'fulfilled') {
|
||||
policies.value = pol.value.data.data || []
|
||||
featureOff.value = false
|
||||
} else {
|
||||
failures.push('policies')
|
||||
// The API answers 404 'Policy feature is not enabled' when the flag is
|
||||
// off. Without telling them apart the page rendered an empty table and
|
||||
// an Add button for a feature that cannot accept one.
|
||||
featureOff.value = pol.reason?.response?.status === 404
|
||||
if (!featureOff.value) failures.push('policies')
|
||||
console.error('Error loading policies:', pol.reason)
|
||||
}
|
||||
|
||||
@@ -284,6 +297,13 @@ async function remove(p) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feature-off-notice {
|
||||
border-left: 3px solid var(--warning);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.feature-off-notice p {
|
||||
margin: 0.4rem 0 0 0;
|
||||
}
|
||||
.modal-lg {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
@@ -10,11 +10,21 @@
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
|
||||
<router-link :to="`/pcs/${$route.params.id}/edit${fromQuery}`" class="btn btn-primary">Edit</router-link>
|
||||
<button @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
<button v-if="!isRetired" @click="confirmRetire" class="btn btn-danger">Retire</button>
|
||||
<!-- A retired record offered Edit and Retire exactly like a live
|
||||
one, and nothing said it was retired. Restoring it was possible
|
||||
through the API and nowhere in the product. -->
|
||||
<button v-if="isRetired" @click="restore" class="btn btn-primary">Restore</button>
|
||||
<router-link :to="returnTo" class="btn btn-secondary">{{ returnLabel }}</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="computer && computer.isactive === false" class="retired-banner card">
|
||||
<strong>This PC is retired.</strong>
|
||||
It stays out of every list and report, and its history and relationships
|
||||
are intact. Restore puts it back.
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="computer">
|
||||
@@ -644,6 +654,21 @@ async function confirmRestart() {
|
||||
// Retire, not delete: the server deactivates the asset, so the PC
|
||||
// leaves every list and report while its history and relationships stay put.
|
||||
// Reversible by an admin setting isactive back to true.
|
||||
const isRetired = computed(() => computer.value?.isactive === false)
|
||||
|
||||
async function restore() {
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
try {
|
||||
await computersApi.update(route.params.id, { isactive: true })
|
||||
toast.success(`Restored ${label}`)
|
||||
const response = await computersApi.get(route.params.id)
|
||||
computer.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error restoring:', error)
|
||||
toast.error(apiError(error, 'Failed to restore'))
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRetire() {
|
||||
const label = computer.value?.assetnumber || computer.value?.name || 'this PC'
|
||||
if (!confirm(`Retire ${label}?\n\nIt will no longer appear in ShopDB. `
|
||||
@@ -660,6 +685,10 @@ async function confirmRetire() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.retired-banner {
|
||||
border-left: 3px solid var(--warning);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.portal-link {
|
||||
font-size: 0.78rem;
|
||||
margin-left: 0.5rem;
|
||||
|
||||
@@ -143,6 +143,10 @@
|
||||
v-model="form.locationid"
|
||||
class="form-control"
|
||||
>
|
||||
<!-- Blank means "no location of its own", which is a real state:
|
||||
the PC then follows whatever it is attached to. The hint
|
||||
below says where that currently resolves to, so leaving the
|
||||
box empty is an informed choice rather than a missing one. -->
|
||||
<option value="">Select location...</option>
|
||||
<option
|
||||
v-for="l in locations"
|
||||
@@ -152,6 +156,33 @@
|
||||
{{ l.locationname }}
|
||||
</option>
|
||||
</select>
|
||||
<small v-if="!form.locationid && inheritedLocation" class="input-hint">
|
||||
No location of its own. It currently shows
|
||||
<strong>{{ inheritedLocation.locationname }}</strong>, inherited
|
||||
from {{ inheritedLocation.inheritedfrom }}. Pick one here only to
|
||||
override that.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<!-- The PC detail page has always rendered a Business Unit row,
|
||||
and the API has always accepted the field - only the form had
|
||||
no control, so it could be seen and never set from the UI. -->
|
||||
<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>
|
||||
|
||||
@@ -364,7 +395,7 @@
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi,
|
||||
printersApi, relationshipTypesApi } from '@/api'
|
||||
printersApi, relationshipTypesApi, businessunitsApi } from '@/api'
|
||||
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
||||
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||
from '@/composables/mapConfig'
|
||||
@@ -373,6 +404,7 @@ import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { loadAssetTypeIds, assetTypeId } from '@/composables/assetTypeIds'
|
||||
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
@@ -390,13 +422,20 @@ const isEdit = computed(() => !!route.params.id)
|
||||
// falls back to the PCs list.
|
||||
const returnTo = computed(() => (route.query.from === 'fleet' ? '/endpoints' : '/pcs'))
|
||||
|
||||
// Seeded asset-type id for computers (see /api/assets/types). Custom-field
|
||||
// values are keyed by the underlying asset id, captured on load / create.
|
||||
const COMPUTER_ASSETTYPEID = 2
|
||||
// Resolved by NAME at load, never hardcoded: the id comes from seed
|
||||
// order, so the constant that used to live here pointed at a different
|
||||
// asset type on a differently-seeded site - the form then rendered THAT
|
||||
// type's custom fields and dropped whatever was typed into them.
|
||||
const COMPUTER_ASSETTYPEID = computed(() => assetTypeId('computer'))
|
||||
const customFieldsRef = ref(null)
|
||||
const printerPickerRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
|
||||
// What this PC resolves to today when it owns no location, captured at load.
|
||||
// Read-only: the form must never post it back as the record's own, which is
|
||||
// exactly what binding the location select to the merged `locationid` did.
|
||||
const inheritedLocation = ref(null)
|
||||
|
||||
// PC Number (assetnumber) defaults to the serial number while the user hasn't
|
||||
// typed their own. Editable; only auto-fills on a new PC.
|
||||
const manualPcNumber = ref(false)
|
||||
@@ -470,6 +509,7 @@ function setProtocolPort(protocolid, value) {
|
||||
const vendors = ref([])
|
||||
const models = ref([])
|
||||
const locations = ref([])
|
||||
const businessunits = ref([])
|
||||
const operatingsystems = ref([])
|
||||
|
||||
|
||||
@@ -519,17 +559,20 @@ const filteredVendors = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAssetTypeIds()
|
||||
try {
|
||||
// Load reference data
|
||||
// perpage 100 so dropdowns aren't truncated to the default 20-row page
|
||||
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes, protoRes] = await Promise.all([
|
||||
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes, protoRes,
|
||||
allBusinessUnits] = await Promise.all([
|
||||
computersApi.types.list({ perpage: 100 }),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 100 }),
|
||||
vendorsApi.listAll(),
|
||||
modelsApi.listAll({ category: 'PC' }), // backend caps perpage at 100; page through all
|
||||
locationsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
operatingsystemsApi.list({ perpage: 100 }),
|
||||
computersApi.protocols.list(),
|
||||
businessunitsApi.listAll(),
|
||||
// Handles its own failure: a site without printers still gets a form.
|
||||
])
|
||||
|
||||
@@ -540,6 +583,7 @@ onMounted(async () => {
|
||||
locations.value = locRes || []
|
||||
operatingsystems.value = osRes.data.data || []
|
||||
protocols.value = protoRes.data.data || []
|
||||
businessunits.value = allBusinessUnits || []
|
||||
|
||||
// Load PC if editing (asset-based shape: extension under pc.computer)
|
||||
if (isEdit.value) {
|
||||
@@ -550,6 +594,7 @@ onMounted(async () => {
|
||||
|
||||
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
|
||||
|
||||
inheritedLocation.value = pc.inheritedlocation || null
|
||||
form.value = {
|
||||
machinenumber: pc.assetnumber || '',
|
||||
alias: pc.name && pc.name.toUpperCase() !== 'NONE' ? pc.name : '',
|
||||
@@ -561,7 +606,12 @@ onMounted(async () => {
|
||||
statusid: pc.statusid || '',
|
||||
vendorid: ext.vendorid || '',
|
||||
modelnumberid: ext.modelnumberid || '',
|
||||
locationid: pc.locationid || '',
|
||||
// The OWN location, not the resolved one. Binding to `locationid`
|
||||
// meant the form pre-selected a location this PC had inherited from a
|
||||
// machine, and saving wrote it back as the PC's own - so an edit that
|
||||
// never touched location silently stopped the PC following its parent.
|
||||
locationid: pc.ownlocationid || '',
|
||||
businessunitid: pc.businessunitid || '',
|
||||
osid: ext.osid || '',
|
||||
loggedinuser: ext.loggedinuser || '',
|
||||
azuredeviceid: ext.azuredeviceid || '',
|
||||
@@ -572,9 +622,9 @@ onMounted(async () => {
|
||||
portoverride: a.portoverride ?? null
|
||||
})),
|
||||
notes: pc.notes || '',
|
||||
mapx: pc.mapx ?? null,
|
||||
mapy: pc.mapy ?? null,
|
||||
levelid: pc.levelid ?? null,
|
||||
mapx: pc.ownmapx ?? null,
|
||||
mapy: pc.ownmapy ?? null,
|
||||
levelid: pc.ownlevelid ?? null,
|
||||
ipaddress: primaryComm?.ipaddress || ''
|
||||
}
|
||||
|
||||
@@ -640,6 +690,7 @@ async function savePC() {
|
||||
vendorid: form.value.vendorid || null,
|
||||
modelnumberid: form.value.modelnumberid || null,
|
||||
locationid: form.value.locationid || null,
|
||||
businessunitid: form.value.businessunitid || null,
|
||||
osid: form.value.osid || null,
|
||||
loggedinuser: form.value.loggedinuser || null,
|
||||
azuredeviceid: form.value.azuredeviceid || null,
|
||||
@@ -649,10 +700,22 @@ async function savePC() {
|
||||
notes: form.value.notes || null,
|
||||
ipaddress: form.value.ipaddress || null,
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy
|
||||
mapy: form.value.mapy,
|
||||
// ADR-017: a position without its level cannot be drawn on the right
|
||||
// floor plan. The picker sets all three and this payload sent two, so a
|
||||
// picked pin saved a levelid of null and then rendered nowhere - the
|
||||
// form even showed "Position: x, y" back, because it kept the value it
|
||||
// had never sent.
|
||||
levelid: form.value.levelid
|
||||
}
|
||||
// only set display name when an alias is given, so we don't clobber it
|
||||
if (form.value.alias) {
|
||||
// An emptied alias has to be SENT, or it cannot be cleared: the payload
|
||||
// simply omitted `name`, the old value survived, and the form came back
|
||||
// showing the alias the operator had just deleted, with no error and no
|
||||
// toast. On CREATE the field is still omitted when blank, because there
|
||||
// the server may fill it in and sending null would overwrite that.
|
||||
if (isEdit.value) {
|
||||
payload.name = form.value.alias || null
|
||||
} else if (form.value.alias) {
|
||||
payload.name = form.value.alias
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ onMounted(() => loadData())
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await computersApi.types.list({ perpage: 200, active: false })
|
||||
const response = await computersApi.types.listAll({ active: false })
|
||||
pcTypes.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading PC types:', err)
|
||||
|
||||
@@ -15,7 +15,14 @@
|
||||
placeholder="Search computers..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
<!-- Retire is a soft delete and the confirm dialog promises the record
|
||||
"can be restored". The API kept that promise (?active=false lists
|
||||
them, PUT isactive restores); nothing in the UI could reach it. -->
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="showRetired" @change="loadComputers" />
|
||||
Show retired
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
@@ -65,7 +72,15 @@
|
||||
</tr>
|
||||
<tr v-if="computers.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No computers found
|
||||
<!-- A 403 is not an empty result. Rendering "No computers
|
||||
found" to somebody who simply may not see them tells
|
||||
them the fleet is empty, and they go looking for the
|
||||
data rather than for the permission. -->
|
||||
<template v-if="denied">
|
||||
You do not have permission to view computers. Ask an
|
||||
administrator for the computers.view permission.
|
||||
</template>
|
||||
<template v-else>No computers found</template>
|
||||
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
||||
</td>
|
||||
</tr>
|
||||
@@ -106,6 +121,9 @@ function applySuggestion(word) {
|
||||
setSearch(word)
|
||||
loadComputers()
|
||||
}
|
||||
const showRetired = ref(false)
|
||||
// Told apart from an empty list: see the table's empty row.
|
||||
const denied = ref(false)
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
@@ -123,6 +141,8 @@ async function loadComputers() {
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
// active=false means "include retired", matching the API.
|
||||
if (showRetired.value) params.active = 'false'
|
||||
|
||||
const response = await computersApi.list(params)
|
||||
computers.value = response.data.data || []
|
||||
@@ -130,6 +150,7 @@ async function loadComputers() {
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading computers:', error)
|
||||
denied.value = error?.response?.status === 403
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user