Reset to page one when a filter changes, and let the catalog carry a real type
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 8s
CI / migrations-mysql (push) Failing after 7s

Two unrelated things found while looking at blank printer types.

Selecting a filter while past page one returned an empty list. The filter asked
the server for page 5 of a result set that now had one page, and the screen said
nothing matched. useListQuery already resets the page - setSearch and setExtra
both do - but the filter dropdowns bypassed it and called the loader directly.
Nine list pages now route through applyFilter, which calls setPage(1) when it
needs to and loads directly when already on page one, so the composable's URL
watcher does not also fire and fetch twice.

scripts/retype_models.py addresses why printer types cannot be derived. The
catalog types every printer model "Printer": true, and useless, since it does not
say whether the product is a laser, a plotter or a label printer. That answer is
a property of the model - every VersaLink C405 is a laser MFP - but nothing
recorded it, so nothing could derive it. Recording it on the MODEL means the
existing backfill fills every printer by exact name match, and a printer added
later inherits the right type the moment its model is chosen.

It exports the models needing a decision to CSV with a type suggested from the
model number, a person corrects the column, and applying it is a dry run unless
given --commit. A suggested type is refused unless it already exists in that
asset class's own vocabulary, which is what keeps the later name match working.

The suggestion order matters and got this wrong first time: a generic plotter
pattern matched "Zebra ZT411" and filed a label printer as a plotter. Brands now
come before generic patterns, and the review step exists precisely because a
confident wrong guess would type every asset using that model.

Verified on the development database: 24 printer models need a decision, 22 got
a sensible suggestion, applying them let all 42 printers match a printertype by
name, and the transaction rolled back cleanly.
This commit is contained in:
cproudlock
2026-08-05 11:26:23 -04:00
parent e22322dcc9
commit 85ff25462e
10 changed files with 805 additions and 458 deletions

View File

@@ -14,7 +14,7 @@
placeholder="Search models..." placeholder="Search models..."
@input="debouncedSearch" @input="debouncedSearch"
/> />
<select v-model="vendorFilter" class="form-control" @change="loadModels"> <select v-model="vendorFilter" class="form-control" @change="applyFilter">
<option value="">All Vendors</option> <option value="">All Vendors</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid"> <option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }} {{ v.vendor }}
@@ -272,6 +272,19 @@ onMounted(async () => {
]) ])
}) })
// 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 loadModels()
}
async function loadModels() { async function loadModels() {
loading.value = true loading.value = true
try { try {

View File

@@ -14,7 +14,7 @@
placeholder="Search applications..." placeholder="Search applications..."
@input="debouncedSearch" @input="debouncedSearch"
/> />
<select v-model="filter" class="form-control" @change="loadApplications"> <select v-model="filter" class="form-control" @change="applyFilter">
<option value="installable">Installable Applications</option> <option value="installable">Installable Applications</option>
<option value="all">All Applications</option> <option value="all">All Applications</option>
<option value="hidden">Hidden Applications</option> <option value="hidden">Hidden Applications</option>
@@ -117,6 +117,19 @@ onMounted(() => {
loadApplications() 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() { async function loadApplications() {
loading.value = true loading.value = true
try { try {

View File

@@ -1,263 +1,276 @@
<template> <template>
<div> <div>
<div class="page-header"> <div class="page-header">
<div class="header-left"> <div class="header-left">
<h2>Knowledge Base</h2> <h2>Knowledge Base</h2>
<span v-if="stats" class="stats-badge"> <span v-if="stats" class="stats-badge">
{{ stats.totalarticles }} articles | {{ stats.totalclicks.toLocaleString() }} total clicks {{ stats.totalarticles }} articles | {{ stats.totalclicks.toLocaleString() }} total clicks
</span> </span>
</div> </div>
<router-link to="/knowledgebase/new" class="btn btn-primary">Add Article</router-link> <router-link to="/knowledgebase/new" class="btn btn-primary">Add Article</router-link>
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="filters"> <div class="filters">
<input <input
v-model="search" v-model="search"
type="text" type="text"
class="form-control" class="form-control"
placeholder="Search articles..." placeholder="Search articles..."
@input="debouncedSearch" @input="debouncedSearch"
/> />
<select v-model="topicFilter" class="form-control" @change="loadArticles"> <select v-model="topicFilter" class="form-control" @change="applyFilter">
<option value="">All Topics</option> <option value="">All Topics</option>
<option <option
v-for="app in topics" v-for="app in topics"
:key="app.appid" :key="app.appid"
:value="app.appid" :value="app.appid"
> >
{{ app.appname }} {{ app.appname }}
</option> </option>
</select> </select>
</div> </div>
<div class="card"> <div class="card">
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<template v-else> <template v-else>
<div class="table-container"> <div class="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
<th class="sortable" @click="toggleSort('topic')"> <th class="sortable" @click="toggleSort('topic')">
Topic Topic
<span v-if="sort === 'topic'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span> <span v-if="sort === 'topic'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span>
</th> </th>
<th class="sortable" @click="toggleSort('description')"> <th class="sortable" @click="toggleSort('description')">
Description Description
<span v-if="sort === 'description'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span> <span v-if="sort === 'description'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span>
</th> </th>
<th class="sortable" style="width: 100px;" @click="toggleSort('clicks')"> <th class="sortable" style="width: 100px;" @click="toggleSort('clicks')">
Clicks Clicks
<span v-if="sort === 'clicks'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span> <span v-if="sort === 'clicks'" class="sort-arrow">{{ order === 'asc' ? '&#9650;' : '&#9660;' }}</span>
</th> </th>
<th style="width: 120px;">Actions</th> <th style="width: 120px;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="article in articles" :key="article.linkid"> <tr v-for="article in articles" :key="article.linkid">
<td> <td>
<router-link <router-link
v-if="article.application" v-if="article.application"
:to="`/applications/${article.application.appid}`" :to="`/applications/${article.application.appid}`"
> >
{{ article.application.appname }} {{ article.application.appname }}
</router-link> </router-link>
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</td> </td>
<td> <td>
<a <a
href="#" href="#"
class="article-link" class="article-link"
@click.prevent="openArticle(article)" @click.prevent="openArticle(article)"
:title="article.linkurl" :title="article.linkurl"
> >
{{ truncate(article.shortdescription, 95) }} {{ truncate(article.shortdescription, 95) }}
</a> </a>
</td> </td>
<td style="text-align: center; font-weight: 500;">{{ article.clicks }}</td> <td style="text-align: center; font-weight: 500;">{{ article.clicks }}</td>
<td class="actions"> <td class="actions">
<router-link :to="`/knowledgebase/${article.linkid}`" class="btn btn-sm btn-secondary"> <router-link :to="`/knowledgebase/${article.linkid}`" class="btn btn-sm btn-secondary">
View View
</router-link> </router-link>
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-sm btn-secondary"> <router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-sm btn-secondary">
Edit Edit
</router-link> </router-link>
</td> </td>
</tr> </tr>
<tr v-if="articles.length === 0"> <tr v-if="articles.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);"> <td colspan="4" style="text-align: center; color: var(--text-light);">
No articles found No articles found
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<PaginationBar <PaginationBar
:page="page" :page="page"
:totalPages="totalPages" :totalPages="totalPages"
:perPage="perPage" :perPage="perPage"
@update:page="goToPage" @update:page="goToPage"
@update:perPage="changePerPage" @update:perPage="changePerPage"
/> />
</template> </template>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { knowledgebaseApi, applicationsApi } from '@/api' import { knowledgebaseApi, applicationsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue' import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery' import { useListQuery } from '@/composables/listQuery'
const loading = ref(true) const loading = ref(true)
const articles = ref([]) const articles = ref([])
const topics = ref([]) const topics = ref([])
const stats = ref(null) const stats = ref(null)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles }) const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles })
const perPage = ref(20) const perPage = ref(20)
const totalPages = ref(1) const totalPages = ref(1)
const topicFilter = ref('') const topicFilter = ref('')
const sort = ref('clicks') const sort = ref('clicks')
const order = ref('desc') const order = ref('desc')
let searchTimeout = null let searchTimeout = null
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
loadArticles(), loadArticles(),
loadTopics(), loadTopics(),
loadStats() loadStats()
]) ])
}) })
async function loadArticles() { // A filter change must go back to page 1. Selecting a filter while on page 5
loading.value = true // asked the server for page 5 of a result set that now has one page, and the
try { // list came back empty as though the filter matched nothing.
const params = { //
page: page.value, // setPage(1) writes the URL, which the composable's watcher picks up and
perpage: perPage.value, // answers with onChange - so calling the loader as well would fetch twice.
sort: sort.value, // Load directly only when already on page 1, where nothing changes and the
order: order.value // watcher stays silent.
} function applyFilter() {
if (search.value) params.search = search.value if (page.value > 1) setPage(1)
if (topicFilter.value) params.appid = topicFilter.value else loadArticles()
}
const response = await knowledgebaseApi.list(params)
articles.value = response.data.data || [] async function loadArticles() {
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1 loading.value = true
} catch (error) { try {
console.error('Error loading articles:', error) const params = {
} finally { page: page.value,
loading.value = false perpage: perPage.value,
} sort: sort.value,
} order: order.value
}
async function loadTopics() { if (search.value) params.search = search.value
try { if (topicFilter.value) params.appid = topicFilter.value
const response = await applicationsApi.list({ perpage: 1000 })
topics.value = response.data.data || [] const response = await knowledgebaseApi.list(params)
} catch (error) { articles.value = response.data.data || []
console.error('Error loading topics:', error) totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} } catch (error) {
} console.error('Error loading articles:', error)
} finally {
async function loadStats() { loading.value = false
try { }
const response = await knowledgebaseApi.getStats() }
stats.value = response.data.data
} catch (error) { async function loadTopics() {
console.error('Error loading stats:', error) try {
} const response = await applicationsApi.list({ perpage: 1000 })
} topics.value = response.data.data || []
} catch (error) {
function debouncedSearch() { console.error('Error loading topics:', error)
clearTimeout(searchTimeout) }
searchTimeout = setTimeout(() => { }
setSearch(search.value)
loadArticles() async function loadStats() {
}, 300) try {
} const response = await knowledgebaseApi.getStats()
stats.value = response.data.data
function goToPage(p) { } catch (error) {
setPage(p) console.error('Error loading stats:', error)
loadArticles() }
} }
function changePerPage(newPerPage) { function debouncedSearch() {
perPage.value = newPerPage clearTimeout(searchTimeout)
setPage(1) searchTimeout = setTimeout(() => {
loadArticles() setSearch(search.value)
} loadArticles()
}, 300)
function toggleSort(column) { }
if (sort.value === column) {
order.value = order.value === 'desc' ? 'asc' : 'desc' function goToPage(p) {
} else { setPage(p)
sort.value = column loadArticles()
order.value = 'desc' }
}
loadArticles() function changePerPage(newPerPage) {
} perPage.value = newPerPage
setPage(1)
function truncate(text, length) { loadArticles()
if (!text) return '' }
if (text.length <= length) return text
return text.substring(0, length) + '...' function toggleSort(column) {
} if (sort.value === column) {
order.value = order.value === 'desc' ? 'asc' : 'desc'
async function openArticle(article) { } else {
try { sort.value = column
await knowledgebaseApi.trackClick(article.linkid) order.value = 'desc'
article.clicks = (article.clicks || 0) + 1 }
if (stats.value) { loadArticles()
stats.value.totalclicks++ }
}
if (article.linkurl) { function truncate(text, length) {
window.open(article.linkurl, '_blank') if (!text) return ''
} if (text.length <= length) return text
} catch (error) { return text.substring(0, length) + '...'
console.error('Error tracking click:', error) }
if (article.linkurl) {
window.open(article.linkurl, '_blank') async function openArticle(article) {
} try {
} await knowledgebaseApi.trackClick(article.linkid)
} article.clicks = (article.clicks || 0) + 1
</script> if (stats.value) {
stats.value.totalclicks++
<style scoped> }
/* Knowledge Base specific styles only */ if (article.linkurl) {
.header-left { window.open(article.linkurl, '_blank')
display: flex; }
align-items: center; } catch (error) {
gap: 1rem; console.error('Error tracking click:', error)
} if (article.linkurl) {
window.open(article.linkurl, '_blank')
.header-left h2 { }
margin: 0; }
} }
</script>
.stats-badge {
background: var(--bg); <style scoped>
color: var(--link); /* Knowledge Base specific styles only */
padding: 0.5rem 1rem; .header-left {
border-radius: 6px; display: flex;
font-size: 1rem; align-items: center;
font-weight: 500; gap: 1rem;
} }
.article-link { .header-left h2 {
color: var(--text); margin: 0;
} }
.article-link:hover { .stats-badge {
color: var(--link); background: var(--bg);
} color: var(--link);
padding: 0.5rem 1rem;
.text-muted { border-radius: 6px;
color: var(--text-light); font-size: 1rem;
} font-weight: 500;
</style> }
.article-link {
color: var(--text);
}
.article-link:hover {
color: var(--link);
}
.text-muted {
color: var(--text-light);
}
</style>

View File

@@ -33,13 +33,13 @@
placeholder="Search by hostname, asset #, serial..." placeholder="Search by hostname, asset #, serial..."
@input="debouncedSearch" @input="debouncedSearch"
/> />
<select v-model="vendorFilter" class="form-control" @change="loadDevices"> <select v-model="vendorFilter" class="form-control" @change="applyFilter">
<option value="">All Vendors</option> <option value="">All Vendors</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid"> <option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }} {{ v.vendor }}
</option> </option>
</select> </select>
<select v-model="locationFilter" class="form-control" @change="loadDevices"> <select v-model="locationFilter" class="form-control" @change="applyFilter">
<option value="">All Locations</option> <option value="">All Locations</option>
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid"> <option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
{{ loc.locationname }} {{ loc.locationname }}
@@ -193,6 +193,19 @@ async function loadLocations() {
} }
} }
// 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 loadDevices()
}
async function loadDevices() { async function loadDevices() {
loading.value = true loading.value = true
try { try {

View File

@@ -14,13 +14,13 @@
placeholder="Search subnets..." placeholder="Search subnets..."
@input="debouncedSearch" @input="debouncedSearch"
/> />
<select v-model="vlanFilter" class="form-control" @change="loadSubnets"> <select v-model="vlanFilter" class="form-control" @change="applyFilter">
<option value="">All VLANs</option> <option value="">All VLANs</option>
<option v-for="vlan in vlans" :key="vlan.vlanid" :value="vlan.vlanid"> <option v-for="vlan in vlans" :key="vlan.vlanid" :value="vlan.vlanid">
VLAN {{ vlan.vlannumber }} - {{ vlan.name }} VLAN {{ vlan.vlannumber }} - {{ vlan.name }}
</option> </option>
</select> </select>
<select v-model="typeFilter" class="form-control" @change="loadSubnets"> <select v-model="typeFilter" class="form-control" @change="applyFilter">
<option value="">All Types</option> <option value="">All Types</option>
<option value="ipv4">IPv4</option> <option value="ipv4">IPv4</option>
<option value="ipv6">IPv6</option> <option value="ipv6">IPv6</option>
@@ -359,6 +359,19 @@ async function loadLocations() {
} }
} }
// 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 loadSubnets()
}
async function loadSubnets() { async function loadSubnets() {
loading.value = true loading.value = true
try { try {

View File

@@ -14,7 +14,7 @@
placeholder="Search VLANs..." placeholder="Search VLANs..."
@input="debouncedSearch" @input="debouncedSearch"
/> />
<select v-model="typeFilter" class="form-control" @change="loadVLANs"> <select v-model="typeFilter" class="form-control" @change="applyFilter">
<option value="">All Types</option> <option value="">All Types</option>
<option value="data">Data</option> <option value="data">Data</option>
<option value="voice">Voice</option> <option value="voice">Voice</option>
@@ -220,6 +220,19 @@ onMounted(() => {
loadVLANs() loadVLANs()
}) })
// 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 loadVLANs()
}
async function loadVLANs() { async function loadVLANs() {
loading.value = true loading.value = true
try { try {

View File

@@ -1,185 +1,198 @@
<template> <template>
<div class="page-header"> <div class="page-header">
<h1>Notifications</h1> <h1>Notifications</h1>
<div class="actions"> <div class="actions">
<router-link to="/settings/notificationtypes" class="btn btn-secondary">Manage Types</router-link> <router-link to="/settings/notificationtypes" class="btn btn-secondary">Manage Types</router-link>
<router-link to="/notifications/new" class="btn btn-primary">New Notification</router-link> <router-link to="/notifications/new" class="btn btn-primary">New Notification</router-link>
</div> </div>
</div> </div>
<div class="filters"> <div class="filters">
<input <input
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
class="form-control" class="form-control"
placeholder="Search notifications..." placeholder="Search notifications..."
@input="debouncedSearch" @input="debouncedSearch"
/> />
<select v-model="selectedType" class="form-control" @change="loadNotifications"> <select v-model="selectedType" class="form-control" @change="applyFilter">
<option value="">All Types</option> <option value="">All Types</option>
<option v-for="type in types" :key="type.notificationtypeid" :value="type.notificationtypeid"> <option v-for="type in types" :key="type.notificationtypeid" :value="type.notificationtypeid">
{{ type.typename }} {{ type.typename }}
</option> </option>
</select> </select>
<select v-model="currentFilter" class="form-control" @change="loadNotifications"> <select v-model="currentFilter" class="form-control" @change="applyFilter">
<option value="">All</option> <option value="">All</option>
<option value="current">Current Only</option> <option value="current">Current Only</option>
<option value="pinned">Pinned Only</option> <option value="pinned">Pinned Only</option>
</select> </select>
</div> </div>
<div class="card"> <div class="card">
<div v-if="loading" class="loading">Loading...</div> <div v-if="loading" class="loading">Loading...</div>
<div v-else-if="notifications.length === 0" class="empty"> <div v-else-if="notifications.length === 0" class="empty">
No notifications found. No notifications found.
</div> </div>
<div v-else class="table-container"> <div v-else class="table-container">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Title</th> <th>Title</th>
<th>Type</th> <th>Type</th>
<th>Start Date</th> <th>Start Date</th>
<th>End Date</th> <th>End Date</th>
<th>Status</th> <th>Status</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="notification in notifications" :key="notification.notificationid"> <tr v-for="notification in notifications" :key="notification.notificationid">
<td> <td>
<router-link :to="`/notifications/${notification.notificationid}/edit`"> <router-link :to="`/notifications/${notification.notificationid}/edit`">
{{ notification.title }} {{ notification.title }}
</router-link> </router-link>
<span v-if="notification.ispinned" class="badge badge-primary" title="Pinned">Pinned</span> <span v-if="notification.ispinned" class="badge badge-primary" title="Pinned">Pinned</span>
</td> </td>
<td> <td>
<span class="badge" :style="{ backgroundColor: notification.typecolor }"> <span class="badge" :style="{ backgroundColor: notification.typecolor }">
{{ notification.typename }} {{ notification.typename }}
</span> </span>
</td> </td>
<td>{{ formatDate(notification.startdate) }}</td> <td>{{ formatDate(notification.startdate) }}</td>
<td>{{ notification.enddate ? formatDate(notification.enddate) : 'No end' }}</td> <td>{{ notification.enddate ? formatDate(notification.enddate) : 'No end' }}</td>
<td> <td>
<span :class="['badge', notification.iscurrent ? 'badge-success' : 'badge-secondary']"> <span :class="['badge', notification.iscurrent ? 'badge-success' : 'badge-secondary']">
{{ notification.iscurrent ? 'Active' : 'Inactive' }} {{ notification.iscurrent ? 'Active' : 'Inactive' }}
</span> </span>
</td> </td>
<td class="actions"> <td class="actions">
<router-link :to="`/notifications/${notification.notificationid}/edit`" class="btn btn-small"> <router-link :to="`/notifications/${notification.notificationid}/edit`" class="btn btn-small">
Edit Edit
</router-link> </router-link>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination --> <!-- Pagination -->
<PaginationBar <PaginationBar
:page="page" :page="page"
:totalPages="totalPages" :totalPages="totalPages"
:perPage="perPage" :perPage="perPage"
@update:page="goToPage" @update:page="goToPage"
@update:perPage="changePerPage" @update:perPage="changePerPage"
/> />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { notificationsApi, settingsApi } from '@/api' import { notificationsApi, settingsApi } from '@/api'
import PaginationBar from '@/components/PaginationBar.vue' import PaginationBar from '@/components/PaginationBar.vue'
import { useListQuery } from '@/composables/listQuery' import { useListQuery } from '@/composables/listQuery'
import { formatInZone, DEFAULT_TZ } from '@/utils/datetime' import { formatInZone, DEFAULT_TZ } from '@/utils/datetime'
const notifications = ref([]) const notifications = ref([])
const types = ref([]) const types = ref([])
const loading = ref(true) const loading = ref(true)
const { page, search: searchQuery, setPage, setSearch } = useListQuery({ onChange: loadNotifications }) const { page, search: searchQuery, setPage, setSearch } = useListQuery({ onChange: loadNotifications })
const selectedType = ref('') const selectedType = ref('')
const currentFilter = ref('') const currentFilter = ref('')
const perPage = ref(20) const perPage = ref(20)
const total = ref(0) const total = ref(0)
const totalPages = ref(1) const totalPages = ref(1)
const siteTimezone = ref(DEFAULT_TZ) const siteTimezone = ref(DEFAULT_TZ)
let searchTimeout = null let searchTimeout = null
onMounted(async () => { onMounted(async () => {
try { try {
const tzRes = await settingsApi.get('site_timezone') const tzRes = await settingsApi.get('site_timezone')
const tzValue = tzRes?.data?.data?.value const tzValue = tzRes?.data?.data?.value
if (tzValue) siteTimezone.value = tzValue if (tzValue) siteTimezone.value = tzValue
} catch (e) { /* keep default tz */ } } catch (e) { /* keep default tz */ }
await loadTypes() await loadTypes()
await loadNotifications() await loadNotifications()
}) })
async function loadTypes() { async function loadTypes() {
try { try {
const response = await notificationsApi.types.list() const response = await notificationsApi.types.list()
types.value = response.data.data types.value = response.data.data
} catch (error) { } catch (error) {
console.error('Error loading types:', error) console.error('Error loading types:', error)
} }
} }
async function loadNotifications() { // A filter change must go back to page 1. Selecting a filter while on page 5
loading.value = true // asked the server for page 5 of a result set that now has one page, and the
try { // list came back empty as though the filter matched nothing.
const params = { //
page: page.value, // setPage(1) writes the URL, which the composable's watcher picks up and
perpage: perPage.value // 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.
if (searchQuery.value) { function applyFilter() {
params.search = searchQuery.value if (page.value > 1) setPage(1)
} else loadNotifications()
if (selectedType.value) { }
params.typeid = selectedType.value
} async function loadNotifications() {
if (currentFilter.value === 'current') { loading.value = true
params.current = 'true' try {
} else if (currentFilter.value === 'pinned') { const params = {
params.pinned = 'true' page: page.value,
} perpage: perPage.value
}
const response = await notificationsApi.list(params)
notifications.value = response.data.data if (searchQuery.value) {
total.value = response.data.meta?.pagination?.total || notifications.value.length params.search = searchQuery.value
totalPages.value = Math.ceil(total.value / perPage.value) }
} catch (error) { if (selectedType.value) {
console.error('Error loading notifications:', error) params.typeid = selectedType.value
} finally { }
loading.value = false if (currentFilter.value === 'current') {
} params.current = 'true'
} } else if (currentFilter.value === 'pinned') {
params.pinned = 'true'
function debouncedSearch() { }
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => { const response = await notificationsApi.list(params)
setSearch(searchQuery.value) notifications.value = response.data.data
loadNotifications() total.value = response.data.meta?.pagination?.total || notifications.value.length
}, 300) totalPages.value = Math.ceil(total.value / perPage.value)
} } catch (error) {
console.error('Error loading notifications:', error)
function goToPage(p) { } finally {
setPage(p) loading.value = false
loadNotifications() }
} }
function changePerPage(newPerPage) { function debouncedSearch() {
perPage.value = newPerPage clearTimeout(searchTimeout)
setPage(1) searchTimeout = setTimeout(() => {
loadNotifications() setSearch(searchQuery.value)
} loadNotifications()
}, 300)
function formatDate(dateStr) { }
if (!dateStr) return ''
// startdate/enddate are UTC; show the site-zone calendar date. function goToPage(p) {
return formatInZone(dateStr, siteTimezone.value, { setPage(p)
year: 'numeric', month: 'numeric', day: 'numeric', hour: undefined, minute: undefined loadNotifications()
}) }
}
</script> function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadNotifications()
}
function formatDate(dateStr) {
if (!dateStr) return ''
// startdate/enddate are UTC; show the site-zone calendar date.
return formatInZone(dateStr, siteTimezone.value, {
year: 'numeric', month: 'numeric', day: 'numeric', hour: undefined, minute: undefined
})
}
</script>

View File

@@ -19,11 +19,11 @@
@input="debouncedSearch" @input="debouncedSearch"
/> />
<label class="lowstock-filter"> <label class="lowstock-filter">
<input v-model="lowstockOnly" type="checkbox" @change="loadItems" /> <input v-model="lowstockOnly" type="checkbox" @change="applyFilter" />
Low stock only Low stock only
</label> </label>
<label class="lowstock-filter"> <label class="lowstock-filter">
<input v-model="includeRetired" type="checkbox" @change="loadItems" /> <input v-model="includeRetired" type="checkbox" @change="applyFilter" />
Include retired Include retired
</label> </label>
</div> </div>
@@ -108,6 +108,19 @@ let searchTimeout = null
onMounted(loadItems) onMounted(loadItems)
// 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 loadItems()
}
async function loadItems() { async function loadItems() {
loading.value = true loading.value = true
try { try {

View File

@@ -15,7 +15,7 @@
@input="debouncedSearch" @input="debouncedSearch"
/> />
<label class="checkbox-label"> <label class="checkbox-label">
<input type="checkbox" v-model="showAvailableOnly" @change="loadDevices" /> <input type="checkbox" v-model="showAvailableOnly" @change="applyFilter" />
Available Only Available Only
</label> </label>
</div> </div>
@@ -172,6 +172,19 @@ onMounted(() => {
loadDevices() loadDevices()
}) })
// 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 loadDevices()
}
async function loadDevices() { async function loadDevices() {
loading.value = true loading.value = true
try { try {

230
scripts/retype_models.py Normal file
View File

@@ -0,0 +1,230 @@
"""Give catalog models a useful type, so their assets can inherit it.
The catalog types every printer model as "Printer" - true, and useless. It says
the product is a printer, not whether it is a laser, a plotter or a label
printer. That finer answer is a property of the MODEL (every VersaLink C405 ever
made is a laser MFP), but nothing records it, so nothing can derive it.
Recording it in the catalog rather than on each printer means:
- backfill_vendor_from_model.py then fills every printer automatically, since
it matches an asset's type to its model's type BY EXACT NAME;
- a printer added later inherits the right type as soon as its model is
chosen, with no second step, ever;
- the catalog stops claiming a plotter and a label printer are the same thing.
Two passes, because guessing at a model's type unreviewed is how a plotter ends
up filed as a laser:
python scripts/retype_models.py --export printer-types.csv
... open it, correct the newtype column, save ...
python scripts/retype_models.py --apply printer-types.csv
python scripts/retype_models.py --apply printer-types.csv --commit
A newtype is REFUSED unless it already exists in that asset class's own type
vocabulary - "Laser" is accepted for printers because printertypes has it,
"Laserjet" is not. That is what keeps the name match working afterwards.
Defaults to printers. --class machines|computers|network for the others.
"""
import argparse
import csv
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Each asset class, its own type vocabulary, and the modeltypes.category that a
# newly created model type should carry.
CLASSES = {
'printers': ('printers', 'printertypes', 'printertype', 'Printer'),
'machines': ('machines', 'machinetypes', 'machinetype', 'Equipment'),
'computers': ('computers', 'computertypes', 'computertype', 'PC'),
'network': ('networkdevices', 'networkdevicetypes', 'networkdevicetype', 'Network'),
}
# Model-number keywords that suggest a type. A SUGGESTION only - it lands in the
# CSV for a person to accept or correct, and is never applied on its own.
# ORDER MATTERS - first match wins, so the specific brands come before the
# generic patterns. A bare t\d{3,4} for plotters used to match "Zebra ZT411" and
# file a label printer as a plotter, which is exactly the sort of confident
# mistake the review step exists to catch. The pattern is now explicit instead.
HINTS = [
(r'zebra|\bzt\d|\bzd\d|\bgk\d|\bgx\d|label|datamax|intermec', 'Label'),
(r'designjet|plotter|latex|\bdj\s?t\d{3,4}', 'Plotter'),
(r'datacard|zxp|card|cr80', 'Card'),
(r'thermal|tsp\d|tm-t', 'Thermal'),
(r'deskjet|officejet|inkjet|pixma', 'Inkjet'),
(r'\bml-\d|dot ?matrix|lq-\d|fx-\d', 'Dot Matrix'),
# Multifunction before plain laser: an MFP is also a laser, and the more
# specific answer is the useful one.
(r'mfp|mfc|workcentre|altalink|versalink|\bm\d{3}f', 'MFP'),
(r'laserjet|laser|phaser|\bls\b|\bp\d{4}', 'Laser'),
]
# Types the catalog uses that say nothing about the product. LocationOnly is the
# classic-ASP placeholder that also left 134 machines untyped.
PLACEHOLDERS = {'printer', 'locationonly', 'pc', 'equipment', 'network', 'computer', ''}
def suggest(modelnumber, description=''):
haystack = ('%s %s' % (modelnumber or '', description or '')).lower()
for pattern, answer in HINTS:
if re.search(pattern, haystack):
return answer
return ''
def vocabulary(connection, typetable, typename):
from sqlalchemy import text
rows = connection.execute(text('SELECT %s FROM %s ORDER BY 1' % (typename, typetable))).fetchall()
return [r[0] for r in rows if r[0]]
def export(connection, classname, path, include_all):
from sqlalchemy import text
assettable, typetable, typename, _ = CLASSES[classname]
rows = connection.execute(text("""
SELECT mo.modelnumberid AS modelnumberid,
mo.modelnumber AS modelnumber,
COALESCE(v.vendor, '') AS vendor,
COALESCE(mo.description, '') AS description,
COALESCE(mt.modeltype, '') AS currenttype,
COUNT(a.modelnumberid) AS assetcount
FROM {assettable} a
JOIN models mo ON a.modelnumberid = mo.modelnumberid
LEFT JOIN vendors v ON mo.vendorid = v.vendorid
LEFT JOIN modeltypes mt ON mo.modeltypeid = mt.modeltypeid
GROUP BY mo.modelnumberid, mo.modelnumber, v.vendor, mo.description, mt.modeltype
ORDER BY assetcount DESC, mo.modelnumber
""".format(assettable=assettable))).fetchall()
known = {v.lower() for v in vocabulary(connection, typetable, typename)}
out = []
for row in rows:
current = (row.currenttype or '').strip()
# Already a real answer in this class's vocabulary: nothing to decide.
if not include_all and current.lower() in known:
continue
out.append({
'modelnumberid': row.modelnumberid,
'modelnumber': row.modelnumber,
'vendor': row.vendor,
'assets': row.assetcount,
'currenttype': current,
'suggested': suggest(row.modelnumber, row.description),
'newtype': suggest(row.modelnumber, row.description),
})
with open(path, 'w', newline='', encoding='utf-8-sig') as handle:
writer = csv.DictWriter(handle, fieldnames=[
'modelnumberid', 'modelnumber', 'vendor', 'assets',
'currenttype', 'suggested', 'newtype'])
writer.writeheader()
writer.writerows(out)
print('Wrote %s with %d model(s) to review.' % (path, len(out)))
print('Valid values for newtype (%s):' % classname)
print(' %s' % ', '.join(vocabulary(connection, typetable, typename)))
print('')
print('The newtype column is pre-filled with a guess from the model number.')
print('CHECK EVERY ROW - a wrong guess types every asset using that model.')
print('Clear newtype to leave a model alone.')
return 0
def apply(connection, classname, path, commit):
from sqlalchemy import text
_, typetable, typename, category = CLASSES[classname]
known = {v.lower(): v for v in vocabulary(connection, typetable, typename)}
with open(path, newline='', encoding='utf-8-sig') as handle:
rows = list(csv.DictReader(handle))
planned, refused = [], []
for row in rows:
newtype = (row.get('newtype') or '').strip()
if not newtype:
continue
if newtype.lower() not in known:
refused.append((row.get('modelnumber', '?'), newtype))
continue
planned.append((int(row['modelnumberid']), row.get('modelnumber', '?'),
known[newtype.lower()], int(row.get('assets') or 0)))
for modelnumber, newtype in refused:
print(' REFUSED %-28s "%s" is not in %s' % (modelnumber, newtype, typetable))
if refused:
print(' (add it to %s first if it is a real type, or correct the spelling)' % typetable)
print('')
print('%d model(s) would be retyped, covering %d asset(s):'
% (len(planned), sum(p[3] for p in planned)))
for _, modelnumber, newtype, count in planned[:40]:
print(' %-32s -> %-12s (%d asset(s))' % (modelnumber[:32], newtype, count))
if len(planned) > 40:
print(' ... and %d more' % (len(planned) - 40))
if not commit:
print('')
print('DRY RUN - nothing written. Re-run with --commit to apply.')
return 0
for modelnumberid, _, newtype, _ in planned:
typeid = connection.execute(
text('SELECT modeltypeid FROM modeltypes WHERE modeltype = :n'),
{'n': newtype}).scalar()
if typeid is None:
# The name is valid for this asset class but the catalog has no such
# model type yet. Create it, tagged with this class's category.
connection.execute(
text('INSERT INTO modeltypes (modeltype, category) VALUES (:n, :c)'),
{'n': newtype, 'c': category})
typeid = connection.execute(
text('SELECT modeltypeid FROM modeltypes WHERE modeltype = :n'),
{'n': newtype}).scalar()
connection.execute(
text('UPDATE models SET modeltypeid = :t WHERE modelnumberid = :m'),
{'t': typeid, 'm': modelnumberid})
print('')
print('Committed. %d model(s) retyped.' % len(planned))
print('Now run: python scripts/backfill_vendor_from_model.py --commit')
print('which copies the type onto every asset using those models.')
return 0
def main():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--class', dest='classname', default='printers',
choices=sorted(CLASSES), help='asset class (default printers)')
parser.add_argument('--export', metavar='CSV', help='write models needing a type')
parser.add_argument('--apply', metavar='CSV', help='apply a reviewed file')
parser.add_argument('--all', action='store_true',
help='with --export, include models that already have a valid type')
parser.add_argument('--commit', action='store_true', help='write the changes')
args = parser.parse_args()
if not args.export and not args.apply:
parser.error('give --export or --apply')
from shopdb import create_app
from shopdb.extensions import db
app = create_app()
with app.app_context():
connection = db.session.connection()
if args.export:
code = export(connection, args.classname, args.export, args.all)
else:
code = apply(connection, args.classname, args.apply, args.commit)
if args.commit:
db.session.commit()
return code
if __name__ == '__main__':
sys.exit(main())