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.
260 lines
7.8 KiB
Vue
260 lines
7.8 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Applications</h2>
|
|
<router-link to="/applications/new" class="btn btn-primary">Add Application</router-link>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="filters">
|
|
<input
|
|
v-model="search"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search applications..."
|
|
@input="debouncedSearch"
|
|
/>
|
|
<select v-model="filter" class="form-control" @change="applyFilter">
|
|
<option value="installable">Installable Applications</option>
|
|
<option value="all">All Applications</option>
|
|
<option value="hidden">Hidden Applications</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<template v-else>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 50px;">Files</th>
|
|
<th style="width: 50px;">Docs</th>
|
|
<th>Application Name</th>
|
|
<th>Description</th>
|
|
<th>Support Team</th>
|
|
<th>Contacts</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="app in applications" :key="app.appid" class="clickable-row" @click="$router.push(`/applications/${app.appid}`)">
|
|
<td class="icon-cell">
|
|
<a v-if="app.installpath" :href="fileHref(app.installpath)" target="_blank" title="Download Installation Files" class="icon-download">
|
|
⬇
|
|
</a>
|
|
</td>
|
|
<td class="icon-cell">
|
|
<a v-if="app.documentationpath" :href="fileHref(app.documentationpath)" target="_blank" title="View Documentation" class="icon-docs">
|
|
📄
|
|
</a>
|
|
</td>
|
|
<td>
|
|
<router-link :to="`/applications/${app.appid}`">
|
|
{{ app.appname }}
|
|
</router-link>
|
|
<span class="app-flags">
|
|
<span v-if="app.isinstallable" class="badge badge-info badge-sm">Installable</span>
|
|
<span v-if="app.islicenced" class="badge badge-warning badge-sm">Licensed</span>
|
|
<span v-if="app.isprinter" class="badge badge-secondary badge-sm">Printer</span>
|
|
</span>
|
|
</td>
|
|
<td class="description">{{ app.appdescription || '-' }}</td>
|
|
<td>{{ app.supportteamname || '-' }}</td>
|
|
<td>{{ app.contacts && app.contacts.length ? app.contacts.map(c => c.name).join(', ') : '-' }}</td>
|
|
<td class="actions" @click.stop>
|
|
<router-link
|
|
:to="`/applications/${app.appid}`"
|
|
class="btn btn-secondary btn-sm"
|
|
>
|
|
View
|
|
</router-link>
|
|
<button
|
|
v-if="candelete"
|
|
class="btn btn-danger btn-sm"
|
|
@click="deleteApplication(app)"
|
|
>
|
|
Delete
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="applications.length === 0">
|
|
<td colspan="7" style="text-align: center; color: var(--text-light);">
|
|
No applications found
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<PaginationBar
|
|
:page="page"
|
|
:totalPages="totalPages"
|
|
:perPage="perPage"
|
|
@update:page="goToPage"
|
|
@update:perPage="changePerPage"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { applicationsApi } from '@/api'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import PaginationBar from '@/components/PaginationBar.vue'
|
|
import { useListQuery } from '@/composables/listQuery'
|
|
import { fileHref } from '@/utils/basePath'
|
|
|
|
const applications = ref([])
|
|
const loading = ref(true)
|
|
|
|
// Mirrors the backend guard on the endpoint rather than checking for the admin
|
|
// role, so a non-admin role granted applications.delete gets the button too and
|
|
// nobody is shown a control that would only 403.
|
|
const authStore = useAuthStore()
|
|
const candelete = computed(() => authStore.hasPermission('applications.delete'))
|
|
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadApplications })
|
|
const filter = ref('installable')
|
|
const totalPages = ref(1)
|
|
const perPage = ref(20)
|
|
|
|
let searchTimeout = null
|
|
|
|
onMounted(() => {
|
|
loadApplications()
|
|
})
|
|
|
|
// A filter change must go back to page 1. Selecting a filter while on page 5
|
|
// asked the server for page 5 of a result set that now has one page, and the
|
|
// list came back empty as though the filter matched nothing.
|
|
//
|
|
// setPage(1) writes the URL, which the composable's watcher picks up and
|
|
// answers with onChange - so calling the loader as well would fetch twice.
|
|
// Load directly only when already on page 1, where nothing changes and the
|
|
// watcher stays silent.
|
|
function applyFilter() {
|
|
if (page.value > 1) setPage(1)
|
|
else loadApplications()
|
|
}
|
|
|
|
async function loadApplications() {
|
|
loading.value = true
|
|
try {
|
|
const params = {
|
|
page: page.value,
|
|
perpage: perPage.value,
|
|
search: search.value || undefined
|
|
}
|
|
|
|
// Apply filter
|
|
if (filter.value === 'installable') {
|
|
params.installable = true
|
|
} else if (filter.value === 'hidden') {
|
|
params.hidden = true
|
|
}
|
|
|
|
const response = await applicationsApi.list(params)
|
|
applications.value = response.data.data || []
|
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
|
} catch (error) {
|
|
console.error('Error loading applications:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function debouncedSearch() {
|
|
clearTimeout(searchTimeout)
|
|
searchTimeout = setTimeout(() => {
|
|
setSearch(search.value)
|
|
loadApplications()
|
|
}, 300)
|
|
}
|
|
|
|
// 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.'
|
|
: ''
|
|
|
|
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, (answer || '').trim())
|
|
loadApplications()
|
|
} catch (error) {
|
|
console.error('Error deleting application:', error)
|
|
alert(error.response?.data?.data?.error?.message
|
|
|| 'Failed to remove the application.')
|
|
}
|
|
}
|
|
|
|
function goToPage(p) {
|
|
setPage(p)
|
|
loadApplications()
|
|
}
|
|
|
|
function changePerPage(newPerPage) {
|
|
perPage.value = newPerPage
|
|
setPage(1)
|
|
loadApplications()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Application list specific styles */
|
|
.icon-cell {
|
|
text-align: center;
|
|
width: 50px;
|
|
}
|
|
|
|
.icon-cell a {
|
|
font-size: 1.25rem;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.icon-download {
|
|
color: var(--success);
|
|
}
|
|
|
|
.icon-docs {
|
|
color: var(--secondary);
|
|
}
|
|
|
|
.icon-cell a:hover {
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.app-flags {
|
|
display: inline-flex;
|
|
gap: 0.375rem;
|
|
flex-wrap: wrap;
|
|
margin-left: 0.5rem;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.badge-sm {
|
|
font-size: 0.75rem;
|
|
padding: 0.2rem 0.5rem;
|
|
}
|
|
|
|
.description {
|
|
max-width: 200px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
color: var(--text-light);
|
|
}
|
|
</style>
|