get_pagination_params clamps perpage to MAX_PAGE_SIZE (100) and reports nothing about having done so, so a caller asking for perpage: 1000 gets the first 100 rows and a success response. Every picker built that way looked complete and was not. Found on a live site with 126 active applications: the 26 sorting last were absent from the knowledge-base topic dropdown, so an article could not be filed against them. Nothing was wrong with those application records, and editing them could never have helped. Adds fetchAllPages() to the api module, generalizing the one call site that already handled this correctly (modelsApi.listAll), and points the four application pickers at a new applicationsApi.listAll(): the KB article form, the KB list's topic filter, the notification form, and the report filter builder. Lists that render a page at a time are untouched - they page for a reason. Other callers still asking for more than 100 rows of vendors, locations, models, subnets and the rest are latent: correct only while those tables stay under 100, and silent on the day they do not.
282 lines
7.9 KiB
Vue
282 lines
7.9 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<div class="header-left">
|
|
<h2>Knowledge Base</h2>
|
|
<span v-if="stats" class="stats-badge">
|
|
{{ stats.totalarticles }} articles | {{ stats.totalclicks.toLocaleString() }} total clicks
|
|
</span>
|
|
</div>
|
|
<router-link to="/knowledgebase/new" class="btn btn-primary">Add Article</router-link>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="filters">
|
|
<input
|
|
v-model="search"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search articles..."
|
|
@input="debouncedSearch"
|
|
/>
|
|
<select v-model="topicFilter" class="form-control" @change="applyFilter">
|
|
<option value="">All Topics</option>
|
|
<option
|
|
v-for="app in topics"
|
|
:key="app.appid"
|
|
:value="app.appid"
|
|
>
|
|
{{ app.appname }}
|
|
</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 class="sortable" @click="toggleSort('topic')">
|
|
Topic
|
|
<span v-if="sort === 'topic'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
|
</th>
|
|
<th class="sortable" @click="toggleSort('description')">
|
|
Description
|
|
<span v-if="sort === 'description'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
|
</th>
|
|
<th class="sortable" style="width: 100px;" @click="toggleSort('clicks')">
|
|
Clicks
|
|
<span v-if="sort === 'clicks'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
|
</th>
|
|
<th style="width: 120px;">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="article in articles" :key="article.linkid">
|
|
<td>
|
|
<router-link
|
|
v-if="article.application"
|
|
:to="`/applications/${article.application.appid}`"
|
|
>
|
|
{{ article.application.appname }}
|
|
</router-link>
|
|
<span v-else class="text-muted">-</span>
|
|
</td>
|
|
<td>
|
|
<a
|
|
href="#"
|
|
class="article-link"
|
|
@click.prevent="openArticle(article)"
|
|
:title="article.linkurl"
|
|
>
|
|
{{ truncate(article.shortdescription, 95) }}
|
|
</a>
|
|
</td>
|
|
<td style="text-align: center; font-weight: 500;">{{ article.clicks }}</td>
|
|
<td class="actions">
|
|
<router-link :to="`/knowledgebase/${article.linkid}`" class="btn btn-sm btn-secondary">
|
|
View
|
|
</router-link>
|
|
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-sm btn-secondary">
|
|
Edit
|
|
</router-link>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="articles.length === 0">
|
|
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
|
No articles 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, onMounted } from 'vue'
|
|
import { knowledgebaseApi, applicationsApi } from '@/api'
|
|
import PaginationBar from '@/components/PaginationBar.vue'
|
|
import { useListQuery } from '@/composables/listQuery'
|
|
|
|
const loading = ref(true)
|
|
const articles = ref([])
|
|
const topics = ref([])
|
|
const stats = ref(null)
|
|
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles })
|
|
const perPage = ref(20)
|
|
const totalPages = ref(1)
|
|
const topicFilter = ref('')
|
|
const sort = ref('clicks')
|
|
const order = ref('desc')
|
|
|
|
let searchTimeout = null
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([
|
|
loadArticles(),
|
|
loadTopics(),
|
|
loadStats()
|
|
])
|
|
})
|
|
|
|
// 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 loadArticles()
|
|
}
|
|
|
|
async function loadArticles() {
|
|
loading.value = true
|
|
try {
|
|
const params = {
|
|
page: page.value,
|
|
perpage: perPage.value,
|
|
sort: sort.value,
|
|
order: order.value
|
|
}
|
|
if (search.value) params.search = search.value
|
|
if (topicFilter.value) params.appid = topicFilter.value
|
|
|
|
const response = await knowledgebaseApi.list(params)
|
|
articles.value = response.data.data || []
|
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
|
} catch (error) {
|
|
console.error('Error loading articles:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadTopics() {
|
|
try {
|
|
// listAll, not list: the backend clamps perpage to 100 without saying so,
|
|
// and the catalogue is past that, so topics sorting late in the alphabet
|
|
// were silently missing from this filter.
|
|
// isactive is the only filter that applies to a topic: ishidden governs
|
|
// whether an application shows on the tiles page, which says nothing about
|
|
// whether it can be the subject of an article.
|
|
topics.value = await applicationsApi.listAll({ showhidden: true })
|
|
} catch (error) {
|
|
console.error('Error loading topics:', error)
|
|
}
|
|
}
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const response = await knowledgebaseApi.getStats()
|
|
stats.value = response.data.data
|
|
} catch (error) {
|
|
console.error('Error loading stats:', error)
|
|
}
|
|
}
|
|
|
|
function debouncedSearch() {
|
|
clearTimeout(searchTimeout)
|
|
searchTimeout = setTimeout(() => {
|
|
setSearch(search.value)
|
|
loadArticles()
|
|
}, 300)
|
|
}
|
|
|
|
function goToPage(p) {
|
|
setPage(p)
|
|
loadArticles()
|
|
}
|
|
|
|
function changePerPage(newPerPage) {
|
|
perPage.value = newPerPage
|
|
setPage(1)
|
|
loadArticles()
|
|
}
|
|
|
|
function toggleSort(column) {
|
|
if (sort.value === column) {
|
|
order.value = order.value === 'desc' ? 'asc' : 'desc'
|
|
} else {
|
|
sort.value = column
|
|
order.value = 'desc'
|
|
}
|
|
loadArticles()
|
|
}
|
|
|
|
function truncate(text, length) {
|
|
if (!text) return ''
|
|
if (text.length <= length) return text
|
|
return text.substring(0, length) + '...'
|
|
}
|
|
|
|
async function openArticle(article) {
|
|
try {
|
|
await knowledgebaseApi.trackClick(article.linkid)
|
|
article.clicks = (article.clicks || 0) + 1
|
|
if (stats.value) {
|
|
stats.value.totalclicks++
|
|
}
|
|
if (article.linkurl) {
|
|
window.open(article.linkurl, '_blank')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error tracking click:', error)
|
|
if (article.linkurl) {
|
|
window.open(article.linkurl, '_blank')
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Knowledge Base specific styles only */
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.header-left h2 {
|
|
margin: 0;
|
|
}
|
|
|
|
.stats-badge {
|
|
background: var(--bg);
|
|
color: var(--link);
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 6px;
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.article-link {
|
|
color: var(--text);
|
|
}
|
|
|
|
.article-link:hover {
|
|
color: var(--link);
|
|
}
|
|
|
|
.text-muted {
|
|
color: var(--text-light);
|
|
}
|
|
</style>
|