/** * Print-job feedback for printers that are bound to a LogShip Print Agent (tenant-LAN box). * * Those prints come back from /api/print/* as `printStatus: 'queued'` instead of 'printed'; * the physical outcome arrives later from the agent (spooled → completed | failed). This plugin * turns that into toasts: * - single job → one toast on 'spooled' (success) or 'failed' (error) * - batch (batchId)→ ONE progress toast per batch ("Etiketten 312 / 800 gesendet"), updated in * place, red when something failed — never 800 toasts * * Sources, both used: the in-app SSE bus (plugins/03.notifications-stream.client.ts re-dispatches * `type: 'print-job'` events as a window event — instant, but per PM2 worker) and a light poll of * /api/settings/print-agents/jobs/mine (cross-worker safety net: 2 s while own jobs are open, * 15 s idle). Nothing runs at all on an instance without agents (status endpoint says so). */ type JobEvt = { id: number; batchId?: string | null; status: string; queue?: string; error?: string | null; userId?: number | null; source?: string } const OPEN = new Set(['queued', 'sent']) export default defineNuxtPlugin((nuxtApp) => { if (!process.client) return const nuxtToast: any = useToast() const cookie = (name: string) => { const hit = document.cookie.split('; ').find(c => c.startsWith(name + '=')) return hit ? decodeURIComponent(hit.slice(name.length + 1)) : '' } const isLoggedIn = () => !!cookie('logship_user_id') let enabled = false let cursor = '' let pollTimer: ReturnType | null = null let statusTimer: ReturnType | null = null const known = new Map() // jobId → last status seen const batches = new Map; spooled: number; failed: number; queue: string; closed: boolean }>() let anyOpen = false const addToast = (t: any) => { let id = '' nuxtApp.runWithContext(() => { const res = nuxtToast.add({ duration: t.duration ?? 5000, timeout: t.duration ?? 5000, color: 'primary', ...t } as any) id = res?.id || '' }) return id } const updateToast = (id: string, t: any) => { nuxtApp.runWithContext(() => { try { if (typeof nuxtToast.update === 'function') nuxtToast.update(id, t) else { nuxtToast.remove?.(id); nuxtToast.add({ ...t, id } as any) } } catch { /* toast gone */ } }) } const batchText = (b: { ids: Set; spooled: number; failed: number; queue: string }) => { const total = b.ids.size const done = b.spooled + b.failed return `${done} / ${total} an ${b.queue || 'Drucker'} gesendet${b.failed ? ` · ${b.failed} fehlgeschlagen` : ''}` } const handle = (j: JobEvt) => { if (!j || !Number.isFinite(Number(j.id))) return const me = cookie('logship_user_id') if (j.userId && me && String(j.userId) !== me) return const id = Number(j.id) const prev = known.get(id) known.set(id, j.status) if (j.batchId) { let b = batches.get(j.batchId) if (!b) { b = { toastId: '', ids: new Set(), spooled: 0, failed: 0, queue: j.queue || '', closed: false } batches.set(j.batchId, b) } b.ids.add(id) const wasCounted = prev === 'spooled' || prev === 'completed' || prev === 'failed' if (!wasCounted && (j.status === 'spooled' || j.status === 'completed')) b.spooled++ else if (!wasCounted && j.status === 'failed') b.failed++ else if (prev === 'spooled' && j.status === 'failed') { b.spooled--; b.failed++ } const finished = b.spooled + b.failed >= b.ids.size const payload = { title: finished ? (b.failed ? 'Druckauftrag mit Fehlern' : 'Druckauftrag gesendet') : 'Druckauftrag läuft', description: batchText(b), icon: b.failed ? 'i-mdi-printer-alert' : 'i-mdi-printer', color: b.failed ? 'error' : (finished ? 'success' : 'primary'), duration: finished ? 8000 : 120000, timeout: finished ? 8000 : 120000 } if (!b.toastId) b.toastId = addToast(payload) else updateToast(b.toastId, payload) if (finished) b.closed = true return } if (j.status === 'spooled' && prev !== 'spooled' && prev !== 'completed') { addToast({ title: 'An Drucker gesendet', description: j.queue || '', icon: 'i-mdi-printer-check', color: 'success', duration: 4000 }) } else if (j.status === 'failed' && prev !== 'failed') { addToast({ title: 'Druck fehlgeschlagen', description: `${j.queue || ''}${j.error ? ' – ' + j.error : ''}`.trim(), icon: 'i-mdi-printer-alert', color: 'error', duration: 12000 }) } } const poll = async () => { pollTimer = null if (!enabled || !isLoggedIn()) return try { const res: any = await $fetch('/api/settings/print-agents/jobs/mine', { params: cursor ? { since: cursor } : {} }) const jobs: JobEvt[] = Array.isArray(res?.jobs) ? res.jobs : [] // oldest first so batch counters build up in order jobs.sort((a: any, b: any) => Number(a.id) - Number(b.id)) for (const j of jobs) handle(j) anyOpen = jobs.some(j => OPEN.has(j.status) || j.status === 'spooled') if (res?.serverTime) cursor = res.serverTime } catch { /* keep polling */ } pollTimer = setTimeout(poll, anyOpen ? 2000 : 15000) } const kick = () => { if (!pollTimer) pollTimer = setTimeout(poll, 300) } const checkEnabled = async () => { if (!isLoggedIn()) { enabled = false; return } try { const res: any = await $fetch('/api/settings/print-agents/status') const now = !!res?.enabled if (now && !enabled) { enabled = true; kick() } else if (!now) enabled = false } catch { /* leave as is */ } } // SSE relay from the notifications plugin (same PM2 worker → instant) window.addEventListener('logship:print-job', (e: any) => { const d = e?.detail || {} handle({ id: d.jobId, batchId: d.batchId, status: d.status, queue: d.queue, error: d.error, userId: d.userId, source: d.source }) if (!enabled) { enabled = true; kick() } anyOpen = true if (pollTimer) { clearTimeout(pollTimer); pollTimer = null } kick() }) useRouter().afterEach(() => { if (isLoggedIn() && !statusTimer) { checkEnabled(); statusTimer = setInterval(checkEnabled, 5 * 60_000) } }) setTimeout(() => { if (isLoggedIn()) { checkEnabled(); if (!statusTimer) statusTimer = setInterval(checkEnabled, 5 * 60_000) } }, 2500) })