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.
This commit is contained in:
@@ -30,10 +30,7 @@
|
||||
<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="fileHref(app.applicationlink)" target="_blank" class="hero-link">
|
||||
<span class="link-icon">🔗</span> Launch Application
|
||||
</a>
|
||||
<div class="hero-links" v-if="app.installpath || app.documentationpath">
|
||||
<a v-if="app.installpath" :href="fileHref(app.installpath)" target="_blank" class="hero-link">
|
||||
<span class="link-icon">⬇</span> Download Files
|
||||
</a>
|
||||
@@ -61,6 +58,13 @@
|
||||
<span v-else>{{ app.supportteamname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="app.applicationlink">
|
||||
<span class="info-label">Application Link</span>
|
||||
<!-- Reference only, deliberately not a link. It tells an
|
||||
operator where to go for new versions; it is not a launch
|
||||
or download action and must not read as one. -->
|
||||
<span class="info-value mono reference-value">{{ app.applicationlink }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="app.contacts && app.contacts.length">
|
||||
<span class="info-label">Contacts</span>
|
||||
<span class="info-value">
|
||||
@@ -432,6 +436,11 @@ function kbKeywords(keywords) {
|
||||
}
|
||||
|
||||
/* Support contacts stack one per line */
|
||||
.reference-value {
|
||||
word-break: break-all;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.contact-line {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -69,12 +69,51 @@
|
||||
<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. -->
|
||||
@@ -121,7 +160,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="installpath">Install Path</label>
|
||||
<label for="installpath">Installer path</label>
|
||||
<input
|
||||
id="installpath"
|
||||
v-model="form.installpath"
|
||||
@@ -150,7 +189,29 @@
|
||||
</div>
|
||||
<small class="form-hint">
|
||||
Up to 500MB. Anything larger belongs on the share - put its path in
|
||||
Install Path instead. Downloads require a login.
|
||||
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>
|
||||
|
||||
@@ -199,11 +260,26 @@
|
||||
|
||||
<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>
|
||||
@@ -233,12 +309,19 @@ const form = ref({
|
||||
islicenced: false,
|
||||
isprinter: false,
|
||||
isrequired: false,
|
||||
ismonitorable: false,
|
||||
monitorlaunchpath: '',
|
||||
monitorprocessname: '',
|
||||
ishidden: false,
|
||||
applicationlink: '',
|
||||
documentationpath: '',
|
||||
installpath: '',
|
||||
image: '',
|
||||
applicationnotes: ''
|
||||
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([])
|
||||
@@ -261,6 +344,12 @@ 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
|
||||
@@ -313,6 +402,60 @@ async function removePackage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -341,13 +484,22 @@ onMounted(async () => {
|
||||
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 || ''
|
||||
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/')) {
|
||||
@@ -371,6 +523,20 @@ async function saveApplication() {
|
||||
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 {
|
||||
@@ -382,6 +548,15 @@ async function saveApplication() {
|
||||
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,
|
||||
@@ -408,12 +583,33 @@ async function saveApplication() {
|
||||
}
|
||||
if (pendingPackage.value) {
|
||||
packageProgress.value = 0
|
||||
await applicationsApi.uploadPackage(appid, pendingPackage.value,
|
||||
percent => { packageProgress.value = percent })
|
||||
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)
|
||||
@@ -489,11 +685,14 @@ async function saveApplication() {
|
||||
.policy-warning {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--warning, #b45309);
|
||||
border: 1px solid var(--warning);
|
||||
border-left-width: 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--warning-bg, #fffbeb);
|
||||
color: var(--text, #333);
|
||||
/* 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 {
|
||||
@@ -508,4 +707,13 @@ async function saveApplication() {
|
||||
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>
|
||||
|
||||
@@ -44,9 +44,6 @@
|
||||
<a v-if="app.installpath" :href="fileHref(app.installpath)" target="_blank" title="Download Installation Files" class="icon-download">
|
||||
⬇
|
||||
</a>
|
||||
<a v-else-if="app.applicationlink" :href="fileHref(app.applicationlink)" target="_blank" title="Application Link" class="icon-link">
|
||||
🔗
|
||||
</a>
|
||||
</td>
|
||||
<td class="icon-cell">
|
||||
<a v-if="app.documentationpath" :href="fileHref(app.documentationpath)" target="_blank" title="View Documentation" class="icon-docs">
|
||||
@@ -178,19 +175,23 @@ function debouncedSearch() {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// The server deactivates rather than drops the row, so this is reversible and
|
||||
// the prompt does not claim otherwise. It does warn about the fleet-wide
|
||||
// consequence, which is the part that is not obvious from a Delete button.
|
||||
// This permanently drops the row, its versions and its uploaded installer, so
|
||||
// the prompt says so plainly and requires the word OK to be typed. A plain
|
||||
// confirm() is one misplaced click away from destroying licensed software.
|
||||
async function deleteApplication(app) {
|
||||
const warning = app.isrequired
|
||||
? `\n\n"${app.appname}" is currently required on all PCs. Removing it also `
|
||||
+ 'withdraws the policy that installs it.'
|
||||
: ''
|
||||
|
||||
if (!confirm(`Remove "${app.appname}" from the catalogue?${warning}`)) return
|
||||
const answer = prompt(
|
||||
`Permanently delete "${app.appname}", its versions and its uploaded `
|
||||
+ `executables? This action cannot be reversed.${warning}\n\n`
|
||||
+ 'Type OK to proceed')
|
||||
if ((answer || '').trim().toUpperCase() !== 'OK') return
|
||||
|
||||
try {
|
||||
await applicationsApi.delete(app.appid)
|
||||
await applicationsApi.delete(app.appid, (answer || '').trim())
|
||||
loadApplications()
|
||||
} catch (error) {
|
||||
console.error('Error deleting application:', error)
|
||||
@@ -227,10 +228,6 @@ function changePerPage(newPerPage) {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.icon-link {
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.icon-docs {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
@@ -215,7 +215,8 @@ def list_endpoints():
|
||||
|
||||
query = (db.session.query(EndpointState, Computer.computerid,
|
||||
Computer.computertypeid, ComputerType.computertype,
|
||||
Asset.assetnumber, statusrank.label('statusrank'))
|
||||
Asset.assetnumber, Asset.name,
|
||||
statusrank.label('statusrank'))
|
||||
.join(Asset, Asset.assetid == EndpointState.assetid)
|
||||
.join(Computer, Computer.assetid == EndpointState.assetid)
|
||||
.outerjoin(ComputerType,
|
||||
@@ -227,7 +228,8 @@ def list_endpoints():
|
||||
if search := (request.args.get('search') or '').strip():
|
||||
like = f'%{search}%'
|
||||
machinehits = _assetids_serving_machine_like(like)
|
||||
clauses = [EndpointState.hostname.ilike(like), Asset.assetnumber.ilike(like)]
|
||||
clauses = [EndpointState.hostname.ilike(like), Asset.assetnumber.ilike(like),
|
||||
Asset.name.ilike(like)]
|
||||
if machinehits:
|
||||
clauses.append(EndpointState.assetid.in_(machinehits))
|
||||
query = query.filter(db.or_(*clauses))
|
||||
@@ -265,10 +267,14 @@ def list_endpoints():
|
||||
health = _health_for_assets(states, computertypes)
|
||||
|
||||
data = []
|
||||
for state, computerid, computertypeid, computertype, assetnumber, rank in items:
|
||||
for (state, computerid, computertypeid, computertype, assetnumber, alias,
|
||||
rank) in items:
|
||||
row = state.to_dict()
|
||||
row['computerid'] = computerid
|
||||
row['assetnumber'] = assetnumber
|
||||
# Named 'alias' rather than 'name' because the fleet row already
|
||||
# carries a hostname; 'name' next to 'hostname' reads as a duplicate.
|
||||
row['alias'] = alias
|
||||
row['computertypeid'] = computertypeid
|
||||
row['computertype'] = computertype
|
||||
row['uptimedisplay'] = _format_uptime(state.uptimeseconds)
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
<input type="checkbox" :checked="allselected" @change="toggleAll" title="Select all on this page" />
|
||||
</th>
|
||||
<th>Status</th>
|
||||
<th>Alias</th>
|
||||
<th>Hostname</th>
|
||||
<th>Machine</th>
|
||||
<th>PC Type</th>
|
||||
@@ -111,6 +112,7 @@
|
||||
<td>
|
||||
<span class="status-dot" :class="item.status"></span>{{ statusLabel(item.status) }}
|
||||
</td>
|
||||
<td>{{ item.alias || '-' }}</td>
|
||||
<td>{{ item.hostname || '-' }}</td>
|
||||
<td>
|
||||
<!-- What stops if this PC is dead. On a shop floor that is
|
||||
@@ -130,7 +132,7 @@
|
||||
<td>{{ formatRelative(item.lastreportedat) }}</td>
|
||||
</tr>
|
||||
<tr v-if="endpoints.length === 0">
|
||||
<td :colspan="cancommand ? 10 : 9" style="text-align: center; color: var(--text-light);">
|
||||
<td :colspan="cancommand ? 11 : 10" style="text-align: center; color: var(--text-light);">
|
||||
No endpoints found
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user