Poll Zabbix for toner levels on a schedule and email/webhook on a downward crossing. Warning fires at or below the warning threshold (default 5%), critical at the critical threshold (default 0%); both thresholds are settings. State lives in printersupplyalerts so an alert fires once per crossing and re-arms after a refill. Recipients mirror the printedparts pattern: plugin-scoped shopdb users + roles + free-text emails (falling back to the site alert_recipients), and a chosen support team's webhook (falling back to the site alert_webhook_url). - PrinterSupplyAlert model + migration printers0002supplyalerts - alerttier(remaining, warning, critical) + check_supplies poller - flask printers check-toner-alerts CLI (run via scheduled task/cron) - printers alert settings + Low-Toner Alerts settings page - 7 tests: tier boundaries, once-per-crossing + re-arm, toner-only scope, custom thresholds, support-team webhook routing
193 lines
6.6 KiB
Vue
193 lines
6.6 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Low-Toner Alerts</h2>
|
|
</div>
|
|
|
|
<div class="card form-card">
|
|
<div v-if="message" class="settings-success">{{ message }}</div>
|
|
<div v-if="error" class="error-message">{{ error }}</div>
|
|
|
|
<p class="field-hint">
|
|
A scheduled poll checks Zabbix toner levels. A warning email fires when
|
|
a toner crosses to the warning threshold or below, a critical email at
|
|
the critical threshold. Alerts fire once per crossing; a refill re-arms
|
|
them.
|
|
</p>
|
|
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label>Warning threshold (% remaining)</label>
|
|
<input v-model.number="values.printers_alert_warning_threshold"
|
|
type="number" min="0" max="100" class="form-control" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Critical threshold (% remaining)</label>
|
|
<input v-model.number="values.printers_alert_critical_threshold"
|
|
type="number" min="0" max="100" class="form-control" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Alert shopdb users</label>
|
|
<div class="user-picker">
|
|
<label v-for="candidate in users" :key="candidate.userid" class="user-row">
|
|
<input type="checkbox" :value="String(candidate.userid)"
|
|
v-model="selectedUserids" />
|
|
<span>{{ candidate.username }}</span>
|
|
<span class="user-email">{{ candidate.email }}</span>
|
|
</label>
|
|
<p v-if="users.length === 0" class="field-hint">No users loaded</p>
|
|
</div>
|
|
<p class="field-hint">
|
|
Selected users receive low-toner alerts at their account email.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Alert roles</label>
|
|
<div class="user-picker">
|
|
<label v-for="role in roles" :key="role.roleid" class="user-row">
|
|
<input type="checkbox" :value="String(role.roleid)"
|
|
v-model="selectedRoleids" />
|
|
<span>{{ role.rolename }}</span>
|
|
<span class="user-email">{{ role.description }}</span>
|
|
</label>
|
|
<p v-if="roles.length === 0" class="field-hint">No roles loaded</p>
|
|
</div>
|
|
<p class="field-hint">
|
|
Every active member of a selected role receives low-toner alerts.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Additional alert emails</label>
|
|
<input v-model="values.printers_alert_email" type="text"
|
|
class="form-control" placeholder="print-team@example.com, lead@example.com" />
|
|
<p class="field-hint">
|
|
Comma-separated. Empty uses the site-wide alert recipients
|
|
(Settings > System > Email).
|
|
</p>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Alert support team (Teams webhook)</label>
|
|
<select v-model="values.printers_alert_supportteamid" class="form-control">
|
|
<option value="">Site default webhook</option>
|
|
<option v-for="team in supportTeams" :key="team.supportteamid" :value="String(team.supportteamid)">
|
|
{{ team.teamname }}{{ team.webhookurl ? '' : ' (no webhook set)' }}
|
|
</option>
|
|
</select>
|
|
<p class="field-hint">
|
|
Low-toner alerts post to this team's webhook (set on Settings >
|
|
Support Teams). Empty uses the site-wide alert webhook.
|
|
</p>
|
|
</div>
|
|
|
|
<button class="btn btn-primary" :disabled="saving" @click="save">
|
|
{{ saving ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { settingsApi, usersApi, supportteamsApi } from '@/api'
|
|
|
|
const KEYS = [
|
|
'printers_alert_email',
|
|
'printers_alert_userids',
|
|
'printers_alert_roleids',
|
|
'printers_alert_supportteamid',
|
|
'printers_alert_warning_threshold',
|
|
'printers_alert_critical_threshold'
|
|
]
|
|
|
|
const values = ref({
|
|
printers_alert_email: '',
|
|
printers_alert_userids: '',
|
|
printers_alert_roleids: '',
|
|
printers_alert_supportteamid: '',
|
|
printers_alert_warning_threshold: 5,
|
|
printers_alert_critical_threshold: 0
|
|
})
|
|
const supportTeams = ref([])
|
|
const users = ref([])
|
|
const selectedUserids = ref([])
|
|
const roles = ref([])
|
|
const selectedRoleids = ref([])
|
|
const saving = ref(false)
|
|
const message = ref('')
|
|
const error = ref('')
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const response = await settingsApi.list({ category: 'printers' })
|
|
const rows = response.data.data || []
|
|
for (const row of rows) {
|
|
if (KEYS.includes(row.key)) values.value[row.key] = row.value
|
|
}
|
|
values.value.printers_alert_warning_threshold =
|
|
parseInt(values.value.printers_alert_warning_threshold, 10) || 0
|
|
values.value.printers_alert_critical_threshold =
|
|
parseInt(values.value.printers_alert_critical_threshold, 10) || 0
|
|
selectedUserids.value = (values.value.printers_alert_userids || '')
|
|
.split(',').map(id => id.trim()).filter(Boolean)
|
|
const usersResponse = await usersApi.list()
|
|
users.value = (usersResponse.data.data || []).filter(
|
|
candidate => candidate.isactive && candidate.email)
|
|
selectedRoleids.value = (values.value.printers_alert_roleids || '')
|
|
.split(',').map(id => id.trim()).filter(Boolean)
|
|
const rolesResponse = await usersApi.roles.list()
|
|
roles.value = rolesResponse.data.data || []
|
|
const teamsResponse = await supportteamsApi.list()
|
|
supportTeams.value = teamsResponse.data.data || []
|
|
} catch (loadError) {
|
|
error.value = 'Could not load settings'
|
|
console.error(loadError)
|
|
}
|
|
})
|
|
|
|
async function save() {
|
|
saving.value = true
|
|
message.value = ''
|
|
error.value = ''
|
|
try {
|
|
values.value.printers_alert_userids = selectedUserids.value.join(',')
|
|
values.value.printers_alert_roleids = selectedRoleids.value.join(',')
|
|
for (const key of KEYS) {
|
|
await settingsApi.update(key, String(values.value[key] ?? ''))
|
|
}
|
|
message.value = 'Settings saved'
|
|
} catch (saveError) {
|
|
error.value = saveError.response?.data?.data?.error?.message || 'Save failed'
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
|
|
.form-row { display: flex; gap: 1rem; }
|
|
.form-row .form-group { flex: 1; }
|
|
.user-picker {
|
|
max-height: 12rem;
|
|
overflow-y: auto;
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.35rem;
|
|
padding: 0.5rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.35rem;
|
|
}
|
|
.user-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
cursor: pointer;
|
|
}
|
|
.user-email { color: var(--text-light); font-size: 0.85rem; }
|
|
</style>
|