Tech Tools section with a code generator, toner report that names the part, toner forecasting, warranty proof of cover, application uploads
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"printedparts",
|
||||
"printers",
|
||||
"slides",
|
||||
"tools",
|
||||
"usb",
|
||||
"warranty"
|
||||
],
|
||||
|
||||
@@ -306,6 +306,9 @@ export const printersApi = {
|
||||
getDrivers(id) {
|
||||
return api.get(`/printers/${id}/drivers`)
|
||||
},
|
||||
supplyForecast(days = 90) {
|
||||
return api.get('/printers/supplies/forecast', { params: { days } })
|
||||
},
|
||||
lowSupplies() {
|
||||
return api.get('/printers/lowsupplies')
|
||||
},
|
||||
@@ -477,6 +480,33 @@ export const applicationsApi = {
|
||||
delete(id) {
|
||||
return api.delete(`/applications/${id}`)
|
||||
},
|
||||
// multipart image upload; backend sets image to the served URL
|
||||
uploadImage(id, file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post(`/applications/${id}/image`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
},
|
||||
removeImage(id) {
|
||||
return api.delete(`/applications/${id}/image`)
|
||||
},
|
||||
// installer upload; backend sets installpath to the download URL.
|
||||
// onProgress gets 0-100 - an installer is big enough that a silent wait
|
||||
// reads as a hang.
|
||||
uploadPackage(id, file, onProgress) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post(`/applications/${id}/package`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: event => {
|
||||
if (onProgress && event.total) {
|
||||
onProgress(Math.round((event.loaded * 100) / event.total))
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
removePackage(id) {
|
||||
return api.delete(`/applications/${id}/package`)
|
||||
},
|
||||
// Versions
|
||||
getVersions(appId) {
|
||||
return api.get(`/applications/${appId}/versions`)
|
||||
@@ -1088,6 +1118,15 @@ export const warrantyApi = {
|
||||
},
|
||||
report() {
|
||||
return api.get('/warranty/report')
|
||||
},
|
||||
// proof of cover: invoice, certificate, whatever the vendor sent
|
||||
uploadProof(id, file) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post(`/warranty/${id}/proof`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
},
|
||||
removeProof(id) {
|
||||
return api.delete(`/warranty/${id}/proof`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,16 +120,27 @@ defineExpose({ load })
|
||||
<style scoped>
|
||||
.dc-grid {
|
||||
display: grid;
|
||||
/* Four across on a wide screen, not five. A card holds a hostname, a machine
|
||||
number and a state on one line; at five columns those lines truncate on
|
||||
the very rows that matter most. auto-fit rather than auto-fill so two
|
||||
cards fill the width instead of leaving three empty tracks. */
|
||||
grid-template-columns: repeat(auto-fit, minmax(28rem, 1fr));
|
||||
/* Four across on a wide screen. A card holds a hostname, a machine number
|
||||
and a state on one line, so the track cannot go much below this without
|
||||
truncating the rows that matter most - but 28rem only fits three once the
|
||||
sidebar takes its share at 1920, which is the common case here. auto-fit
|
||||
rather than auto-fill so two cards fill the width instead of leaving
|
||||
empty tracks. */
|
||||
grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
|
||||
max-width: 120rem;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.75rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Four is the ceiling, not a consequence of the track width. Left to auto-fit
|
||||
alone this reaches five on an ultrawide, and a fifth column buys nothing -
|
||||
the cards get narrower and the rows they hold start truncating again. */
|
||||
@media (min-width: 96rem) {
|
||||
.dc-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.dc-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -1,268 +1,270 @@
|
||||
<template>
|
||||
<div class="location-tooltip-wrapper" @mouseenter="showTooltip" @mouseleave="onWrapperLeave">
|
||||
<slot></slot>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible && hasPosition"
|
||||
class="map-tooltip"
|
||||
:style="tooltipStyle"
|
||||
ref="tooltipRef"
|
||||
@mouseenter="onTooltipEnter"
|
||||
@mouseleave="onTooltipLeave"
|
||||
@wheel.prevent="onWheel"
|
||||
>
|
||||
<div class="map-tooltip-content">
|
||||
<div class="map-preview" ref="mapPreview">
|
||||
<div
|
||||
class="map-transform"
|
||||
:style="transformStyle"
|
||||
>
|
||||
<img
|
||||
:src="blueprintUrl"
|
||||
alt="Shop Floor Map"
|
||||
class="map-image"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
<!-- Marker dot -->
|
||||
<div
|
||||
class="marker-dot"
|
||||
:style="markerStyle"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-tooltip-footer">
|
||||
<span class="coordinates">{{ left }}, {{ top }}</span>
|
||||
<span class="zoom-hint">Scroll to zoom</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { currentTheme } from '../stores/theme'
|
||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
||||
|
||||
// Fetch this facility's blueprint + dimensions once; computeds below react
|
||||
// when it loads.
|
||||
loadMapConfig()
|
||||
|
||||
const props = defineProps({
|
||||
left: { type: Number, default: null },
|
||||
top: { type: Number, default: null },
|
||||
machineName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const visible = ref(false)
|
||||
const tooltipRef = ref(null)
|
||||
const mapPreview = ref(null)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
const isOverTooltip = ref(false)
|
||||
const zoom = ref(1)
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
const hasPosition = computed(() => {
|
||||
return props.left !== null && props.top !== null
|
||||
})
|
||||
|
||||
const blueprintUrl = computed(() => {
|
||||
// Reading currentTheme keeps this reactive to theme changes.
|
||||
return blueprintUrlFor(currentTheme.value)
|
||||
})
|
||||
|
||||
// Calculate marker position as percentage of the facility blueprint size
|
||||
const markerX = computed(() => {
|
||||
return (props.left / mapConfig.width) * 100
|
||||
})
|
||||
|
||||
const markerY = computed(() => {
|
||||
return (props.top / mapConfig.height) * 100
|
||||
})
|
||||
|
||||
// Marker style with counter-scale to maintain constant size
|
||||
const markerStyle = computed(() => ({
|
||||
left: markerX.value + '%',
|
||||
top: markerY.value + '%',
|
||||
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`
|
||||
}))
|
||||
|
||||
// Transform style that centers on the marker and zooms toward it
|
||||
const transformStyle = computed(() => {
|
||||
// Calculate translation to center the marker in the preview
|
||||
const translateX = 50 - markerX.value
|
||||
const translateY = 50 - markerY.value
|
||||
|
||||
return {
|
||||
transform: `translate(${translateX}%, ${translateY}%) scale(${zoom.value})`,
|
||||
transformOrigin: `${markerX.value}% ${markerY.value}%`
|
||||
}
|
||||
})
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
left: `${tooltipPosition.value.x}px`,
|
||||
top: `${tooltipPosition.value.y}px`
|
||||
}))
|
||||
|
||||
function onImageLoad() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
function showTooltip(event) {
|
||||
if (!hasPosition.value) return
|
||||
|
||||
visible.value = true
|
||||
zoom.value = 1
|
||||
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
tooltipPosition.value = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.bottom + 10
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
adjustPosition()
|
||||
})
|
||||
}
|
||||
|
||||
function onWrapperLeave() {
|
||||
// Small delay to allow moving to tooltip
|
||||
setTimeout(() => {
|
||||
if (!isOverTooltip.value) {
|
||||
hideTooltip()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function onTooltipEnter() {
|
||||
isOverTooltip.value = true
|
||||
}
|
||||
|
||||
function onTooltipLeave() {
|
||||
isOverTooltip.value = false
|
||||
hideTooltip()
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
visible.value = false
|
||||
zoom.value = 1
|
||||
}
|
||||
|
||||
function onWheel(event) {
|
||||
const delta = event.deltaY > 0 ? -0.3 : 0.3
|
||||
const newZoom = Math.max(1, Math.min(8, zoom.value + delta))
|
||||
zoom.value = newZoom
|
||||
}
|
||||
|
||||
function adjustPosition() {
|
||||
if (!tooltipRef.value) return
|
||||
|
||||
const tooltip = tooltipRef.value
|
||||
const rect = tooltip.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
if (rect.right > viewportWidth - 20) {
|
||||
tooltipPosition.value.x -= (rect.right - viewportWidth + 20)
|
||||
}
|
||||
if (rect.left < 20) {
|
||||
tooltipPosition.value.x += (20 - rect.left)
|
||||
}
|
||||
|
||||
if (rect.bottom > viewportHeight - 20) {
|
||||
tooltipPosition.value.y = rect.top - tooltip.offsetHeight - 20
|
||||
}
|
||||
}
|
||||
|
||||
// Reset zoom when tooltip becomes visible
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal) {
|
||||
zoom.value = 1
|
||||
}
|
||||
})
|
||||
|
||||
// Reset imageLoaded when theme changes to force reload
|
||||
watch(currentTheme, () => {
|
||||
imageLoaded.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.location-tooltip-wrapper {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.location-tooltip-wrapper:hover {
|
||||
color: var(--primary, #1976d2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.map-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.map-tooltip-content {
|
||||
background: var(--bg-card, #ffffff);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border, #e0e0e0);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-preview {
|
||||
position: relative;
|
||||
width: 500px;
|
||||
height: 385px;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #f5f5f5);
|
||||
}
|
||||
|
||||
.map-transform {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
|
||||
.map-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.marker-dot {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #ff0000;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 3px rgba(255,0,0,0.3), 0 0 10px #ff0000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-tooltip-footer {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg, #f5f5f5);
|
||||
border-top: 1px solid var(--border, #e0e0e0);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.coordinates {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-light, #666666);
|
||||
}
|
||||
|
||||
.zoom-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light, #666666);
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="location-tooltip-wrapper" @mouseenter="showTooltip" @mouseleave="onWrapperLeave">
|
||||
<slot></slot>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible && hasPosition"
|
||||
class="map-tooltip"
|
||||
:style="tooltipStyle"
|
||||
ref="tooltipRef"
|
||||
@mouseenter="onTooltipEnter"
|
||||
@mouseleave="onTooltipLeave"
|
||||
@wheel.prevent="onWheel"
|
||||
>
|
||||
<div class="map-tooltip-content">
|
||||
<div class="map-preview" ref="mapPreview">
|
||||
<div
|
||||
class="map-transform"
|
||||
:style="transformStyle"
|
||||
>
|
||||
<img
|
||||
:src="blueprintUrl"
|
||||
alt="Shop Floor Map"
|
||||
class="map-image"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
<!-- Marker dot -->
|
||||
<div
|
||||
class="marker-dot"
|
||||
:style="markerStyle"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-tooltip-footer">
|
||||
<span class="coordinates">{{ left }}, {{ top }}</span>
|
||||
<span class="zoom-hint">Scroll to zoom</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { currentTheme } from '../stores/theme'
|
||||
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
|
||||
|
||||
// Fetch this facility's blueprint + dimensions once; computeds below react
|
||||
// when it loads.
|
||||
loadMapConfig()
|
||||
|
||||
const props = defineProps({
|
||||
left: { type: Number, default: null },
|
||||
top: { type: Number, default: null },
|
||||
machineName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const visible = ref(false)
|
||||
const tooltipRef = ref(null)
|
||||
const mapPreview = ref(null)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
const isOverTooltip = ref(false)
|
||||
const zoom = ref(1)
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
const hasPosition = computed(() => {
|
||||
return props.left !== null && props.top !== null
|
||||
})
|
||||
|
||||
const blueprintUrl = computed(() => {
|
||||
// Reading currentTheme keeps this reactive to theme changes.
|
||||
return blueprintUrlFor(currentTheme.value)
|
||||
})
|
||||
|
||||
// Calculate marker position as percentage of the facility blueprint size
|
||||
const markerX = computed(() => {
|
||||
return (props.left / mapConfig.width) * 100
|
||||
})
|
||||
|
||||
const markerY = computed(() => {
|
||||
return (props.top / mapConfig.height) * 100
|
||||
})
|
||||
|
||||
// Marker style with counter-scale to maintain constant size
|
||||
const markerStyle = computed(() => ({
|
||||
left: markerX.value + '%',
|
||||
top: markerY.value + '%',
|
||||
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`
|
||||
}))
|
||||
|
||||
// Transform style that centers on the marker and zooms toward it
|
||||
const transformStyle = computed(() => {
|
||||
// Calculate translation to center the marker in the preview
|
||||
const translateX = 50 - markerX.value
|
||||
const translateY = 50 - markerY.value
|
||||
|
||||
return {
|
||||
transform: `translate(${translateX}%, ${translateY}%) scale(${zoom.value})`,
|
||||
transformOrigin: `${markerX.value}% ${markerY.value}%`
|
||||
}
|
||||
})
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
left: `${tooltipPosition.value.x}px`,
|
||||
top: `${tooltipPosition.value.y}px`
|
||||
}))
|
||||
|
||||
function onImageLoad() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
function showTooltip(event) {
|
||||
if (!hasPosition.value) return
|
||||
|
||||
visible.value = true
|
||||
zoom.value = 1
|
||||
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
tooltipPosition.value = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.bottom + 10
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
adjustPosition()
|
||||
})
|
||||
}
|
||||
|
||||
function onWrapperLeave() {
|
||||
// Small delay to allow moving to tooltip
|
||||
setTimeout(() => {
|
||||
if (!isOverTooltip.value) {
|
||||
hideTooltip()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function onTooltipEnter() {
|
||||
isOverTooltip.value = true
|
||||
}
|
||||
|
||||
function onTooltipLeave() {
|
||||
isOverTooltip.value = false
|
||||
hideTooltip()
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
visible.value = false
|
||||
zoom.value = 1
|
||||
}
|
||||
|
||||
function onWheel(event) {
|
||||
const delta = event.deltaY > 0 ? -0.3 : 0.3
|
||||
const newZoom = Math.max(1, Math.min(8, zoom.value + delta))
|
||||
zoom.value = newZoom
|
||||
}
|
||||
|
||||
function adjustPosition() {
|
||||
if (!tooltipRef.value) return
|
||||
|
||||
const tooltip = tooltipRef.value
|
||||
const rect = tooltip.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
if (rect.right > viewportWidth - 20) {
|
||||
tooltipPosition.value.x -= (rect.right - viewportWidth + 20)
|
||||
}
|
||||
if (rect.left < 20) {
|
||||
tooltipPosition.value.x += (20 - rect.left)
|
||||
}
|
||||
|
||||
if (rect.bottom > viewportHeight - 20) {
|
||||
tooltipPosition.value.y = rect.top - tooltip.offsetHeight - 20
|
||||
}
|
||||
}
|
||||
|
||||
// Reset zoom when tooltip becomes visible
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal) {
|
||||
zoom.value = 1
|
||||
}
|
||||
})
|
||||
|
||||
// Reset imageLoaded when theme changes to force reload
|
||||
watch(currentTheme, () => {
|
||||
imageLoaded.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.location-tooltip-wrapper {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.location-tooltip-wrapper:hover {
|
||||
color: var(--primary, #1976d2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.map-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.map-tooltip-content {
|
||||
background: var(--bg-card, #ffffff);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border, #e0e0e0);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-preview {
|
||||
position: relative;
|
||||
/* Smaller than it was: this is a glance-and-move-on preview, and at 500px it
|
||||
covered the row it was launched from. Aspect ratio kept. */
|
||||
width: 390px;
|
||||
height: 300px;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #f5f5f5);
|
||||
}
|
||||
|
||||
.map-transform {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
|
||||
.map-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.marker-dot {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #ff0000;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 3px rgba(255,0,0,0.3), 0 0 10px #ff0000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-tooltip-footer {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg, #f5f5f5);
|
||||
border-top: 1px solid var(--border, #e0e0e0);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.coordinates {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-light, #666666);
|
||||
}
|
||||
|
||||
.zoom-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light, #666666);
|
||||
}
|
||||
</style>
|
||||
|
||||
48
frontend/src/utils/fiscalWeek.js
Normal file
48
frontend/src/utils/fiscalWeek.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Fiscal week, as the classic ASP site computed it.
|
||||
*
|
||||
* That site called it "Fiscal Week" and calculated an ISO 8601 week number
|
||||
* (includes/leftsidebar.asp): week 1 is the week containing 4 January, weeks
|
||||
* start on Monday, and the year is decided by that week's Thursday. This is a
|
||||
* faithful port - the label and the arithmetic both come from there.
|
||||
*
|
||||
* Worth knowing if the numbers are ever questioned: a true GE fiscal calendar
|
||||
* need not line up with ISO weeks. Nobody has asked for a different rule, and
|
||||
* changing it here would silently disagree with the classic site people still
|
||||
* compare against, so it stays ISO until someone says otherwise.
|
||||
*/
|
||||
|
||||
/**
|
||||
* ISO 8601 week number for a date (1-53).
|
||||
*
|
||||
* Works in local time deliberately: the number people quote is the one on the
|
||||
* wall where they are, and a UTC-based week rolls over hours early in the
|
||||
* evening at a US site.
|
||||
*/
|
||||
export function fiscalWeek(date = new Date()) {
|
||||
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
|
||||
// The Thursday of this week decides which year the week belongs to, which is
|
||||
// what makes late December and early January land in the right year.
|
||||
const dayOfWeek = (day.getDay() + 6) % 7 // 0 = Monday
|
||||
const thursday = new Date(day)
|
||||
thursday.setDate(day.getDate() + 3 - dayOfWeek)
|
||||
|
||||
// Week 1 is the week containing 4 January; find its Monday.
|
||||
const jan4 = new Date(thursday.getFullYear(), 0, 4)
|
||||
const jan4DayOfWeek = (jan4.getDay() + 6) % 7
|
||||
const week1Monday = new Date(jan4)
|
||||
week1Monday.setDate(jan4.getDate() - jan4DayOfWeek)
|
||||
|
||||
const days = Math.round((thursday - week1Monday) / 86400000)
|
||||
return Math.floor(days / 7) + 1
|
||||
}
|
||||
|
||||
/** The year the week belongs to, which is not always the calendar year. */
|
||||
export function fiscalWeekYear(date = new Date()) {
|
||||
const day = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const dayOfWeek = (day.getDay() + 6) % 7
|
||||
const thursday = new Date(day)
|
||||
thursday.setDate(day.getDate() + 3 - dayOfWeek)
|
||||
return thursday.getFullYear()
|
||||
}
|
||||
49
frontend/src/utils/fiscalWeek.spec.js
Normal file
49
frontend/src/utils/fiscalWeek.spec.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { fiscalWeek, fiscalWeekYear } from './fiscalWeek'
|
||||
|
||||
/**
|
||||
* The number has to match what the classic ASP site shows, because people will
|
||||
* have both open. The awkward cases are all at the year boundary, which is
|
||||
* exactly where a naive "day of year / 7" gets it wrong.
|
||||
*/
|
||||
describe('fiscalWeek', () => {
|
||||
it('counts the week containing 4 January as week 1', () => {
|
||||
expect(fiscalWeek(new Date(2026, 0, 4))).toBe(1)
|
||||
})
|
||||
|
||||
it('puts a late-December date in week 1 of the NEXT year', () => {
|
||||
// 2025-12-29 is a Monday; its Thursday falls in 2026, so it is 2026 week 1.
|
||||
expect(fiscalWeek(new Date(2025, 11, 29))).toBe(1)
|
||||
expect(fiscalWeekYear(new Date(2025, 11, 29))).toBe(2026)
|
||||
})
|
||||
|
||||
it('puts an early-January date in the LAST week of the previous year', () => {
|
||||
// 2027-01-01 is a Friday; its Thursday is 2026-12-31, so it is 2026 week 53.
|
||||
expect(fiscalWeek(new Date(2027, 0, 1))).toBe(53)
|
||||
expect(fiscalWeekYear(new Date(2027, 0, 1))).toBe(2026)
|
||||
})
|
||||
|
||||
it('holds the same number all week, Monday through Sunday', () => {
|
||||
const monday = new Date(2026, 7, 10)
|
||||
const week = fiscalWeek(monday)
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(2026, 7, 10 + i)
|
||||
expect(fiscalWeek(day)).toBe(week)
|
||||
}
|
||||
})
|
||||
|
||||
it('increments the following Monday', () => {
|
||||
expect(fiscalWeek(new Date(2026, 7, 17))).toBe(fiscalWeek(new Date(2026, 7, 10)) + 1)
|
||||
})
|
||||
|
||||
it('gives a 53-week year its 53rd week', () => {
|
||||
// 2026 starts on a Thursday, so it runs to 53 weeks.
|
||||
expect(fiscalWeek(new Date(2026, 11, 31))).toBe(53)
|
||||
})
|
||||
|
||||
it('is not fooled by a time of day', () => {
|
||||
const morning = new Date(2026, 7, 12, 6, 0, 0)
|
||||
const nearMidnight = new Date(2026, 7, 12, 23, 59, 0)
|
||||
expect(fiscalWeek(morning)).toBe(fiscalWeek(nearMidnight))
|
||||
})
|
||||
})
|
||||
@@ -104,7 +104,7 @@ import ToastHost from '../components/ToastHost.vue'
|
||||
import {
|
||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
||||
Box, KeyRound, LogOut
|
||||
Box, KeyRound, LogOut, Wrench
|
||||
} from 'lucide-vue-next'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||
@@ -166,6 +166,7 @@ const iconMap = {
|
||||
'shield': ShieldCheck,
|
||||
'ruler': Ruler,
|
||||
'box': Box,
|
||||
'wrench': Wrench,
|
||||
}
|
||||
|
||||
// Default navigation (used as fallback if API fails)
|
||||
|
||||
@@ -25,3 +25,8 @@ export default [
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
|
||||
// No full-screen routes: every view in this plugin renders inside
|
||||
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||
// without Rollup warning about a missing export.
|
||||
export const toplevel = []
|
||||
|
||||
@@ -1,441 +1,452 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Application Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/applications/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/applications" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="app">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="app.image">
|
||||
<img :src="`/images/applications/${app.image}`" :alt="app.appname" @error="handleImageError" />
|
||||
</div>
|
||||
<div class="hero-image placeholder" v-else>
|
||||
<span class="placeholder-icon">📦</span>
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ app.appname }}</h1>
|
||||
</div>
|
||||
<p class="hero-description" v-if="app.appdescription">{{ app.appdescription }}</p>
|
||||
<div class="hero-meta">
|
||||
<span v-if="app.isinstallable" class="badge badge-lg badge-info">Installable</span>
|
||||
<span v-if="app.islicenced" class="badge badge-lg badge-warning">Licensed</span>
|
||||
<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>
|
||||
<a v-if="app.installpath" :href="fileHref(app.installpath)" target="_blank" class="hero-link">
|
||||
<span class="link-icon">⬇</span> Download Files
|
||||
</a>
|
||||
<a v-if="app.documentationpath" :href="fileHref(app.documentationpath)" target="_blank" class="hero-link">
|
||||
<span class="link-icon">📄</span> Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Support -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Support</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Support Team</span>
|
||||
<span class="info-value">
|
||||
<a v-if="app.teamurl" :href="app.teamurl" target="_blank">
|
||||
{{ app.supportteamname || '-' }}
|
||||
</a>
|
||||
<span v-else>{{ app.supportteamname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="app.contacts && app.contacts.length">
|
||||
<span class="info-label">Contacts</span>
|
||||
<span class="info-value">
|
||||
<span v-for="(contact, index) in app.contacts" :key="index" class="contact-line">
|
||||
{{ contact.name }}<span v-if="contact.sso" class="mono"> ({{ contact.sso }})</span>
|
||||
<template v-if="contact.sso && contactEmailDomain">
|
||||
<a class="contact-action" :href="`mailto:${contactEmail(contact)}`" title="Email">Email</a>
|
||||
<a class="contact-action" :href="`https://teams.microsoft.com/l/chat/0/0?users=${contactEmail(contact)}`" target="_blank" rel="noopener" title="Teams chat">Teams</a>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Notes -->
|
||||
<div class="section-card" v-if="app.applicationnotes">
|
||||
<h3 class="section-title">Application Notes</h3>
|
||||
<!-- Notes are authored as HTML (form says "HTML supported");
|
||||
render sanitized so scripts/handlers cannot slip in. -->
|
||||
<div class="notes-text" v-html="sanitizedNotes"></div>
|
||||
</div>
|
||||
|
||||
<!-- Versions -->
|
||||
<div class="section-card" v-if="versions.length > 0">
|
||||
<h3 class="section-title">Available Versions</h3>
|
||||
<div class="version-list">
|
||||
<div v-for="ver in versions" :key="ver.appversionid" class="version-item">
|
||||
<span class="version-number">v{{ ver.version }}</span>
|
||||
<span class="version-date" v-if="ver.releasedate">{{ formatDate(ver.releasedate) }}</span>
|
||||
<span class="version-notes" v-if="ver.notes">{{ ver.notes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Knowledge Base -->
|
||||
<div class="section-card" v-if="app.knowledgebase && app.knowledgebase.length">
|
||||
<h3 class="section-title">Knowledge Base ({{ app.knowledgebase.length }})</h3>
|
||||
<div class="kb-list">
|
||||
<a
|
||||
v-for="kb in app.knowledgebase"
|
||||
:key="kb.linkid"
|
||||
:href="kb.linkurl"
|
||||
target="_blank"
|
||||
class="kb-item"
|
||||
>
|
||||
<span class="kb-title">{{ kb.shortdescription }}</span>
|
||||
<span class="kb-keywords" v-if="kb.keywords">
|
||||
<span
|
||||
v-for="(kw, i) in kbKeywords(kb.keywords)"
|
||||
:key="i"
|
||||
class="kb-tag"
|
||||
>{{ kw }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Installed On PCs -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Installed On ({{ installedOn.length }} PCs)</h3>
|
||||
<div v-if="installedOn.length > 0" class="pc-list">
|
||||
<router-link
|
||||
v-for="install in installedOn"
|
||||
:key="install.id"
|
||||
:to="`/pcs/${install.computerid}`"
|
||||
class="pc-item"
|
||||
>
|
||||
<div class="pc-info">
|
||||
<span class="pc-name">{{ install.computer?.hostname || install.computer?.assetnumber || `PC #${install.computerid}` }}</span>
|
||||
<span class="pc-alias" v-if="install.computer?.assetnumber">{{ install.computer.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="pc-version" v-if="install.version">
|
||||
v{{ install.version }}
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>Not installed on any PCs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(app.createddate) }}</span>
|
||||
<span>Modified {{ formatDate(app.modifieddate) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Application not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { applicationsApi } from '@/api'
|
||||
import { getContactEmailDomain } from '@/utils/siteSettings'
|
||||
import { sanitizeNotesHtml } from '@/utils/sanitizeHtml'
|
||||
import { fileHref } from '@/utils/basePath'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const app = ref(null)
|
||||
const versions = ref([])
|
||||
const installedOn = ref([])
|
||||
const contactEmailDomain = ref('')
|
||||
|
||||
// Application notes are HTML; sanitize before binding with v-html.
|
||||
const sanitizedNotes = computed(() => sanitizeNotesHtml(app.value?.applicationnotes))
|
||||
|
||||
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
|
||||
function contactEmail(contact) {
|
||||
return `${contact.sso}@${contactEmailDomain.value}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
contactEmailDomain.value = await getContactEmailDomain()
|
||||
// Load application details
|
||||
const response = await applicationsApi.get(route.params.id)
|
||||
app.value = response.data.data
|
||||
|
||||
// Load versions
|
||||
try {
|
||||
const versionsRes = await applicationsApi.getVersions(route.params.id)
|
||||
versions.value = versionsRes.data.data || []
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
// Load installed on which PCs
|
||||
try {
|
||||
const installedRes = await applicationsApi.getInstalledOn(route.params.id)
|
||||
installedOn.value = installedRes.data.data || []
|
||||
} catch (e) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading application:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
function handleImageError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
|
||||
// Keywords are stored space-separated; split into chips for the KB card.
|
||||
function kbKeywords(keywords) {
|
||||
return (keywords || '').split(/\s+/).filter(Boolean)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Application-specific styles - shared styles are in global style.css */
|
||||
|
||||
/* Knowledge Base card: each entry is a bordered block, not a run-on line.
|
||||
shortdescription (up to 500 chars) clamps to 2 lines; keywords render as
|
||||
small chips below instead of a second wall of text. */
|
||||
.kb-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.kb-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.kb-item:hover {
|
||||
border-color: var(--primary);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.kb-title {
|
||||
color: var(--link);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.kb-keywords {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.kb-tag {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-light);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hero description (app-specific) */
|
||||
.hero-description {
|
||||
color: var(--text-light);
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Hero links (app-specific) */
|
||||
.hero-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.hero-link:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Placeholder image */
|
||||
.hero-image.placeholder {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Version List */
|
||||
.version-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.version-number {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.version-date {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.version-notes {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* PC List */
|
||||
.pc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pc-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.pc-item:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.pc-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pc-name {
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pc-alias {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.pc-version {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 1rem;
|
||||
color: var(--primary);
|
||||
background: var(--bg-card);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2.5rem;
|
||||
color: var(--text-light);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Support contacts stack one per line */
|
||||
.contact-line {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
|
||||
.notes-text {
|
||||
/* Rendered HTML (v-html): block tags handle spacing, so no pre-wrap. */
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.notes-text :first-child { margin-top: 0; }
|
||||
.notes-text :last-child { margin-bottom: 0; }
|
||||
.notes-text p { margin: 0 0 0.6rem; }
|
||||
.notes-text ul,
|
||||
.notes-text ol { margin: 0 0 0.6rem 1.4rem; }
|
||||
.notes-text a { color: var(--link); }
|
||||
.notes-text code,
|
||||
.notes-text pre { background: var(--bg); border-radius: 4px; padding: 0.1rem 0.3rem; }
|
||||
</style>
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Application Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/applications/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/applications" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="app">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="app.image">
|
||||
<img :src="imageSrc" :alt="app.appname" @error="handleImageError" />
|
||||
</div>
|
||||
<div class="hero-image placeholder" v-else>
|
||||
<span class="placeholder-icon">📦</span>
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ app.appname }}</h1>
|
||||
</div>
|
||||
<p class="hero-description" v-if="app.appdescription">{{ app.appdescription }}</p>
|
||||
<div class="hero-meta">
|
||||
<span v-if="app.isinstallable" class="badge badge-lg badge-info">Installable</span>
|
||||
<span v-if="app.islicenced" class="badge badge-lg badge-warning">Licensed</span>
|
||||
<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>
|
||||
<a v-if="app.installpath" :href="fileHref(app.installpath)" target="_blank" class="hero-link">
|
||||
<span class="link-icon">⬇</span> Download Files
|
||||
</a>
|
||||
<a v-if="app.documentationpath" :href="fileHref(app.documentationpath)" target="_blank" class="hero-link">
|
||||
<span class="link-icon">📄</span> Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Support -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Support</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Support Team</span>
|
||||
<span class="info-value">
|
||||
<a v-if="app.teamurl" :href="app.teamurl" target="_blank">
|
||||
{{ app.supportteamname || '-' }}
|
||||
</a>
|
||||
<span v-else>{{ app.supportteamname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="app.contacts && app.contacts.length">
|
||||
<span class="info-label">Contacts</span>
|
||||
<span class="info-value">
|
||||
<span v-for="(contact, index) in app.contacts" :key="index" class="contact-line">
|
||||
{{ contact.name }}<span v-if="contact.sso" class="mono"> ({{ contact.sso }})</span>
|
||||
<template v-if="contact.sso && contactEmailDomain">
|
||||
<a class="contact-action" :href="`mailto:${contactEmail(contact)}`" title="Email">Email</a>
|
||||
<a class="contact-action" :href="`https://teams.microsoft.com/l/chat/0/0?users=${contactEmail(contact)}`" target="_blank" rel="noopener" title="Teams chat">Teams</a>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Notes -->
|
||||
<div class="section-card" v-if="app.applicationnotes">
|
||||
<h3 class="section-title">Application Notes</h3>
|
||||
<!-- Notes are authored as HTML (form says "HTML supported");
|
||||
render sanitized so scripts/handlers cannot slip in. -->
|
||||
<div class="notes-text" v-html="sanitizedNotes"></div>
|
||||
</div>
|
||||
|
||||
<!-- Versions -->
|
||||
<div class="section-card" v-if="versions.length > 0">
|
||||
<h3 class="section-title">Available Versions</h3>
|
||||
<div class="version-list">
|
||||
<div v-for="ver in versions" :key="ver.appversionid" class="version-item">
|
||||
<span class="version-number">v{{ ver.version }}</span>
|
||||
<span class="version-date" v-if="ver.releasedate">{{ formatDate(ver.releasedate) }}</span>
|
||||
<span class="version-notes" v-if="ver.notes">{{ ver.notes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Knowledge Base -->
|
||||
<div class="section-card" v-if="app.knowledgebase && app.knowledgebase.length">
|
||||
<h3 class="section-title">Knowledge Base ({{ app.knowledgebase.length }})</h3>
|
||||
<div class="kb-list">
|
||||
<a
|
||||
v-for="kb in app.knowledgebase"
|
||||
:key="kb.linkid"
|
||||
:href="kb.linkurl"
|
||||
target="_blank"
|
||||
class="kb-item"
|
||||
>
|
||||
<span class="kb-title">{{ kb.shortdescription }}</span>
|
||||
<span class="kb-keywords" v-if="kb.keywords">
|
||||
<span
|
||||
v-for="(kw, i) in kbKeywords(kb.keywords)"
|
||||
:key="i"
|
||||
class="kb-tag"
|
||||
>{{ kw }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Installed On PCs -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Installed On ({{ installedOn.length }} PCs)</h3>
|
||||
<div v-if="installedOn.length > 0" class="pc-list">
|
||||
<router-link
|
||||
v-for="install in installedOn"
|
||||
:key="install.id"
|
||||
:to="`/pcs/${install.computerid}`"
|
||||
class="pc-item"
|
||||
>
|
||||
<div class="pc-info">
|
||||
<span class="pc-name">{{ install.computer?.hostname || install.computer?.assetnumber || `PC #${install.computerid}` }}</span>
|
||||
<span class="pc-alias" v-if="install.computer?.assetnumber">{{ install.computer.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="pc-version" v-if="install.version">
|
||||
v{{ install.version }}
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>Not installed on any PCs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(app.createddate) }}</span>
|
||||
<span>Modified {{ formatDate(app.modifieddate) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Application not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { applicationsApi } from '@/api'
|
||||
import { getContactEmailDomain } from '@/utils/siteSettings'
|
||||
import { sanitizeNotesHtml } from '@/utils/sanitizeHtml'
|
||||
import { fileHref } from '@/utils/basePath'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const app = ref(null)
|
||||
|
||||
// An uploaded image is stored as a served URL; entries from the classic site
|
||||
// are a bare filename that still lives under the frontend's own directory.
|
||||
// Both have to render, so the shape of the value decides the source.
|
||||
const imageSrc = computed(() => {
|
||||
const value = app.value?.image
|
||||
if (!value) return ''
|
||||
return value.startsWith('/api/') || value.startsWith('http')
|
||||
? value
|
||||
: `/images/applications/${value}`
|
||||
})
|
||||
const versions = ref([])
|
||||
const installedOn = ref([])
|
||||
const contactEmailDomain = ref('')
|
||||
|
||||
// Application notes are HTML; sanitize before binding with v-html.
|
||||
const sanitizedNotes = computed(() => sanitizeNotesHtml(app.value?.applicationnotes))
|
||||
|
||||
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
|
||||
function contactEmail(contact) {
|
||||
return `${contact.sso}@${contactEmailDomain.value}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
contactEmailDomain.value = await getContactEmailDomain()
|
||||
// Load application details
|
||||
const response = await applicationsApi.get(route.params.id)
|
||||
app.value = response.data.data
|
||||
|
||||
// Load versions
|
||||
try {
|
||||
const versionsRes = await applicationsApi.getVersions(route.params.id)
|
||||
versions.value = versionsRes.data.data || []
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
// Load installed on which PCs
|
||||
try {
|
||||
const installedRes = await applicationsApi.getInstalledOn(route.params.id)
|
||||
installedOn.value = installedRes.data.data || []
|
||||
} catch (e) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading application:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
function handleImageError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
|
||||
// Keywords are stored space-separated; split into chips for the KB card.
|
||||
function kbKeywords(keywords) {
|
||||
return (keywords || '').split(/\s+/).filter(Boolean)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Application-specific styles - shared styles are in global style.css */
|
||||
|
||||
/* Knowledge Base card: each entry is a bordered block, not a run-on line.
|
||||
shortdescription (up to 500 chars) clamps to 2 lines; keywords render as
|
||||
small chips below instead of a second wall of text. */
|
||||
.kb-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.kb-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.kb-item:hover {
|
||||
border-color: var(--primary);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
.kb-title {
|
||||
color: var(--link);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.kb-keywords {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.kb-tag {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-light);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hero description (app-specific) */
|
||||
.hero-description {
|
||||
color: var(--text-light);
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Hero links (app-specific) */
|
||||
.hero-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.hero-link:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Placeholder image */
|
||||
.hero-image.placeholder {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Version List */
|
||||
.version-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.version-number {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.version-date {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.version-notes {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* PC List */
|
||||
.pc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pc-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.pc-item:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.pc-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pc-name {
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pc-alias {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.pc-version {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 1rem;
|
||||
color: var(--primary);
|
||||
background: var(--bg-card);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2.5rem;
|
||||
color: var(--text-light);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Support contacts stack one per line */
|
||||
.contact-line {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
|
||||
.notes-text {
|
||||
/* Rendered HTML (v-html): block tags handle spacing, so no pre-wrap. */
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.notes-text :first-child { margin-top: 0; }
|
||||
.notes-text :last-child { margin-bottom: 0; }
|
||||
.notes-text p { margin: 0 0 0.6rem; }
|
||||
.notes-text ul,
|
||||
.notes-text ol { margin: 0 0 0.6rem 1.4rem; }
|
||||
.notes-text a { color: var(--link); }
|
||||
.notes-text code,
|
||||
.notes-text pre { background: var(--bg); border-radius: 4px; padding: 0.1rem 0.3rem; }
|
||||
</style>
|
||||
|
||||
@@ -111,18 +111,59 @@
|
||||
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="image">Image Filename</label>
|
||||
<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
|
||||
Install Path instead. Downloads require a login.
|
||||
</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="e.g., myapp.png"
|
||||
placeholder="filled in by the upload above"
|
||||
/>
|
||||
<small class="form-hint">Image should be placed in /images/applications/</small>
|
||||
<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>
|
||||
@@ -184,6 +225,76 @@ const form = ref({
|
||||
|
||||
const supportTeams = ref([])
|
||||
|
||||
// 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 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 }
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -210,6 +321,10 @@ onMounted(async () => {
|
||||
image: app.image || '',
|
||||
applicationnotes: app.applicationnotes || ''
|
||||
}
|
||||
imagePreview.value = imageSrcFor(app.image)
|
||||
if ((app.installpath || '').startsWith('/api/applications/package/')) {
|
||||
uploadedPackage.value = app.installpath.split('/').pop()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
@@ -240,16 +355,34 @@ async function saveApplication() {
|
||||
applicationnotes: form.value.applicationnotes || null
|
||||
}
|
||||
|
||||
let appid = route.params.id
|
||||
if (isEdit.value) {
|
||||
await applicationsApi.update(route.params.id, appData)
|
||||
await applicationsApi.update(appid, appData)
|
||||
} else {
|
||||
await applicationsApi.create(appData)
|
||||
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
|
||||
await applicationsApi.uploadPackage(appid, pendingPackage.value,
|
||||
percent => { packageProgress.value = percent })
|
||||
pendingPackage.value = null
|
||||
packageProgress.value = null
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -257,6 +390,46 @@ async function saveApplication() {
|
||||
</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;
|
||||
|
||||
@@ -19,3 +19,8 @@ export default [
|
||||
meta: { requiresAuth: true, plugin: 'backups' }
|
||||
}
|
||||
]
|
||||
|
||||
// No full-screen routes: every view in this plugin renders inside
|
||||
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||
// without Rollup warning about a missing export.
|
||||
export const toplevel = []
|
||||
|
||||
@@ -58,3 +58,8 @@ export default [
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
}
|
||||
]
|
||||
|
||||
// No full-screen routes: every view in this plugin renders inside
|
||||
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||
// without Rollup warning about a missing export.
|
||||
export const toplevel = []
|
||||
|
||||
@@ -16,3 +16,8 @@ export default [
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'employees' }
|
||||
}
|
||||
]
|
||||
|
||||
// No full-screen routes: every view in this plugin renders inside
|
||||
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||
// without Rollup warning about a missing export.
|
||||
export const toplevel = []
|
||||
|
||||
@@ -36,3 +36,8 @@ export default [
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
|
||||
}
|
||||
]
|
||||
|
||||
// No full-screen routes: every view in this plugin renders inside
|
||||
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||
// without Rollup warning about a missing export.
|
||||
export const toplevel = []
|
||||
|
||||
@@ -27,3 +27,8 @@ export default [
|
||||
meta: { requiresAuth: true, plugin: 'knowledgebase' }
|
||||
}
|
||||
]
|
||||
|
||||
// No full-screen routes: every view in this plugin renders inside
|
||||
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||
// without Rollup warning about a missing export.
|
||||
export const toplevel = []
|
||||
|
||||
@@ -33,3 +33,8 @@ export default [
|
||||
meta: { requiresAuth: true, plugin: 'machines' }
|
||||
}
|
||||
]
|
||||
|
||||
// No full-screen routes: every view in this plugin renders inside
|
||||
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||
// without Rollup warning about a missing export.
|
||||
export const toplevel = []
|
||||
|
||||
@@ -52,3 +52,8 @@ export default [
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'measuringtools' }
|
||||
}
|
||||
]
|
||||
|
||||
// No full-screen routes: every view in this plugin renders inside
|
||||
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||
// without Rollup warning about a missing export.
|
||||
export const toplevel = []
|
||||
|
||||
@@ -57,3 +57,8 @@ export default [
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
|
||||
}
|
||||
]
|
||||
|
||||
// No full-screen routes: every view in this plugin renders inside
|
||||
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||
// without Rollup warning about a missing export.
|
||||
export const toplevel = []
|
||||
|
||||
@@ -39,3 +39,8 @@ export default [
|
||||
meta: { plugin: 'notifications' }
|
||||
}
|
||||
]
|
||||
|
||||
// No full-screen routes: every view in this plugin renders inside
|
||||
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||
// without Rollup warning about a missing export.
|
||||
export const toplevel = []
|
||||
|
||||
@@ -1482,3 +1482,102 @@ def dashboard_supplies():
|
||||
|
||||
rows.sort(key=lambda r: (not r['iscritical'], r['printername']))
|
||||
return success_response(rows)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Supply forecast
|
||||
#
|
||||
# The toner report answers "what is empty now". This answers "what will be, and
|
||||
# what have we been getting through" - a purchasing question, on a different
|
||||
# cadence, off data Zabbix has been keeping all along.
|
||||
# =============================================================================
|
||||
|
||||
@printers_asset_bp.route('/supplies/forecast', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def supplies_forecast():
|
||||
"""Days-to-empty and replacement counts per printer.
|
||||
|
||||
?days=90 how far back to read (Zabbix retention is the real ceiling)
|
||||
|
||||
Printers sort by their soonest supply. Anything without an honest estimate
|
||||
is returned separately with the reason, rather than sorted as though it
|
||||
were fine or dropped as though it did not exist.
|
||||
"""
|
||||
from ..services.supply_history import analyse, soonest
|
||||
|
||||
try:
|
||||
days = max(1, min(365, int(request.args.get('days', 90))))
|
||||
except (TypeError, ValueError):
|
||||
days = 90
|
||||
|
||||
service = ZabbixService()
|
||||
if not service.isconfigured or not service.isreachable:
|
||||
return success_response({
|
||||
'printers': [], 'unestimated': [], 'days': days,
|
||||
'available': False,
|
||||
'reason': 'Zabbix is not configured or not reachable',
|
||||
})
|
||||
|
||||
rows = (
|
||||
db.session.query(Printer, Asset, Communication, Vendor)
|
||||
.join(Asset, Asset.assetid == Printer.assetid)
|
||||
.join(Communication, Communication.assetid == Asset.assetid)
|
||||
.outerjoin(Vendor, Vendor.vendorid == Printer.vendorid)
|
||||
.filter(Asset.isactive == True,
|
||||
Communication.ipaddress.isnot(None),
|
||||
Communication.ipaddress != '')
|
||||
.all()
|
||||
)
|
||||
|
||||
seen = set()
|
||||
estimated, unestimated = [], []
|
||||
|
||||
for printer, asset, comm, vendor in rows:
|
||||
if printer.printerid in seen:
|
||||
continue
|
||||
seen.add(printer.printerid)
|
||||
|
||||
supplies = service.getsuppliesbyip_cached(comm.ipaddress)
|
||||
if not supplies:
|
||||
continue
|
||||
itemids = [s['itemid'] for s in supplies if s.get('itemid')]
|
||||
history = service.gethistory(itemids, days=days)
|
||||
|
||||
analysed = []
|
||||
for supply in supplies:
|
||||
points = history.get(str(supply.get('itemid')), [])
|
||||
detail = analyse(points)
|
||||
detail['name'] = supply.get('name')
|
||||
detail['color'] = supply.get('color')
|
||||
# Trust the live read for the level; history can lag a poll behind.
|
||||
detail['currentlevel'] = supply.get('level', detail['currentlevel'])
|
||||
analysed.append(detail)
|
||||
|
||||
entry = {
|
||||
'printerid': printer.printerid,
|
||||
'printername': asset.name or printer.hostname or '',
|
||||
'assetnumber': asset.assetnumber or '',
|
||||
'ipaddress': comm.ipaddress,
|
||||
'vendor': vendor.vendor if vendor else None,
|
||||
'supplies': analysed,
|
||||
'daysleft': soonest(analysed),
|
||||
'replacements': sum(s['replacements'] for s in analysed),
|
||||
}
|
||||
(estimated if entry['daysleft'] is not None else unestimated).append(entry)
|
||||
|
||||
# Soonest first: the point of the report is what to order next.
|
||||
estimated.sort(key=lambda p: p['daysleft'])
|
||||
unestimated.sort(key=lambda p: p['printername'])
|
||||
|
||||
return success_response({
|
||||
'printers': estimated,
|
||||
'unestimated': unestimated,
|
||||
'days': days,
|
||||
'available': True,
|
||||
'summary': {
|
||||
'estimated': len(estimated),
|
||||
'unestimated': len(unestimated),
|
||||
'replacements': sum(p['replacements'] for p in estimated + unestimated),
|
||||
'duewithin30': sum(1 for p in estimated if p['daysleft'] <= 30),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -8,6 +8,12 @@ export default [
|
||||
component: () => import('./views/TonerReport.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'reports/toner-forecast',
|
||||
name: 'toner-forecast',
|
||||
component: () => import('./views/TonerForecast.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/printertypes',
|
||||
name: 'printer-types',
|
||||
|
||||
182
plugins/printers/frontend/views/TonerForecast.vue
Normal file
182
plugins/printers/frontend/views/TonerForecast.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Toner Forecast</h1>
|
||||
<div class="actions">
|
||||
<select v-model.number="days" class="form-control" @change="load">
|
||||
<option :value="30">Last 30 days</option>
|
||||
<option :value="90">Last 90 days</option>
|
||||
<option :value="180">Last 180 days</option>
|
||||
</select>
|
||||
<router-link to="/reports/toner" class="btn btn-secondary">Toner Report</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Reading history...</div>
|
||||
|
||||
<!-- Zabbix off is not the same as nothing running out. Say which. -->
|
||||
<div v-else-if="!available" class="card empty-state">
|
||||
<p>{{ reason || 'Supply history is unavailable.' }}</p>
|
||||
<p class="muted">
|
||||
Levels and history both come from Zabbix. With it unreachable there is
|
||||
nothing to forecast from - this is not a report of "nothing is due".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="summary-row">
|
||||
<div class="summary-card">
|
||||
<span class="summary-number">{{ summary.duewithin30 }}</span>
|
||||
<span class="summary-label">due within 30 days</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-number">{{ summary.estimated }}</span>
|
||||
<span class="summary-label">printers with an estimate</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-number">{{ summary.replacements }}</span>
|
||||
<span class="summary-label">cartridges changed in {{ days }} days</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Printer</th>
|
||||
<th>Runs out in</th>
|
||||
<th>Supply</th>
|
||||
<th>Level</th>
|
||||
<th>Burn rate</th>
|
||||
<th>Changed</th>
|
||||
<th>Based on</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Sorted by days left, not by level: a cartridge at 60% falling
|
||||
fast is ordered before one sitting at 8% that never moves. -->
|
||||
<template v-for="p in printers" :key="p.printerid">
|
||||
<tr v-for="(s, index) in p.supplies" :key="p.printerid + s.name"
|
||||
:class="{ 'row-group-start': index === 0 }">
|
||||
<td v-if="index === 0" :rowspan="p.supplies.length">
|
||||
<router-link :to="`/printers/${p.printerid}`">
|
||||
{{ p.printername || p.assetnumber }}
|
||||
</router-link>
|
||||
<div class="muted small">{{ p.ipaddress }}</div>
|
||||
</td>
|
||||
<td v-if="index === 0" :rowspan="p.supplies.length">
|
||||
<span class="days" :class="urgency(p.daysleft)">
|
||||
{{ p.daysleft }} days
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ s.name }}</td>
|
||||
<td>{{ s.currentlevel != null ? Math.round(s.currentlevel) + '%' : '-' }}</td>
|
||||
<td>{{ s.burnrateperday != null ? s.burnrateperday + '%/day' : '-' }}</td>
|
||||
<td>{{ s.replacements || 0 }}</td>
|
||||
<td class="muted small">
|
||||
{{ s.reason ? s.reason : s.basisdays + ' days of readings' }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="!printers.length">
|
||||
<td colspan="7" class="muted" style="text-align:center;">
|
||||
Nothing can be estimated yet from {{ days }} days of history.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kept separate rather than sorted in as 0 or as 999: a printer with no
|
||||
estimate is neither urgent nor safe, and the reason is the useful part. -->
|
||||
<div v-if="unestimated.length" class="card">
|
||||
<h3 class="section-title">No estimate yet ({{ unestimated.length }})</h3>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Printer</th><th>Supply</th><th>Level</th><th>Why</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="p in unestimated" :key="p.printerid">
|
||||
<tr v-for="s in p.supplies" :key="p.printerid + s.name">
|
||||
<td>
|
||||
<router-link :to="`/printers/${p.printerid}`">
|
||||
{{ p.printername || p.assetnumber }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ s.name }}</td>
|
||||
<td>{{ s.currentlevel != null ? Math.round(s.currentlevel) + '%' : '-' }}</td>
|
||||
<td class="muted">{{ s.reason || '-' }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
const loading = ref(true)
|
||||
const available = ref(false)
|
||||
const reason = ref('')
|
||||
const days = ref(90)
|
||||
const printers = ref([])
|
||||
const unestimated = ref([])
|
||||
const summary = ref({ duewithin30: 0, estimated: 0, replacements: 0 })
|
||||
|
||||
function urgency(daysleft) {
|
||||
if (daysleft <= 7) return 'critical'
|
||||
if (daysleft <= 30) return 'low'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await printersApi.supplyForecast(days.value)
|
||||
const data = response.data.data
|
||||
available.value = data.available
|
||||
reason.value = data.reason || ''
|
||||
printers.value = data.printers || []
|
||||
unestimated.value = data.unestimated || []
|
||||
summary.value = data.summary || summary.value
|
||||
} catch (err) {
|
||||
available.value = false
|
||||
reason.value = apiError(err, 'Failed to read supply history')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.actions .form-control { width: auto; }
|
||||
.summary-row { display: flex; gap: 1rem; margin-bottom: 1.25rem; flex-wrap: wrap; }
|
||||
.summary-card {
|
||||
background: var(--bg-card-solid);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
min-width: 12rem;
|
||||
}
|
||||
.summary-number { display: block; font-size: 1.8rem; font-weight: 700; }
|
||||
.summary-label { color: var(--text-light); font-size: 0.85rem; }
|
||||
.days { font-weight: 700; }
|
||||
.days.critical { color: var(--danger); }
|
||||
.days.low { color: var(--warning); }
|
||||
.small { font-size: 0.8rem; }
|
||||
.row-group-start td { border-top: 2px solid var(--border); }
|
||||
.empty-state { padding: 2rem; text-align: center; }
|
||||
.section-title { padding: 0.75rem 1rem 0; }
|
||||
</style>
|
||||
@@ -53,22 +53,42 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Printer Name</th>
|
||||
<th>Asset #</th>
|
||||
<th>Location</th>
|
||||
<th>IP Address</th>
|
||||
<th>Hostname</th>
|
||||
<th>Supplies</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
|
||||
<td>
|
||||
<router-link :to="`/printers/${printer.printerid}`">
|
||||
<!-- Coordinates give the name the same floor-plan preview the
|
||||
printer's own page uses; it replaces the Location column. -->
|
||||
<LocationMapTooltip
|
||||
v-if="printer.mapx != null && printer.mapy != null"
|
||||
:left="printer.mapx"
|
||||
:top="printer.mapy"
|
||||
:machineName="printer.printername || printer.assetnumber"
|
||||
>
|
||||
<router-link :to="`/printers/${printer.printerid}`">
|
||||
{{ printer.printername || 'Unknown' }}
|
||||
</router-link>
|
||||
</LocationMapTooltip>
|
||||
<router-link v-else :to="`/printers/${printer.printerid}`">
|
||||
{{ printer.printername || 'Unknown' }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ printer.assetnumber }}</td>
|
||||
<td>{{ printer.location || '-' }}</td>
|
||||
<td>{{ printer.ipaddress }}</td>
|
||||
<td>
|
||||
<!-- Opens the printer's own web page. New tab: the report is a
|
||||
worklist, and losing your place in it to visit one printer
|
||||
means finding your row again. -->
|
||||
<a
|
||||
v-if="fqdnFor(printer)"
|
||||
:href="`http://${fqdnFor(printer)}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:title="printer.ipaddress"
|
||||
>{{ fqdnFor(printer) }}</a>
|
||||
<span v-else>{{ printer.ipaddress || '-' }}</span>
|
||||
</td>
|
||||
<td class="supplies-cell">
|
||||
<div
|
||||
v-for="(supply, idx) in printer.supplies"
|
||||
@@ -86,6 +106,16 @@
|
||||
<span class="supply-level" :class="'supply-text-' + supply.status">
|
||||
{{ supply.level }}%
|
||||
</span>
|
||||
<span class="supply-parts">
|
||||
<span
|
||||
v-for="part in supply.partnumbers"
|
||||
:key="part.partnumber"
|
||||
class="part-chip"
|
||||
:title="partTooltip(part)"
|
||||
>{{ part.partnumber }}</span>
|
||||
<span v-if="!supply.partnumbers || !supply.partnumbers.length"
|
||||
class="part-none" title="No part number on file for this model and color">-</span>
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -102,13 +132,14 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import EmailReportButton from '@/components/EmailReportButton.vue'
|
||||
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
|
||||
|
||||
const emailColumns = [
|
||||
{ key: 'printer', label: 'Printer' },
|
||||
{ key: 'assetnumber', label: 'Asset #' },
|
||||
{ key: 'location', label: 'Location' },
|
||||
{ key: 'ipaddress', label: 'IP Address' },
|
||||
{ key: 'hostname', label: 'Hostname' },
|
||||
{ key: 'supply', label: 'Supply' },
|
||||
{ key: 'partnumber', label: 'Part Number' },
|
||||
{ key: 'level', label: 'Level' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
]
|
||||
@@ -125,6 +156,47 @@ const filterOptions = [
|
||||
{ label: 'Low', value: 'low' }
|
||||
]
|
||||
|
||||
// Printers have no stored FQDN; the site builds one from the IP via the
|
||||
// printer_hostname_template setting, the same way PrinterForm does.
|
||||
const hostnameTemplate = ref('')
|
||||
|
||||
function fqdnFor(printer) {
|
||||
if (!printer.ipaddress || !hostnameTemplate.value) return ''
|
||||
return hostnameTemplate.value.replace('{ip}', printer.ipaddress.replace(/\./g, '-'))
|
||||
}
|
||||
|
||||
// A model can list several capacity tiers for one color, so the chip shows the
|
||||
// part number and the tooltip says which one it is.
|
||||
function partTooltip(part) {
|
||||
const bits = [part.marketingname, part.capacitytier]
|
||||
if (part.pageyield) bits.push(part.pageyield + ' pages')
|
||||
return bits.filter(Boolean).join(' - ')
|
||||
}
|
||||
|
||||
// One flat row per part number so an ordering list is copy-pasteable; supplies
|
||||
// with no part on file still get a row.
|
||||
function reportRows() {
|
||||
const rows = []
|
||||
for (const printer of filteredPrinters.value) {
|
||||
for (const supply of printer.supplies || []) {
|
||||
const parts = supply.partnumbers && supply.partnumbers.length
|
||||
? supply.partnumbers.map(p => p.partnumber)
|
||||
: ['']
|
||||
for (const partnumber of parts) {
|
||||
rows.push({
|
||||
printer: printer.printername || '',
|
||||
hostname: fqdnFor(printer) || printer.ipaddress || '',
|
||||
supply: supply.name || '',
|
||||
partnumber: partnumber,
|
||||
level: supply.level,
|
||||
status: supply.status || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const filteredPrinters = computed(() => {
|
||||
if (filter.value === 'all') return printers.value
|
||||
return printers.value.filter(p =>
|
||||
@@ -132,36 +204,17 @@ const filteredPrinters = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// One row per supply, honoring the active filter, for the emailed table.
|
||||
const emailRows = computed(() => {
|
||||
const rows = []
|
||||
for (const printer of filteredPrinters.value) {
|
||||
for (const supply of printer.supplies || []) {
|
||||
rows.push({
|
||||
printer: printer.printername || '',
|
||||
assetnumber: printer.assetnumber || '',
|
||||
location: printer.location || '',
|
||||
ipaddress: printer.ipaddress || '',
|
||||
supply: supply.name || '',
|
||||
level: supply.level + '%',
|
||||
status: supply.status || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
})
|
||||
// Emailed table, honoring the active filter.
|
||||
const emailRows = computed(() =>
|
||||
reportRows().map(row => ({ ...row, level: row.level + '%' }))
|
||||
)
|
||||
|
||||
function exportCSV() {
|
||||
// one row per supply, honoring the active filter
|
||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
const rows = [['printer', 'assetnumber', 'location', 'ipaddress', 'supply', 'level', 'status']]
|
||||
for (const printer of filteredPrinters.value) {
|
||||
for (const supply of printer.supplies || []) {
|
||||
rows.push([
|
||||
printer.printername || '', printer.assetnumber || '', printer.location || '',
|
||||
printer.ipaddress || '', supply.name || '', supply.level, supply.status || ''
|
||||
])
|
||||
}
|
||||
const header = ['printer', 'hostname', 'supply', 'partnumber', 'level', 'status']
|
||||
const rows = [header]
|
||||
for (const row of reportRows()) {
|
||||
rows.push(header.map(key => row[key]))
|
||||
}
|
||||
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
|
||||
const link = document.createElement('a')
|
||||
@@ -173,6 +226,7 @@ function exportCSV() {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
hostnameTemplate.value = await getPrinterHostnameTemplate()
|
||||
const response = await printersApi.lowSupplies()
|
||||
const data = response.data.data
|
||||
printers.value = data.printers || []
|
||||
@@ -218,32 +272,31 @@ onMounted(async () => {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* One grid for the whole cell, with each supply row contributing its cells
|
||||
directly (display: contents). Cartridge names were being ellipsised inside a
|
||||
fixed 120px column, which hid the very thing being reordered; a max-content
|
||||
column sizes to the longest name instead, and the columns still line up
|
||||
across the rows of one printer. */
|
||||
.supplies-cell {
|
||||
min-width: 280px;
|
||||
min-width: 460px;
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(80px, 1fr) auto max-content;
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.supply-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.supply-row:last-child {
|
||||
margin-bottom: 0;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.supply-name {
|
||||
flex: 0 0 120px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.supply-bar-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
@@ -269,12 +322,33 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.supply-level {
|
||||
flex: 0 0 40px;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.supply-parts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.part-chip {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.part-none {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.supply-text-ok {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
@@ -358,6 +358,17 @@ class PrintersPlugin(BasePlugin):
|
||||
'category': 'printers',
|
||||
'route': '/reports/toner',
|
||||
},
|
||||
{
|
||||
# Separate from the toner report on purpose: that one is an
|
||||
# exceptions list a tech acts on today, this is an ordering
|
||||
# view read monthly, and it runs a heavier history query.
|
||||
'id': 'tonerforecast',
|
||||
'name': 'Toner Forecast',
|
||||
'description': 'Estimated days until each printer runs out, '
|
||||
'and how many cartridges it has been through',
|
||||
'category': 'printers',
|
||||
'route': '/reports/toner-forecast',
|
||||
},
|
||||
]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
|
||||
@@ -9,6 +9,7 @@ from .supply_parts import (
|
||||
alerttier,
|
||||
)
|
||||
from .supply_alerts import check_supplies
|
||||
from .supply_history import analyse, soonest
|
||||
from .seed_supplies import seedsupplies
|
||||
|
||||
__all__ = [
|
||||
@@ -19,5 +20,7 @@ __all__ = [
|
||||
'lookupsupplies',
|
||||
'alerttier',
|
||||
'check_supplies',
|
||||
'analyse',
|
||||
'soonest',
|
||||
'seedsupplies',
|
||||
]
|
||||
|
||||
162
plugins/printers/services/supply_history.py
Normal file
162
plugins/printers/services/supply_history.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Read a supply's level history and say what it means.
|
||||
|
||||
Two questions come out of the same series of readings:
|
||||
|
||||
* how fast is it draining, and when does it hit empty
|
||||
* how many times has it been replaced
|
||||
|
||||
Both hang on one observation: a cartridge only ever goes DOWN while it is in
|
||||
use, so a rise is a replacement. Everything here is built on locating those
|
||||
rises and treating each stretch between them as one cartridge's life.
|
||||
|
||||
The maths is deliberately kept away from Zabbix so it can be tested against a
|
||||
list of numbers. The service layer fetches; this decides.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# A reading can wobble by a point without anything happening - SNMP rounding,
|
||||
# a gauge settling after a power cycle. A rise has to clear this to count as a
|
||||
# new cartridge rather than noise.
|
||||
REPLACEMENT_RISE = 10
|
||||
|
||||
# Below this many readings a slope is arithmetic, not evidence. Two points
|
||||
# through a coarse gauge can "prove" any rate at all.
|
||||
MIN_POINTS_FOR_ESTIMATE = 4
|
||||
|
||||
# Many printers report in 10% steps, so a fortnight can pass on one plateau.
|
||||
# Without a minimum observed drop the slope reads as zero and the forecast
|
||||
# says "never", which is worse than saying nothing.
|
||||
MIN_DROP_FOR_ESTIMATE = 2
|
||||
|
||||
|
||||
def _asfloat(value):
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def normalise(points):
|
||||
"""[(clock, value)] -> sorted [(datetime, float)], junk dropped.
|
||||
|
||||
Zabbix returns clock as a unix string and value as a string; a history
|
||||
table can also carry the odd unparseable row.
|
||||
"""
|
||||
out = []
|
||||
for clock, value in points:
|
||||
seconds = _asfloat(clock)
|
||||
level = _asfloat(value)
|
||||
if seconds is None or level is None:
|
||||
continue
|
||||
out.append((datetime.fromtimestamp(seconds, tz=timezone.utc), level))
|
||||
out.sort(key=lambda p: p[0])
|
||||
return out
|
||||
|
||||
|
||||
def find_replacements(points, rise=REPLACEMENT_RISE):
|
||||
"""Timestamps where the level jumped up - one per cartridge change.
|
||||
|
||||
Returns [] for a series that only falls. A rise smaller than `rise` is
|
||||
treated as noise, not a replacement.
|
||||
"""
|
||||
replacements = []
|
||||
for (_, previous), (when, current) in zip(points, points[1:]):
|
||||
if current - previous >= rise:
|
||||
replacements.append(when)
|
||||
return replacements
|
||||
|
||||
|
||||
def current_run(points, rise=REPLACEMENT_RISE):
|
||||
"""The readings since the last replacement.
|
||||
|
||||
Fitting across a replacement averages a spent cartridge with a fresh one
|
||||
and produces a slope that describes neither.
|
||||
"""
|
||||
if not points:
|
||||
return []
|
||||
start = 0
|
||||
for index in range(1, len(points)):
|
||||
if points[index][1] - points[index - 1][1] >= rise:
|
||||
start = index
|
||||
return points[start:]
|
||||
|
||||
|
||||
def burn_rate(points):
|
||||
"""Percent consumed per day over these readings, or None.
|
||||
|
||||
None means "no honest estimate": too few readings, no elapsed time, or a
|
||||
drop too small to distinguish from a gauge that has not moved yet.
|
||||
"""
|
||||
if len(points) < MIN_POINTS_FOR_ESTIMATE:
|
||||
return None
|
||||
first_when, first_level = points[0]
|
||||
last_when, last_level = points[-1]
|
||||
days = (last_when - first_when).total_seconds() / 86400
|
||||
if days <= 0:
|
||||
return None
|
||||
drop = first_level - last_level
|
||||
if drop < MIN_DROP_FOR_ESTIMATE:
|
||||
return None
|
||||
return drop / days
|
||||
|
||||
|
||||
def analyse(points, rise=REPLACEMENT_RISE):
|
||||
"""Everything the report needs about one supply.
|
||||
|
||||
Returns:
|
||||
currentlevel latest reading, or None
|
||||
daysleft at the current rate, or None when there is no estimate
|
||||
burnrateperday percent per day, or None
|
||||
reason why there is no estimate - shown rather than hidden, so
|
||||
a missing forecast is explained instead of looking broken
|
||||
replacements how many times it has been changed in this window
|
||||
lastreplaced when, or None
|
||||
basisdays span of readings the estimate rests on
|
||||
points the run since the last replacement, for the chart
|
||||
"""
|
||||
points = normalise(points)
|
||||
result = {
|
||||
'currentlevel': None, 'daysleft': None, 'burnrateperday': None,
|
||||
'reason': None, 'replacements': 0, 'lastreplaced': None,
|
||||
'basisdays': 0, 'points': [],
|
||||
}
|
||||
if not points:
|
||||
result['reason'] = 'no history'
|
||||
return result
|
||||
|
||||
replacements = find_replacements(points, rise=rise)
|
||||
result['replacements'] = len(replacements)
|
||||
result['lastreplaced'] = replacements[-1].isoformat() if replacements else None
|
||||
|
||||
run = current_run(points, rise=rise)
|
||||
result['currentlevel'] = run[-1][1]
|
||||
result['points'] = [(when.isoformat(), level) for when, level in run]
|
||||
if len(run) >= 2:
|
||||
result['basisdays'] = round(
|
||||
(run[-1][0] - run[0][0]).total_seconds() / 86400, 1)
|
||||
|
||||
rate = burn_rate(run)
|
||||
if rate is None:
|
||||
# Say which of the three it is; "no estimate" alone invites a bug report.
|
||||
if len(run) < MIN_POINTS_FOR_ESTIMATE:
|
||||
result['reason'] = ('replaced recently' if replacements
|
||||
else 'not enough history yet')
|
||||
else:
|
||||
result['reason'] = 'level has not moved enough to estimate'
|
||||
return result
|
||||
|
||||
result['burnrateperday'] = round(rate, 2)
|
||||
result['daysleft'] = max(0, int(run[-1][1] / rate))
|
||||
return result
|
||||
|
||||
|
||||
def soonest(supplies):
|
||||
"""Days-left of the supply that runs out first, or None if none estimate.
|
||||
|
||||
The report sorts printers by this: a printer is as urgent as its most
|
||||
pressing cartridge, and listing it once per supply would scatter it down
|
||||
the page.
|
||||
"""
|
||||
days = [s['daysleft'] for s in supplies if s.get('daysleft') is not None]
|
||||
return min(days) if days else None
|
||||
@@ -19,6 +19,7 @@ Configuration (database Setting overrides env var):
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import requests
|
||||
@@ -219,6 +220,39 @@ class ZabbixService:
|
||||
})
|
||||
return supplies
|
||||
|
||||
def gethistory(self, itemids, days=90, limit=5000):
|
||||
"""Raw level history for supply items: {itemid: [(clock, value), ...]}.
|
||||
|
||||
Zabbix keeps this already - we simply never asked for it. history=3 is
|
||||
the unsigned-integer table, which is where a percent-remaining item
|
||||
lands; a site that types the item as float would need history=0, so a
|
||||
miss falls back rather than erroring.
|
||||
|
||||
Returns {} when Zabbix is off or unreachable, so callers degrade to
|
||||
"no estimate" instead of failing.
|
||||
"""
|
||||
if not itemids:
|
||||
return {}
|
||||
timefrom = int(time.time()) - days * 86400
|
||||
collected = {}
|
||||
for historytype in (3, 0):
|
||||
rows = self._apicall("history.get", {
|
||||
"output": "extend",
|
||||
"history": historytype,
|
||||
"itemids": list(itemids),
|
||||
"time_from": timefrom,
|
||||
"sortfield": "clock",
|
||||
"sortorder": "ASC",
|
||||
"limit": limit,
|
||||
}) or []
|
||||
for row in rows:
|
||||
collected.setdefault(str(row.get("itemid")), []).append(
|
||||
(row.get("clock"), row.get("value")))
|
||||
# Items live in one table or the other; stop once something answered.
|
||||
if collected:
|
||||
break
|
||||
return collected
|
||||
|
||||
def getpingstatus(self, ip: str) -> str:
|
||||
"""ICMP ping state for a printer: '1' up, '0' down, '-1' unknown."""
|
||||
hostid = self.gethostidbyip(ip)
|
||||
|
||||
@@ -5,11 +5,14 @@ never stored. Warranties link to assets many-to-many via warrantyassets, though
|
||||
the common case is one warranty per asset.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import os
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint, request, current_app, send_from_directory
|
||||
from flask_jwt_extended import jwt_required
|
||||
from sqlalchemy.orm import joinedload
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from shopdb.api import (
|
||||
db, Asset,
|
||||
@@ -22,6 +25,21 @@ from ..services import get_provider, ProviderNotConfigured, WarrantyLookupError
|
||||
|
||||
warranty_bp = Blueprint('warranty', __name__)
|
||||
|
||||
# What a proof of cover actually arrives as: a vendor PDF, a scan, or a
|
||||
# screenshot of a portal page. Office formats are allowed because purchase
|
||||
# records often arrive that way.
|
||||
PROOF_EXTENSIONS = {'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.tif',
|
||||
'.tiff', '.msg', '.eml', '.doc', '.docx', '.xls', '.xlsx'}
|
||||
PROOF_URL_PREFIX = '/api/warranty/proof/'
|
||||
|
||||
# A certificate is a document, not a disk image. Anything past this is somebody
|
||||
# attaching the wrong thing.
|
||||
MAX_PROOF_BYTES = 25 * 1024 * 1024
|
||||
|
||||
|
||||
def _proof_dir():
|
||||
return os.path.join(current_app.instance_path, 'warrantyproofs')
|
||||
|
||||
|
||||
def _parse_date(value):
|
||||
"""Accept 'YYYY-MM-DD' (or None/empty) -> date or None."""
|
||||
@@ -466,3 +484,100 @@ def _hostname(asset):
|
||||
return None
|
||||
computer = Computer.query.filter_by(assetid=asset.assetid).first()
|
||||
return computer.hostname if computer else None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Proof of cover
|
||||
#
|
||||
# Authenticated on the way in AND on the way out: an invoice carries prices and
|
||||
# a service tag, so it is not something to serve openly the way an asset photo
|
||||
# is. Stored as warranty-<id><ext>, one per warranty, with the vendor's own
|
||||
# filename kept alongside so a person recognises it later.
|
||||
# =============================================================================
|
||||
|
||||
@warranty_bp.route('/<int:warrantyid>/proof', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('warranty.edit')
|
||||
def upload_proof(warrantyid):
|
||||
"""Upload (or replace) the proof-of-cover document for a warranty."""
|
||||
warranty = db.session.get(Warranty, warrantyid)
|
||||
if not warranty:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found',
|
||||
http_code=404)
|
||||
|
||||
upload = request.files.get('file')
|
||||
if not upload or not upload.filename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||
|
||||
ext = os.path.splitext(upload.filename)[1].lower()
|
||||
if ext not in PROOF_EXTENSIONS:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Unsupported document type {}. Allowed: {}'.format(
|
||||
ext, ', '.join(sorted(PROOF_EXTENSIONS))))
|
||||
|
||||
# Seek rather than trust Content-Length: a chunked upload has no length
|
||||
# header, and a client can understate the one it sends.
|
||||
upload.stream.seek(0, os.SEEK_END)
|
||||
size = upload.stream.tell()
|
||||
upload.stream.seek(0)
|
||||
if size > MAX_PROOF_BYTES:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Document is {:.0f}MB; the limit is {:.0f}MB.'.format(
|
||||
size / 1048576, MAX_PROOF_BYTES / 1048576))
|
||||
|
||||
proofdir = _proof_dir()
|
||||
os.makedirs(proofdir, exist_ok=True)
|
||||
# One proof per warranty: clear any prior extension so a re-upload as .pdf
|
||||
# does not leave the old .png behind claiming to be current.
|
||||
for old in glob.glob(os.path.join(proofdir,
|
||||
secure_filename(f'warranty-{warrantyid}') + '.*')):
|
||||
os.remove(old)
|
||||
|
||||
filename = secure_filename(f'warranty-{warrantyid}{ext}')
|
||||
upload.save(os.path.join(proofdir, filename))
|
||||
|
||||
warranty.proofurl = f'{PROOF_URL_PREFIX}{filename}'
|
||||
warranty.prooffilename = upload.filename
|
||||
db.session.commit()
|
||||
|
||||
return success_response(warranty.to_dict(), message='Proof uploaded')
|
||||
|
||||
|
||||
@warranty_bp.route('/proof/<path:filename>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('warranty.view')
|
||||
def serve_proof(filename):
|
||||
"""Download a proof document.
|
||||
|
||||
Authenticated: an invoice carries pricing and a service tag. Sent as an
|
||||
attachment under the vendor's original filename where we still have it, so
|
||||
a download is recognisable rather than 'warranty-12.pdf'.
|
||||
"""
|
||||
warranty = Warranty.query.filter(
|
||||
Warranty.proofurl == f'{PROOF_URL_PREFIX}{filename}').first()
|
||||
downloadname = (warranty.prooffilename if warranty and warranty.prooffilename
|
||||
else filename)
|
||||
return send_from_directory(_proof_dir(), filename, as_attachment=True,
|
||||
download_name=downloadname)
|
||||
|
||||
|
||||
@warranty_bp.route('/<int:warrantyid>/proof', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('warranty.edit')
|
||||
def delete_proof(warrantyid):
|
||||
"""Remove a warranty's proof document."""
|
||||
warranty = db.session.get(Warranty, warrantyid)
|
||||
if not warranty:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Warranty not found',
|
||||
http_code=404)
|
||||
|
||||
for old in glob.glob(os.path.join(_proof_dir(),
|
||||
secure_filename(f'warranty-{warrantyid}') + '.*')):
|
||||
os.remove(old)
|
||||
warranty.proofurl = None
|
||||
warranty.prooffilename = None
|
||||
db.session.commit()
|
||||
|
||||
return success_response(warranty.to_dict(), message='Proof removed')
|
||||
|
||||
@@ -21,3 +21,8 @@ export default [
|
||||
meta: { plugin: 'warranty' }
|
||||
}
|
||||
]
|
||||
|
||||
// No full-screen routes: every view in this plugin renders inside
|
||||
// AppLayout. Declared so routes.gen.js can read `.toplevel` off the module
|
||||
// without Rollup warning about a missing export.
|
||||
export const toplevel = []
|
||||
|
||||
@@ -151,6 +151,21 @@
|
||||
<label>Notes</label>
|
||||
<textarea v-model="form.notes" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Proof of cover</label>
|
||||
<div v-if="proofName" class="proof-current">
|
||||
<a v-if="proofUrl" :href="proofUrl" @click.prevent="downloadProof">{{ proofName }}</a>
|
||||
<span v-else>{{ proofName }}</span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="removeProof"
|
||||
:disabled="busyProof">Remove</button>
|
||||
</div>
|
||||
<input type="file" class="form-control" :accept="PROOF_ACCEPT" @change="onProofPicked" />
|
||||
<small class="muted">
|
||||
Invoice, certificate or a saved email - up to 25MB. Downloading
|
||||
needs a login, since it carries pricing and a service tag.
|
||||
</small>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -167,7 +182,7 @@
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { warrantyApi, assetsApi, vendorsApi } from '@/api'
|
||||
import api, { warrantyApi, assetsApi, vendorsApi } from '@/api'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
import MachineMapChip from '../components/MachineMapChip.vue'
|
||||
@@ -315,7 +330,10 @@ function openModal(item = null) {
|
||||
} else {
|
||||
form.value = blankForm()
|
||||
selectedAssets.value = []
|
||||
proofName.value = ''
|
||||
proofUrl.value = ''
|
||||
}
|
||||
pendingProof.value = null
|
||||
assetQuery.value = ''
|
||||
assetResults.value = []
|
||||
error.value = ''
|
||||
@@ -323,15 +341,75 @@ function openModal(item = null) {
|
||||
}
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
// Chosen before a new warranty exists, so it is held here and uploaded once
|
||||
// there is an id to attach it to.
|
||||
const pendingProof = ref(null)
|
||||
const proofName = ref('')
|
||||
const proofUrl = ref('')
|
||||
const busyProof = ref(false)
|
||||
const PROOF_ACCEPT = '.pdf,.png,.jpg,.jpeg,.gif,.webp,.tif,.tiff,.msg,.eml,.doc,.docx,.xls,.xlsx'
|
||||
const MAX_PROOF_BYTES = 25 * 1024 * 1024
|
||||
|
||||
function onProofPicked(event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
if (file.size > MAX_PROOF_BYTES) {
|
||||
error.value = `${file.name} is ${(file.size / 1048576).toFixed(0)}MB; the limit is 25MB.`
|
||||
event.target.value = ''
|
||||
return
|
||||
}
|
||||
error.value = ''
|
||||
pendingProof.value = file
|
||||
proofName.value = file.name
|
||||
proofUrl.value = ''
|
||||
}
|
||||
|
||||
async function removeProof() {
|
||||
pendingProof.value = null
|
||||
proofName.value = ''
|
||||
proofUrl.value = ''
|
||||
if (editing.value) {
|
||||
busyProof.value = true
|
||||
try { await warrantyApi.removeProof(editing.value.warrantyid) }
|
||||
catch (err) { error.value = apiError(err, 'Failed to remove the document') }
|
||||
finally { busyProof.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
// The route is authenticated, so a plain href would 401. Fetch it through the
|
||||
// client, which carries the token, then hand the blob to the browser.
|
||||
async function downloadProof() {
|
||||
try {
|
||||
const response = await api.get(proofUrl.value, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(response.data)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = proofName.value || 'proof'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to download the document')
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = { ...form.value, assetids: selectedAssets.value.map(a => a.assetid) }
|
||||
let warrantyid
|
||||
if (editing.value) {
|
||||
await warrantyApi.update(editing.value.warrantyid, payload)
|
||||
warrantyid = editing.value.warrantyid
|
||||
await warrantyApi.update(warrantyid, payload)
|
||||
} else {
|
||||
await warrantyApi.create(payload)
|
||||
const created = await warrantyApi.create(payload)
|
||||
warrantyid = created.data.data.warrantyid
|
||||
}
|
||||
// After the save: a new warranty has no id until it exists, and the upload
|
||||
// keys on it. A failed upload leaves the saved record alone and reports.
|
||||
if (pendingProof.value) {
|
||||
await warrantyApi.uploadProof(warrantyid, pendingProof.value)
|
||||
pendingProof.value = null
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
@@ -390,6 +468,8 @@ async function refresh(w) {
|
||||
.filters label { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||
.filters .form-control { max-width: 320px; }
|
||||
.result-count { color: var(--text-light); font-size: 0.85rem; }
|
||||
.proof-current { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
|
||||
.proof-current a { word-break: break-all; }
|
||||
.servicelevel-cell { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 0.9rem 0 0.2rem; }
|
||||
.page-info { color: var(--text-light); font-size: 0.85rem; }
|
||||
|
||||
50
plugins/warranty/migrations/versions/0002_proof_document.py
Normal file
50
plugins/warranty/migrations/versions/0002_proof_document.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Add warranties.proofurl / prooffilename (proof-of-cover document).
|
||||
|
||||
A provider lookup answers whether a unit is covered. It does not produce the
|
||||
invoice or the extended-warranty certificate, and a manually entered warranty
|
||||
had nowhere to keep one - so the proof lived in somebody's mailbox until they
|
||||
left. Two columns: the served URL of the stored file, and the name the vendor
|
||||
sent it under, which is what a person recognises months later.
|
||||
|
||||
Idempotent; downgrade drops both.
|
||||
|
||||
Revision ID: warranty0002proof
|
||||
Revises: warranty0001anchor
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = 'warranty0002proof'
|
||||
down_revision = 'warranty0001anchor'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_TABLE = 'warranties'
|
||||
_COLUMNS = (('proofurl', sa.String(500)), ('prooffilename', sa.String(255)))
|
||||
|
||||
|
||||
def _column_names(insp, table):
|
||||
return {c['name'] for c in insp.get_columns(table)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if _TABLE not in insp.get_table_names():
|
||||
return
|
||||
existing = _column_names(insp, _TABLE)
|
||||
for name, coltype in _COLUMNS:
|
||||
if name not in existing:
|
||||
op.add_column(_TABLE, sa.Column(name, coltype, nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
if _TABLE not in insp.get_table_names():
|
||||
return
|
||||
existing = _column_names(insp, _TABLE)
|
||||
for name, _ in _COLUMNS:
|
||||
if name in existing:
|
||||
op.drop_column(_TABLE, name)
|
||||
@@ -48,6 +48,14 @@ class Warranty(db.Model):
|
||||
# When a provider lookup last refreshed this record.
|
||||
lastcheckeddate = db.Column(db.DateTime)
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
# Proof of cover: the purchase invoice, the extended-warranty certificate,
|
||||
# whatever the vendor sent. A provider lookup answers "is it covered"; this
|
||||
# answers "prove it" months later, when the email it arrived in is gone.
|
||||
# Stores the served URL, not the original filename - see the upload route.
|
||||
proofurl = db.Column(db.String(500))
|
||||
prooffilename = db.Column(db.String(255))
|
||||
|
||||
isactive = db.Column(db.Boolean, nullable=False, server_default='1')
|
||||
|
||||
links = db.relationship('WarrantyAsset', back_populates='warranty',
|
||||
@@ -68,6 +76,8 @@ class Warranty(db.Model):
|
||||
'enddate': self.enddate.isoformat() if self.enddate else None,
|
||||
'lastcheckeddate': self.lastcheckeddate.isoformat() + 'Z' if self.lastcheckeddate else None,
|
||||
'notes': self.notes,
|
||||
'proofurl': self.proofurl,
|
||||
'prooffilename': self.prooffilename,
|
||||
'isactive': bool(self.isactive),
|
||||
'status': status,
|
||||
'statuscolor': STATUS_COLORS.get(status, STATUS_COLORS['unknown']),
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""Applications API endpoints."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
import glob
|
||||
import os
|
||||
|
||||
from flask import Blueprint, request, current_app, send_from_directory
|
||||
from flask_jwt_extended import jwt_required
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import (
|
||||
@@ -17,6 +21,46 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
from shopdb.utils.import_mode import apply_import_timestamps
|
||||
|
||||
|
||||
# Uploads live in the instance dir, like model images and part photos, so a
|
||||
# site's files are not mixed into the code tree and survive a redeploy.
|
||||
APP_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico'}
|
||||
APP_IMAGE_URL_PREFIX = '/api/applications/image/'
|
||||
|
||||
# Installers are whatever a vendor ships. The list is a guard against a browser
|
||||
# handing us something unintended, not a security boundary - the download route
|
||||
# always sends as an attachment and never executes anything.
|
||||
APP_PACKAGE_EXTENSIONS = {'.exe', '.msi', '.msp', '.zip', '.7z', '.cab', '.iso',
|
||||
'.appx', '.msix', '.ps1', '.bat', '.txt', '.pdf'}
|
||||
APP_PACKAGE_URL_PREFIX = '/api/applications/package/'
|
||||
|
||||
# A real installer runs to hundreds of megabytes; an ISO runs to gigabytes and
|
||||
# does not belong in an instance directory. 500MB is the line: enough for the
|
||||
# msi/exe this is for, small enough that the disk cannot vanish behind it.
|
||||
MAX_PACKAGE_BYTES = 500 * 1024 * 1024
|
||||
|
||||
|
||||
def _appimage_dir():
|
||||
return os.path.join(current_app.instance_path, 'applicationimages')
|
||||
|
||||
|
||||
def _apppackage_dir():
|
||||
return os.path.join(current_app.instance_path, 'applicationpackages')
|
||||
|
||||
|
||||
def _replace_upload(directory, stem, ext, upload):
|
||||
"""Save one file per application, replacing any prior extension.
|
||||
|
||||
Returns the stored filename. Without the glob a re-upload as .png would
|
||||
orphan the old .jpg and the two would fight over which is current.
|
||||
"""
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
for old in glob.glob(os.path.join(directory, secure_filename(stem) + '.*')):
|
||||
os.remove(old)
|
||||
filename = secure_filename(f'{stem}{ext}')
|
||||
upload.save(os.path.join(directory, filename))
|
||||
return filename
|
||||
|
||||
|
||||
def _computer_models():
|
||||
"""Lazily import the computers plugin models, or None if unavailable.
|
||||
|
||||
@@ -460,3 +504,157 @@ def update_installed_app(machine_id: int, app_id: int):
|
||||
|
||||
# Support teams + contacts now live in the supportteams blueprint
|
||||
# (/api/supportteams), replacing the legacy appowners pair.
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Image and installer uploads
|
||||
#
|
||||
# Both follow the model-image trio (upload / serve / delete). The image is
|
||||
# public because application pages render it before anything else loads; the
|
||||
# installer is not, because it is a binary a site pays for.
|
||||
# =============================================================================
|
||||
|
||||
@applications_bp.route('/<int:app_id>/image', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('applications.edit')
|
||||
def upload_application_image(app_id: int):
|
||||
"""Upload (or replace) an application's image.
|
||||
|
||||
multipart/form-data: file=<image>. Stored as application-<id><ext>, one per
|
||||
application, and application.image is pointed at the served URL.
|
||||
"""
|
||||
app = db.session.get(Application, app_id)
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Application with ID {app_id} not found',
|
||||
http_code=404)
|
||||
|
||||
upload = request.files.get('file')
|
||||
if not upload or not upload.filename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||
ext = os.path.splitext(upload.filename)[1].lower()
|
||||
if ext not in APP_IMAGE_EXTENSIONS:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Unsupported image type {ext}')
|
||||
|
||||
filename = _replace_upload(_appimage_dir(), f'application-{app_id}', ext, upload)
|
||||
app.image = f'{APP_IMAGE_URL_PREFIX}{filename}'
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log('updated', 'Application', entityid=app_id,
|
||||
entityname=app.appname, changes={'image': {'new': app.image}})
|
||||
db.session.commit()
|
||||
return success_response(app.to_dict(), message='Application image uploaded')
|
||||
|
||||
|
||||
@applications_bp.route('/image/<path:filename>', methods=['GET'])
|
||||
def serve_application_image(filename):
|
||||
"""Serve an uploaded application image (public - lists and tiles read it)."""
|
||||
return send_from_directory(_appimage_dir(), filename)
|
||||
|
||||
|
||||
@applications_bp.route('/<int:app_id>/image', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('applications.edit')
|
||||
def delete_application_image(app_id: int):
|
||||
"""Remove an application's image and clear the field."""
|
||||
app = db.session.get(Application, app_id)
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Application with ID {app_id} not found',
|
||||
http_code=404)
|
||||
|
||||
for old in glob.glob(os.path.join(_appimage_dir(),
|
||||
secure_filename(f'application-{app_id}') + '.*')):
|
||||
os.remove(old)
|
||||
app.image = None
|
||||
db.session.commit()
|
||||
return success_response(app.to_dict(), message='Application image removed')
|
||||
|
||||
|
||||
@applications_bp.route('/<int:app_id>/package', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('applications.edit')
|
||||
def upload_application_package(app_id: int):
|
||||
"""Upload (or replace) the installer for an application.
|
||||
|
||||
multipart/form-data: file=<installer>. The original filename is kept in
|
||||
installpath so the download arrives named the way the vendor shipped it,
|
||||
and the stored copy is application-<id><ext>.
|
||||
"""
|
||||
app = db.session.get(Application, app_id)
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Application with ID {app_id} not found',
|
||||
http_code=404)
|
||||
|
||||
upload = request.files.get('file')
|
||||
if not upload or not upload.filename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||
ext = os.path.splitext(upload.filename)[1].lower()
|
||||
if ext not in APP_PACKAGE_EXTENSIONS:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Unsupported installer type {}. Allowed: {}'.format(
|
||||
ext, ', '.join(sorted(APP_PACKAGE_EXTENSIONS))))
|
||||
|
||||
# Measure by seeking the stream, not Content-Length: a chunked upload has
|
||||
# no length header, and trusting the header lets a client understate size.
|
||||
upload.stream.seek(0, os.SEEK_END)
|
||||
size = upload.stream.tell()
|
||||
upload.stream.seek(0)
|
||||
if size > MAX_PACKAGE_BYTES:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Installer is {:.0f}MB; the limit is {:.0f}MB. Put larger payloads '
|
||||
'on the share and link to them with Install Path.'.format(
|
||||
size / 1048576, MAX_PACKAGE_BYTES / 1048576))
|
||||
|
||||
filename = _replace_upload(_apppackage_dir(), f'application-{app_id}', ext, upload)
|
||||
app.installpath = '{}{}'.format(APP_PACKAGE_URL_PREFIX, filename)
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log('updated', 'Application', entityid=app_id,
|
||||
entityname=app.appname,
|
||||
changes={'installpath': {'new': app.installpath},
|
||||
'uploadedfilename': {'new': upload.filename},
|
||||
'bytes': {'new': size}})
|
||||
db.session.commit()
|
||||
return success_response(
|
||||
{**app.to_dict(), 'uploadedfilename': upload.filename, 'bytes': size},
|
||||
message='Installer uploaded')
|
||||
|
||||
|
||||
@applications_bp.route('/package/<path:filename>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('applications.view')
|
||||
def serve_application_package(filename):
|
||||
"""Download an uploaded installer.
|
||||
|
||||
Authenticated, unlike the image: this is licensed vendor software, and an
|
||||
open URL would publish it to anything that can reach the site. Always sent
|
||||
as an attachment so a browser saves it rather than trying to render it.
|
||||
"""
|
||||
return send_from_directory(_apppackage_dir(), filename, as_attachment=True)
|
||||
|
||||
|
||||
@applications_bp.route('/<int:app_id>/package', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('applications.edit')
|
||||
def delete_application_package(app_id: int):
|
||||
"""Remove an uploaded installer and clear the path it set."""
|
||||
app = db.session.get(Application, app_id)
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Application with ID {app_id} not found',
|
||||
http_code=404)
|
||||
|
||||
for old in glob.glob(os.path.join(_apppackage_dir(),
|
||||
secure_filename(f'application-{app_id}') + '.*')):
|
||||
os.remove(old)
|
||||
# Only clear the path if it pointed at the upload; a site's own share path
|
||||
# was typed by a person and is not ours to wipe.
|
||||
if (app.installpath or '').startswith(APP_PACKAGE_URL_PREFIX):
|
||||
app.installpath = None
|
||||
db.session.commit()
|
||||
return success_response(app.to_dict(), message='Installer removed')
|
||||
|
||||
165
tests/test_core/test_application_uploads.py
Normal file
165
tests/test_core/test_application_uploads.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Tests for application image and installer uploads.
|
||||
|
||||
The form used to take an image FILENAME and expect someone to drop the file into
|
||||
the frontend's own directory by hand, and an install path typed from memory.
|
||||
Both are uploads now, following the model-image trio.
|
||||
|
||||
The two differ deliberately and that is most of what is pinned here: the image
|
||||
is public because application tiles render before anything is authenticated, the
|
||||
installer is not, because it is licensed vendor software.
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
from shopdb.core.models import Application
|
||||
|
||||
|
||||
def _app(db, name='Test App'):
|
||||
app = Application(appname=name)
|
||||
db.session.add(app)
|
||||
db.session.commit()
|
||||
return app
|
||||
|
||||
|
||||
def _png():
|
||||
# 1x1 PNG; enough to exercise the path without a fixture file.
|
||||
return io.BytesIO(
|
||||
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
|
||||
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00'
|
||||
b'\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82')
|
||||
|
||||
|
||||
def test_image_upload_sets_the_served_url(client, db, auth_headers):
|
||||
app = _app(db)
|
||||
|
||||
response = client.post(f'/api/applications/{app.appid}/image',
|
||||
data={'file': (_png(), 'logo.png')},
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()['data']['image'] == \
|
||||
f'/api/applications/image/application-{app.appid}.png'
|
||||
|
||||
|
||||
def test_image_is_readable_without_a_token(client, db, auth_headers):
|
||||
"""Tiles and lists render the image before anything is authenticated."""
|
||||
app = _app(db)
|
||||
client.post(f'/api/applications/{app.appid}/image',
|
||||
data={'file': (_png(), 'logo.png')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
anon = client.get(f'/api/applications/image/application-{app.appid}.png')
|
||||
|
||||
assert anon.status_code == 200
|
||||
assert anon.mimetype == 'image/png'
|
||||
|
||||
|
||||
def test_image_rejects_a_non_image(client, db, auth_headers):
|
||||
app = _app(db)
|
||||
|
||||
response = client.post(f'/api/applications/{app.appid}/image',
|
||||
data={'file': (io.BytesIO(b'MZ'), 'payload.exe')},
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'Unsupported image type' in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_installer_upload_sets_the_install_path(client, db, auth_headers):
|
||||
app = _app(db)
|
||||
|
||||
response = client.post(f'/api/applications/{app.appid}/package',
|
||||
data={'file': (io.BytesIO(b'fake installer'), 'setup.msi')},
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
body = response.get_json()['data']
|
||||
assert body['installpath'] == \
|
||||
f'/api/applications/package/application-{app.appid}.msi'
|
||||
# The name the vendor shipped is reported back, since the stored copy is
|
||||
# renamed and the uploader needs to recognise what they sent.
|
||||
assert body['uploadedfilename'] == 'setup.msi'
|
||||
|
||||
|
||||
def test_installer_download_needs_a_token(client, db, auth_headers):
|
||||
"""Licensed software: an open URL would publish it to the whole network."""
|
||||
app = _app(db)
|
||||
client.post(f'/api/applications/{app.appid}/package',
|
||||
data={'file': (io.BytesIO(b'fake installer'), 'setup.msi')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
path = f'/api/applications/package/application-{app.appid}.msi'
|
||||
|
||||
assert client.get(path).status_code == 401
|
||||
|
||||
authed = client.get(path, headers=auth_headers)
|
||||
assert authed.status_code == 200
|
||||
assert authed.data == b'fake installer'
|
||||
# Sent as an attachment so a browser saves it rather than rendering it.
|
||||
assert 'attachment' in authed.headers.get('Content-Disposition', '')
|
||||
|
||||
|
||||
def test_installer_rejects_an_unlisted_type(client, db, auth_headers):
|
||||
app = _app(db)
|
||||
|
||||
response = client.post(f'/api/applications/{app.appid}/package',
|
||||
data={'file': (io.BytesIO(b'x'), 'driver.dll')},
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'Unsupported installer type' in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_installer_rejects_an_oversize_file(client, db, auth_headers, monkeypatch):
|
||||
"""The cap keeps an ISO out of the instance directory."""
|
||||
from shopdb.core.api import applications as applications_api
|
||||
monkeypatch.setattr(applications_api, 'MAX_PACKAGE_BYTES', 1024)
|
||||
app = _app(db)
|
||||
|
||||
response = client.post(f'/api/applications/{app.appid}/package',
|
||||
data={'file': (io.BytesIO(b'x' * 4096), 'big.msi')},
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'the limit is' in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_removing_an_installer_clears_only_an_uploaded_path(client, db, auth_headers):
|
||||
"""A share path was typed by a person and is not ours to wipe."""
|
||||
app = _app(db)
|
||||
client.post(f'/api/applications/{app.appid}/package',
|
||||
data={'file': (io.BytesIO(b'installer'), 'setup.msi')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
cleared = client.delete(f'/api/applications/{app.appid}/package',
|
||||
headers=auth_headers)
|
||||
assert cleared.get_json()['data']['installpath'] is None
|
||||
|
||||
app.installpath = r'\\share\installers\vendor.msi'
|
||||
db.session.commit()
|
||||
again = client.delete(f'/api/applications/{app.appid}/package',
|
||||
headers=auth_headers)
|
||||
assert again.get_json()['data']['installpath'] == r'\\share\installers\vendor.msi'
|
||||
|
||||
|
||||
def test_replacing_an_image_does_not_orphan_the_old_extension(client, db, auth_headers):
|
||||
"""One image per application, whatever the extension arrives as."""
|
||||
import glob
|
||||
import os
|
||||
from flask import current_app
|
||||
app = _app(db)
|
||||
|
||||
client.post(f'/api/applications/{app.appid}/image',
|
||||
data={'file': (_png(), 'logo.png')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
client.post(f'/api/applications/{app.appid}/image',
|
||||
data={'file': (io.BytesIO(b'<svg/>'), 'logo.svg')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
imagedir = os.path.join(current_app.instance_path, 'applicationimages')
|
||||
files = glob.glob(os.path.join(imagedir, f'application-{app.appid}.*'))
|
||||
assert len(files) == 1 and files[0].endswith('.svg')
|
||||
@@ -71,6 +71,8 @@ EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
||||
# notifications indexes businessunitid, then adds the per-type grace window and
|
||||
# the shared board category.
|
||||
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'
|
||||
# warranty adds the proof-of-cover document columns on top of its anchor.
|
||||
EXPECTED_HEAD_REVISION['warranty'] = 'warranty0002proof'
|
||||
|
||||
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||
# core chain never owned.
|
||||
@@ -239,9 +241,21 @@ def test_upgrade_all_on_fresh_db_is_clean_and_idempotent(tmp_path, monkeypatch):
|
||||
first = app.extensions['plugin_manager'].upgrade_all_plugins()
|
||||
second = app.extensions['plugin_manager'].upgrade_all_plugins()
|
||||
|
||||
assert set(first) == set(PLUGIN_TABLE_OWNERS)
|
||||
assert all(status == 'ok' for status in first.values()), first
|
||||
assert all(status == 'ok' for status in second.values()), second
|
||||
# A plugin that owns no tables (Tech Tools: every tool is
|
||||
# client-side) carries no chain, so upgrade-all reports it
|
||||
# 'no-migrations'. Assert that explicitly rather than letting it
|
||||
# widen the 'ok' check, which would also swallow a table-owning
|
||||
# plugin whose chain silently went missing.
|
||||
schemaless = set(first) - set(PLUGIN_TABLE_OWNERS)
|
||||
for name in schemaless:
|
||||
assert first[name] == 'no-migrations', (name, first[name])
|
||||
assert second[name] == 'no-migrations', (name, second[name])
|
||||
|
||||
migrated = {name: status for name, status in first.items()
|
||||
if name in PLUGIN_TABLE_OWNERS}
|
||||
assert set(migrated) == set(PLUGIN_TABLE_OWNERS)
|
||||
assert all(status == 'ok' for status in migrated.values()), migrated
|
||||
assert all(second[name] == 'ok' for name in PLUGIN_TABLE_OWNERS), second
|
||||
|
||||
insp = inspect(db.engine)
|
||||
for plugin in PLUGIN_TABLE_OWNERS:
|
||||
|
||||
130
tests/test_plugins/test_supply_history.py
Normal file
130
tests/test_plugins/test_supply_history.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Tests for toner burn-rate and replacement counting.
|
||||
|
||||
The analysis is pure arithmetic over a list of readings, so it is tested that
|
||||
way - no Zabbix, no fixtures. What matters is that it refuses to guess: the
|
||||
cases where an estimate would be dishonest are the ones most likely to reach a
|
||||
purchasing decision.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from plugins.printers.services.supply_history import (
|
||||
analyse, burn_rate, current_run, find_replacements, normalise, soonest,
|
||||
)
|
||||
|
||||
START = datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def series(levels, hours=24):
|
||||
"""[(clock, value)] one reading per `hours`, as Zabbix returns them."""
|
||||
return [(str(int((START + timedelta(hours=i * hours)).timestamp())), str(v))
|
||||
for i, v in enumerate(levels)]
|
||||
|
||||
|
||||
def test_counts_one_replacement_per_upward_step():
|
||||
points = normalise(series([80, 60, 40, 20, 100, 80, 60, 95, 70]))
|
||||
|
||||
assert len(find_replacements(points)) == 2
|
||||
|
||||
|
||||
def test_ignores_a_wobble_that_is_not_a_replacement():
|
||||
"""SNMP rounding and a gauge settling both nudge a reading upward."""
|
||||
points = normalise(series([60, 58, 61, 57, 55]))
|
||||
|
||||
assert find_replacements(points) == []
|
||||
|
||||
|
||||
def test_estimate_uses_only_the_current_cartridge():
|
||||
"""Fitting across a swap averages a spent cartridge with a fresh one."""
|
||||
points = normalise(series([90, 60, 30, 100, 90, 80, 70]))
|
||||
|
||||
run = current_run(points)
|
||||
|
||||
assert [level for _, level in run] == [100, 90, 80, 70]
|
||||
|
||||
|
||||
def test_days_left_from_a_steady_drain():
|
||||
# 10 points a day apart, 5% a day, ending at 55%
|
||||
result = analyse(series([100, 95, 90, 85, 80, 75, 70, 65, 60, 55]))
|
||||
|
||||
assert result['burnrateperday'] == 5.0
|
||||
assert result['daysleft'] == 11 # 55 / 5
|
||||
assert result['reason'] is None
|
||||
assert result['basisdays'] == 9.0
|
||||
|
||||
|
||||
def test_no_estimate_from_two_readings():
|
||||
"""Two points through a coarse gauge can prove any rate at all."""
|
||||
result = analyse(series([100, 90]))
|
||||
|
||||
assert result['daysleft'] is None
|
||||
assert result['reason'] == 'not enough history yet'
|
||||
|
||||
|
||||
def test_no_estimate_while_the_gauge_has_not_moved():
|
||||
"""A printer reporting in 10% steps sits on a plateau for a fortnight."""
|
||||
result = analyse(series([70, 70, 70, 70, 70, 70]))
|
||||
|
||||
assert result['daysleft'] is None
|
||||
assert result['reason'] == 'level has not moved enough to estimate'
|
||||
|
||||
|
||||
def test_a_recent_replacement_says_so_rather_than_guessing():
|
||||
result = analyse(series([40, 30, 20, 10, 100, 98]))
|
||||
|
||||
assert result['daysleft'] is None
|
||||
assert result['reason'] == 'replaced recently'
|
||||
assert result['replacements'] == 1
|
||||
assert result['lastreplaced'] is not None
|
||||
|
||||
|
||||
def test_empty_history_is_reported_not_crashed():
|
||||
result = analyse([])
|
||||
|
||||
assert result['reason'] == 'no history'
|
||||
assert result['currentlevel'] is None
|
||||
assert result['replacements'] == 0
|
||||
|
||||
|
||||
def test_junk_rows_are_dropped_not_fatal():
|
||||
points = normalise([('notaclock', '50'), ('1780000000', 'n/a'),
|
||||
('1780000000', '50')])
|
||||
|
||||
assert len(points) == 1
|
||||
|
||||
|
||||
def test_days_left_never_goes_negative():
|
||||
result = analyse(series([20, 15, 10, 5, 0]))
|
||||
|
||||
assert result['daysleft'] == 0
|
||||
|
||||
|
||||
def test_printer_sorts_by_its_soonest_supply():
|
||||
"""A colour MFP is as urgent as its most pressing cartridge."""
|
||||
supplies = [{'daysleft': 40}, {'daysleft': 6}, {'daysleft': None}]
|
||||
|
||||
assert soonest(supplies) == 6
|
||||
|
||||
|
||||
def test_soonest_is_none_when_nothing_can_be_estimated():
|
||||
assert soonest([{'daysleft': None}, {'daysleft': None}]) is None
|
||||
|
||||
|
||||
def test_burn_rate_needs_elapsed_time():
|
||||
"""Several readings in the same second is not a rate."""
|
||||
clock = str(int(START.timestamp()))
|
||||
points = normalise([(clock, '90'), (clock, '80'), (clock, '70'), (clock, '60')])
|
||||
|
||||
assert burn_rate(points) is None
|
||||
|
||||
|
||||
def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db):
|
||||
"""A dashboard must be told the data is missing, not shown an empty list
|
||||
that reads as 'nothing runs out soon'."""
|
||||
response = client.get('/api/printers/supplies/forecast')
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()['data']
|
||||
assert body['available'] is False
|
||||
assert 'not configured' in body['reason']
|
||||
assert body['printers'] == []
|
||||
55
tests/test_plugins/test_tools.py
Normal file
55
tests/test_plugins/test_tools.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Tests for the Tech Tools plugin.
|
||||
|
||||
The plugin owns no API and no tables, so the whole backend contract is: it
|
||||
loads, it advertises one nav entry, and it claims no database. That last part
|
||||
matters - a plugin that accidentally returns models would pull the tools
|
||||
section into the migration and prune-schema machinery it has no business in.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from plugins.tools.plugin import ToolsPlugin
|
||||
|
||||
|
||||
PLUGIN_DIR = os.path.join(os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.abspath(__file__)))), 'plugins', 'tools')
|
||||
|
||||
|
||||
def test_meta_matches_manifest():
|
||||
"""The manifest is the single source of truth per ADR-002."""
|
||||
with open(os.path.join(PLUGIN_DIR, 'manifest.json'), encoding='utf-8') as handle:
|
||||
manifest = json.load(handle)
|
||||
|
||||
meta = ToolsPlugin().meta
|
||||
assert meta.name == manifest['name']
|
||||
assert meta.version == manifest['version']
|
||||
assert meta.core_version == manifest['core_version']
|
||||
|
||||
|
||||
def test_no_backend_surface():
|
||||
"""Client-side only: no blueprint to register, no tables to migrate."""
|
||||
plugin = ToolsPlugin()
|
||||
assert plugin.get_blueprint() is None
|
||||
assert plugin.get_models() == []
|
||||
|
||||
|
||||
def test_navigation_item():
|
||||
items = ToolsPlugin().get_navigation_items()
|
||||
assert len(items) == 1
|
||||
item = items[0]
|
||||
assert item['name'] == 'Tech Tools'
|
||||
assert item['route'] == '/tools'
|
||||
# The icon has to exist in AppLayout's iconMap or the nav entry renders
|
||||
# with no glyph.
|
||||
assert item['icon'] == 'wrench'
|
||||
|
||||
|
||||
def test_nav_icon_is_mapped_in_the_frontend():
|
||||
"""Guard the one cross-file coupling: nav icon name -> AppLayout iconMap."""
|
||||
layout = os.path.join(os.path.dirname(PLUGIN_DIR), '..', 'frontend', 'src',
|
||||
'views', 'AppLayout.vue')
|
||||
with open(os.path.normpath(layout), encoding='utf-8') as handle:
|
||||
source = handle.read()
|
||||
for item in ToolsPlugin().get_navigation_items():
|
||||
assert f"'{item['icon']}':" in source
|
||||
110
tests/test_plugins/test_warranty_proof.py
Normal file
110
tests/test_plugins/test_warranty_proof.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Tests for the warranty proof-of-cover document.
|
||||
|
||||
A provider lookup answers whether a unit is covered; it does not produce the
|
||||
invoice or the extended-warranty certificate. Those had nowhere to live, so
|
||||
they stayed in somebody's mailbox. These pin the parts that are easy to get
|
||||
wrong: the document is authenticated on the way out, one per warranty, and the
|
||||
vendor's own filename survives so a download is recognisable.
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
from plugins.warranty.models import Warranty
|
||||
|
||||
|
||||
def _warranty(db, vendor='Dell'):
|
||||
w = Warranty(vendor=vendor, servicetag='ABC123', provider='manual')
|
||||
db.session.add(w)
|
||||
db.session.commit()
|
||||
return w
|
||||
|
||||
|
||||
def test_upload_sets_url_and_keeps_the_original_filename(client, db, auth_headers):
|
||||
w = _warranty(db)
|
||||
|
||||
response = client.post(f'/api/warranty/{w.warrantyid}/proof',
|
||||
data={'file': (io.BytesIO(b'%PDF-1.4 invoice'),
|
||||
'Dell invoice 4471.pdf')},
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
body = response.get_json()['data']
|
||||
assert body['proofurl'] == f'/api/warranty/proof/warranty-{w.warrantyid}.pdf'
|
||||
assert body['prooffilename'] == 'Dell invoice 4471.pdf'
|
||||
|
||||
|
||||
def test_download_needs_a_token_and_uses_the_vendor_filename(client, db, auth_headers):
|
||||
"""An invoice carries pricing and a service tag - not open like an asset photo."""
|
||||
w = _warranty(db)
|
||||
client.post(f'/api/warranty/{w.warrantyid}/proof',
|
||||
data={'file': (io.BytesIO(b'%PDF-1.4 invoice'), 'Dell invoice 4471.pdf')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
path = f'/api/warranty/proof/warranty-{w.warrantyid}.pdf'
|
||||
|
||||
assert client.get(path).status_code == 401
|
||||
|
||||
authed = client.get(path, headers=auth_headers)
|
||||
assert authed.status_code == 200
|
||||
assert authed.data == b'%PDF-1.4 invoice'
|
||||
disposition = authed.headers.get('Content-Disposition', '')
|
||||
assert 'attachment' in disposition
|
||||
assert 'Dell invoice 4471.pdf' in disposition
|
||||
|
||||
|
||||
def test_rejects_an_unsupported_type(client, db, auth_headers):
|
||||
w = _warranty(db)
|
||||
|
||||
response = client.post(f'/api/warranty/{w.warrantyid}/proof',
|
||||
data={'file': (io.BytesIO(b'MZ'), 'setup.exe')},
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'Unsupported document type' in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_rejects_an_oversize_document(client, db, auth_headers, monkeypatch):
|
||||
from plugins.warranty.api import routes
|
||||
monkeypatch.setattr(routes, 'MAX_PROOF_BYTES', 512)
|
||||
w = _warranty(db)
|
||||
|
||||
response = client.post(f'/api/warranty/{w.warrantyid}/proof',
|
||||
data={'file': (io.BytesIO(b'x' * 2048), 'scan.pdf')},
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'the limit is' in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_replacing_keeps_one_document_per_warranty(client, db, auth_headers):
|
||||
import glob
|
||||
import os
|
||||
from flask import current_app
|
||||
w = _warranty(db)
|
||||
|
||||
client.post(f'/api/warranty/{w.warrantyid}/proof',
|
||||
data={'file': (io.BytesIO(b'first'), 'scan.png')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
client.post(f'/api/warranty/{w.warrantyid}/proof',
|
||||
data={'file': (io.BytesIO(b'%PDF second'), 'invoice.pdf')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
proofdir = os.path.join(current_app.instance_path, 'warrantyproofs')
|
||||
files = glob.glob(os.path.join(proofdir, f'warranty-{w.warrantyid}.*'))
|
||||
assert len(files) == 1 and files[0].endswith('.pdf')
|
||||
|
||||
|
||||
def test_removing_clears_both_columns(client, db, auth_headers):
|
||||
w = _warranty(db)
|
||||
client.post(f'/api/warranty/{w.warrantyid}/proof',
|
||||
data={'file': (io.BytesIO(b'%PDF'), 'invoice.pdf')},
|
||||
content_type='multipart/form-data', headers=auth_headers)
|
||||
|
||||
response = client.delete(f'/api/warranty/{w.warrantyid}/proof',
|
||||
headers=auth_headers)
|
||||
|
||||
body = response.get_json()['data']
|
||||
assert body['proofurl'] is None
|
||||
assert body['prooffilename'] is None
|
||||
Reference in New Issue
Block a user