Files
shopdb-flask/plugins/applications/frontend/views/ApplicationForm.vue
benjamin.baker 0134eaa219 Hard delete applications, nest installer packages, detect PE versions
Soft delete left orphaned executables on disk and let a deleted name collide
with a fresh import, so delete is now a hard delete that removes the
associated files. The destructive paths (delete, revert-to-record) require a
typed OK because neither can be undone.

Installers keep their filename at import and nest per application/version,
which stops repeat imports doubling up. Version now comes from the same PE
detection the endpoints agent uses; when no version can be read the caller is
asked for one rather than silently storing a blank.

Applications also surface Version and a monitorable toggle, with launch path
and process name required only while monitorable is ticked. Fleet shows the
PC alias, matching PC Details.
2026-09-07 15:04:21 -04:00

720 lines
24 KiB
Vue

<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.ismonitorable" />
Monitorable
</label>
<label>
<input type="checkbox" v-model="form.ishidden" />
Hidden
</label>
</div>
<!-- The server rejects a monitorable app missing either field, so the
two inputs appear with the box rather than being hidden away in
another section where the rejection would read as a bug. -->
<div v-if="form.ismonitorable" class="monitor-fields">
<div class="form-group">
<label for="monitorprocessname">Process Name</label>
<input
id="monitorprocessname"
v-model="form.monitorprocessname"
type="text"
class="form-control"
placeholder="e.g. notepad.exe"
/>
<small class="form-hint">
The image name as it appears in Task Manager. This is what the
agent watches for.
</small>
</div>
<div class="form-group">
<label for="monitorlaunchpath">Launch Path</label>
<input
id="monitorlaunchpath"
v-model="form.monitorlaunchpath"
type="text"
class="form-control"
placeholder="e.g. C:\Program Files\Vendor\app.exe"
/>
<small class="form-hint">
Run on the endpoint when the process is found missing. A local
path on the PC, not a share.
</small>
</div>
</div>
<!-- Only shown when the user is newly ticking the box. An app that
was already required has an existing policy, so re-saving it is
not a new deployment and does not need re-acknowledging. -->
<div v-if="showsPolicyWarning" class="policy-warning">
<strong>This will install software on your fleet.</strong>
<p>
Marking <em>{{ form.appname || 'this application' }}</em> as required
on all PCs creates a configuration policy that installs it on every
non-server endpoint it applies to. Installation happens
automatically, without further prompting, on each machine as it
checks in.
</p>
<label>
<input type="checkbox" v-model="acknowledgePolicy" />
I understand this will install the application on all matched PCs.
</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">Installer path</label>
<input
id="installpath"
v-model="form.installpath"
type="text"
class="form-control"
placeholder="Network path or URL to install files"
/>
<small class="form-hint">
A share path or URL, or upload the installer below and this fills
itself in.
</small>
</div>
<div class="form-group">
<label for="packagefile">Installer</label>
<div v-if="uploadedPackage" class="upload-current">
<span class="upload-name">{{ uploadedPackage }}</span>
<button type="button" class="btn btn-sm btn-secondary" @click="removePackage"
:disabled="busyPackage">Remove installer</button>
</div>
<input id="packagefile" type="file" class="form-control"
:accept="PACKAGE_ACCEPT" @change="onPackagePicked" />
<div v-if="packageProgress !== null" class="upload-progress">
<div class="upload-bar"><span :style="{ width: packageProgress + '%' }"></span></div>
<span>{{ packageProgress }}%</span>
</div>
<small class="form-hint">
Up to 500MB. Anything larger belongs on the share - put its path in
Installer path instead. Downloads require a login.
</small>
</div>
<div class="form-group">
<label for="version">Version</label>
<input
id="version"
v-model="form.version"
type="text"
class="form-control"
placeholder="e.g. 2.4.1.0"
/>
<small class="form-hint">
<span v-if="detectedVersion">
Read from the installer. Change it if the vendor numbers releases
differently.
</span>
<span v-else>
Read from the installer where one is present. MSI and archive
files do not carry a version, so type it in to keep a later
re-import from looking like new software.
</span>
</small>
</div>
<div class="form-group">
<label for="imagefile">Image</label>
<div v-if="imagePreview" class="upload-current">
<img :src="imagePreview" alt="" class="upload-thumb" />
<button type="button" class="btn btn-sm btn-secondary" @click="removeImage"
:disabled="busyImage">Remove image</button>
</div>
<input id="imagefile" type="file" class="form-control"
accept="image/*" @change="onImagePicked" />
<small class="form-hint">
PNG, JPG, GIF, WEBP, SVG or ICO. Replaces whatever is there now.
</small>
</div>
<div class="form-group">
<label for="image">Image path</label>
<input
id="image"
v-model="form.image"
type="text"
class="form-control"
placeholder="filled in by the upload above"
/>
<small class="form-hint">
Set by the upload. Older entries hold a bare filename served from
/images/applications/, and those still work - leave them alone
unless you are replacing the image.
</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 v-if="versionWarning" class="policy-warning">{{ versionWarning }}</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>
<!-- Edit only: there is nothing to strip from an application that has
not been created yet. Pushed to the right and styled as a danger
action so it is not mistaken for Cancel. -->
<button
v-if="isEdit"
type="button"
class="btn btn-danger"
style="margin-left: auto;"
:disabled="busyRevert"
@click="revertToRecord"
>
{{ busyRevert ? 'Reverting...' : 'Revert to Record' }}
</button>
</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,
ismonitorable: false,
monitorlaunchpath: '',
monitorprocessname: '',
ishidden: false,
applicationlink: '',
documentationpath: '',
installpath: '',
image: '',
applicationnotes: '',
// Not a column on applications - it creates/updates an AppVersion row on
// save. Held here so the field can sit next to the installer that produced
// it, which is where the operator is looking when it matters.
version: ''
})
const supportTeams = ref([])
// The value as saved, so we can tell a NEW deployment from a re-save of an
// application that was already required. Only the former warrants a warning.
const wasRequired = ref(false)
const acknowledgePolicy = ref(false)
const showsPolicyWarning = computed(
() => form.value.isrequired && !wasRequired.value
)
// Files are chosen before the application exists, so they are held here and
// uploaded once there is an id to attach them to (see saveApplication).
const pendingImage = ref(null)
const pendingPackage = ref(null)
const imagePreview = ref('')
const uploadedPackage = ref('')
const packageProgress = ref(null)
const busyImage = ref(false)
const busyPackage = ref(false)
const busyRevert = ref(false)
const detectedVersion = ref('')
const versionWarning = ref('')
// Versions already on the record, so saving an unchanged form does not try to
// re-create one and trip the duplicate check.
const existingVersions = ref([])
const PACKAGE_ACCEPT = '.exe,.msi,.msp,.zip,.7z,.cab,.iso,.appx,.msix,.ps1,.bat,.txt,.pdf'
const MAX_PACKAGE_BYTES = 500 * 1024 * 1024
function onImagePicked(event) {
const file = event.target.files?.[0]
if (!file) return
pendingImage.value = file
// Show the chosen file immediately rather than after a round trip.
imagePreview.value = URL.createObjectURL(file)
}
function onPackagePicked(event) {
const file = event.target.files?.[0]
if (!file) return
if (file.size > MAX_PACKAGE_BYTES) {
error.value = `${file.name} is ${(file.size / 1048576).toFixed(0)}MB; the limit is 500MB. `
+ 'Put it on the share and use Install Path instead.'
event.target.value = ''
return
}
error.value = ''
pendingPackage.value = file
uploadedPackage.value = file.name
}
async function removeImage() {
pendingImage.value = null
imagePreview.value = ''
form.value.image = ''
if (isEdit.value) {
busyImage.value = true
try { await applicationsApi.removeImage(route.params.id) }
catch (err) { error.value = apiError(err, 'Failed to remove the image') }
finally { busyImage.value = false }
}
}
async function removePackage() {
pendingPackage.value = null
uploadedPackage.value = ''
if (isEdit.value) {
busyPackage.value = true
try {
const response = await applicationsApi.removePackage(route.params.id)
form.value.installpath = response.data.data.installpath || ''
} catch (err) {
error.value = apiError(err, 'Failed to remove the installer')
} finally { busyPackage.value = false }
}
}
// Strips the application back to a bare catalogue record. Distinct from Delete
// on the list: the row, name and knowledgebase links survive, so this is the
// action for "stop deploying this" rather than "this never existed". Still
// irreversible, so it uses the same typed-OK gate the backend enforces.
async function revertToRecord() {
const answer = prompt(
'Remove all policy associations and uploaded executables? This action '
+ 'cannot be reversed. Type OK to proceed')
if ((answer || '').trim().toUpperCase() !== 'OK') return
busyRevert.value = true
error.value = ''
try {
const response = await applicationsApi.revertToRecord(
route.params.id, (answer || '').trim())
const app = response.data.data
// Re-seed from the server rather than guessing which fields it cleared.
// The image is deliberately untouched: revert removes executables and
// policy associations, not the record's icon.
form.value.installpath = app.installpath || ''
form.value.isrequired = app.isrequired
// Revert drops every AppVersion row, so the field must not keep showing a
// version that no longer exists.
form.value.version = ''
uploadedPackage.value = ''
pendingPackage.value = null
detectedVersion.value = ''
versionWarning.value = ''
} catch (err) {
error.value = apiError(err, 'Failed to revert the application')
} finally {
busyRevert.value = false
}
}
// Records the typed version against the application, if it is new. Creating a
// version that already exists is not an error worth failing the save over -
// re-saving a form without touching the field is the normal case, and the
// backend answers 409, which would otherwise surface as "failed to save" on a
// save that in fact succeeded.
async function saveVersion(appid) {
const version = (form.value.version || '').trim()
if (!version) return
if (existingVersions.value.some(v => v.version === version)) return
try {
await applicationsApi.createVersion(appid, { version })
existingVersions.value = [{ version }, ...existingVersions.value]
} catch (err) {
if (err.response?.status === 409) return
throw err
}
}
// The image field holds either a served URL (new) or a bare filename from the
// classic site, which is still rendered out of /images/applications/.
function imageSrcFor(value) {
if (!value) return ''
return value.startsWith('/api/') || value.startsWith('http')
? value
: `/images/applications/${value}`
}
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,
ismonitorable: app.ismonitorable || false,
monitorlaunchpath: app.monitorlaunchpath || '',
monitorprocessname: app.monitorprocessname || '',
ishidden: app.ishidden || false,
applicationlink: app.applicationlink || '',
documentationpath: app.documentationpath || '',
installpath: app.installpath || '',
image: app.image || '',
applicationnotes: app.applicationnotes || '',
// The detail payload already carries versions newest-first, so the
// current one comes free rather than costing another request.
version: (app.versions && app.versions.length)
? (app.versions[0].version || '')
: ''
}
existingVersions.value = app.versions || []
imagePreview.value = imageSrcFor(app.image)
wasRequired.value = app.isrequired || false
if ((app.installpath || '').startsWith('/api/applications/package/')) {
uploadedPackage.value = app.installpath.split('/').pop()
}
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
} finally {
loading.value = false
}
})
async function saveApplication() {
error.value = ''
if (showsPolicyWarning.value && !acknowledgePolicy.value) {
error.value = 'Please acknowledge that this will install the application '
+ 'on all matched PCs before saving.'
return
}
// Mirrors _validate_monitorable on the server. Checked here too so the
// operator is told which field is missing while they are still looking at it,
// rather than after a round trip.
if (form.value.ismonitorable) {
if (!(form.value.monitorprocessname || '').trim()) {
error.value = 'A monitorable application needs a process name.'
return
}
if (!(form.value.monitorlaunchpath || '').trim()) {
error.value = 'A monitorable application needs a launch path.'
return
}
}
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,
ismonitorable: form.value.ismonitorable,
// Cleared when the box is unticked so a later re-tick cannot silently
// revive a stale process name the operator can no longer see.
monitorlaunchpath: form.value.ismonitorable
? (form.value.monitorlaunchpath || null)
: null,
monitorprocessname: form.value.ismonitorable
? (form.value.monitorprocessname || null)
: null,
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,
acknowledgepolicy: showsPolicyWarning.value
}
let appid = route.params.id
if (isEdit.value) {
await applicationsApi.update(appid, appData)
} else {
const created = await applicationsApi.create(appData)
appid = created.data.data.appid
}
// Uploads come after the save: a new application has no id until it exists,
// and both endpoints key on it. A failed upload must not lose the record
// that saved fine, so it reports and stays on the form.
if (pendingImage.value) {
await applicationsApi.uploadImage(appid, pendingImage.value)
pendingImage.value = null
}
if (pendingPackage.value) {
packageProgress.value = 0
const uploaded = await applicationsApi.uploadPackage(
appid, pendingPackage.value,
percent => { packageProgress.value = percent })
pendingPackage.value = null
packageProgress.value = null
// An installer with no readable version resource is how duplicate
// version rows got created: the operator saved, saw no version, and
// imported the same file again. MSIs and archives are not PE files and
// never carry one, so ask rather than guess.
detectedVersion.value = uploaded.data.data.detectedversion || ''
if (detectedVersion.value && !form.value.version.trim()) {
form.value.version = detectedVersion.value
}
if (!form.value.version.trim()) {
versionWarning.value =
`No version could be read from ${uploaded.data.data.uploadedfilename}. `
+ 'Enter one in the Version field above so a later import is not '
+ 'mistaken for new software.'
// The application and installer are already saved; only the version is
// outstanding, so stay on the form rather than losing that work.
return
}
}
await saveVersion(appid)
router.push('/applications')
} catch (err) {
console.error('Error saving application:', err)
error.value = apiError(err, 'Failed to save application')
packageProgress.value = null
} finally {
saving.value = false
}
}
</script>
<style scoped>
.upload-current {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.upload-thumb {
width: 3rem;
height: 3rem;
object-fit: contain;
border: 1px solid var(--border);
border-radius: 0.25rem;
background: var(--bg);
}
.upload-name {
font-family: ui-monospace, monospace;
font-size: 0.85rem;
word-break: break-all;
}
.upload-progress {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.85rem;
}
.upload-bar {
flex: 1;
height: 0.5rem;
background: var(--border);
border-radius: 999px;
overflow: hidden;
}
.upload-bar span {
display: block;
height: 100%;
background: var(--primary);
transition: width 0.2s ease;
}
.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);
}
.policy-warning {
margin-top: 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--warning);
border-left-width: 4px;
border-radius: 4px;
/* Derived from the real token rather than a fixed tint: a hardcoded light
value here survives the theme switch and renders light-on-light in dark
mode. --warning-bg does not exist in style.css. */
background: color-mix(in srgb, var(--warning) 12%, var(--bg-card-solid));
color: var(--text);
}
.policy-warning p {
margin: 0.5rem 0 0.75rem;
font-size: 0.9rem;
}
.policy-warning label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-weight: 600;
}
.monitor-fields {
margin-top: 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-left: 4px solid var(--warning);
border-radius: 4px;
background: color-mix(in srgb, var(--warning) 6%, var(--bg-card-solid));
}
</style>