The USB kiosk had a feature flag and nothing else. A flag is a decision, not a credential: with the kiosk on, anyone who could reach the app could move a drive between checked-out and checked-in and sign it with a badge they guessed. Same reasoning that closed the parts kiosk, smaller blast radius - which is why Darkwing did not raise it and why it was worth asking about anyway. Both kiosks now share one mechanism. The device token moved out of the printedparts api wrapper into a request interceptor that attaches X-Kiosk-Token by URL prefix, with one localStorage key per kiosk - they are separate screens in separate rooms, and a shared token would mean provisioning one grants the other. claimKioskToken() is shared too, so both views take the token from a ?token= URL once and strip it from the address bar identically. Then the part that matters more than either fix. I told you to set the new settings in the UI, and that was impossible: every plugin settings page carries a hardcoded key list, and nothing had added them. printedparts_kiosk_enabled, printedparts_kiosk_token and usb_kiosk_token were declared, seeded and read correctly, and rendered by no page - changeable only by writing SQL. tests/test_plugin_settings_reachable.py now fails when any declared setting appears nowhere in its plugin's frontend. It found seven MORE that were already in that state, all pre-existing: collector_corporate_ranges computers_configpolicies_enabled computers_machinelink_alerts computers_quietreporthours geenforce_reportstaleminutes printers_dashboardpercent warranty_expiringdays All seven now have fields. I first parked them in a KNOWN_GAPS ratchet, then fixed them and deleted the ratchet - a backlog list in a new check is a way of agreeing the check does not apply yet. There is no backlog; the check is absolute. Two of my own mistakes on the way, both caught by tests rather than by reading: the blanket rename of the USB guard also rewrote the call inside _kiosk_open, so it called itself until the stack ran out; and the first version of the reachable check skipped all 17 plugins because it looked for get_settings() when the method is get_settings_defaults() - it passed by testing nothing, which is the exact shape it exists to catch. Suite 2336, frontend 316, build clean. Scope: product
205 lines
7.1 KiB
Vue
205 lines
7.1 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 class="form-group">
|
|
<label>Dashboard threshold (% remaining)</label>
|
|
<input v-model.number="values.printers_dashboardpercent"
|
|
type="number" min="0" max="100" class="form-control" />
|
|
<p class="field-hint">
|
|
At or below this, a printer's supply shows on the dashboard. Separate
|
|
from the alert thresholds above: this one only decides what is
|
|
listed, it sends nothing.
|
|
</p>
|
|
</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',
|
|
'printers_dashboardpercent'
|
|
]
|
|
|
|
const values = ref({
|
|
printers_alert_email: '',
|
|
printers_alert_userids: '',
|
|
printers_alert_roleids: '',
|
|
printers_alert_supportteamid: '',
|
|
printers_alert_warning_threshold: 5,
|
|
printers_dashboardpercent: 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>
|