'use strict'; /** * NetOps To-Do — frontend. * Vanlig JS uten rammeverk (bevisst, lett å patche). All data via REST-API; * sanntidsoppdateringer, presence og "redigerer nå" via WebSocket. */ // ---------- Konstanter ---------- const CATEGORIES = [ { id: 'L1', name: 'L1 Fysisk', desc: 'Kabling, fiber, patch, SFP, rack' }, { id: 'L2', name: 'L2 Switching', desc: 'VLAN, STP, MAC, trunking' }, { id: 'L3', name: 'L3 Ruting', desc: 'OSPF, ISIS, statisk, VRF' }, { id: 'L4', name: 'L4 Lastbalansering', desc: 'HAProxy, NGINX, F5, BIG-IP' }, { id: 'BGP', name: 'BGP', desc: 'iBGP / eBGP, policies, RR' }, { id: 'PEER', name: 'Peering / IX', desc: 'IXP, private peering, PeeringDB' }, { id: 'SEC', name: 'Firewall / Security',desc: 'ACL, firewall, IPS, audit' }, { id: 'NMS', name: 'Overvåking / NMS', desc: 'LibreNMS, Grafana, alerts' }, { id: 'AUTO', name: 'Automatisering', desc: 'Ansible, scripts, CI/CD' }, { id: 'DOC', name: 'Dokumentasjon', desc: 'Diagrammer, IPAM, runbooks' }, { id: 'INC', name: 'Incident / On-call', desc: 'Feilsøking, postmortem' }, { id: 'PROJ', name: 'Prosjekt / Plan', desc: 'Design, anskaffelse, oppgraderinger' }, ]; const CAT_COLORS = { L1: '#ff9f43', L2: '#4fc3f7', L3: '#7fb8ff', L4: '#bb86fc', BGP: '#4ade80', PEER: '#26de81', SEC: '#ff5370', NMS: '#ffd93d', AUTO: '#00d2d3', DOC: '#a0a0a0', INC: '#ff6b6b', PROJ: '#8172B3' }; const STATUS_LABELS = { todo: 'Å gjøre', progress: 'Pågår', blocked: 'Blokkert', done: 'Ferdig' }; const PRIO_ORDER = { P1: 1, P2: 2, P3: 3, P4: 4 }; const BOARD_COLS = ['todo', 'progress', 'blocked', 'done']; const ACT_META = { create: { icon: '+', verb: 'opprettet' }, update: { icon: '✎', verb: 'endret' }, done: { icon: '✓', verb: 'fullførte' }, reopen: { icon: '↺', verb: 'gjenåpnet' }, delete: { icon: '✕', verb: 'slettet' }, comment: { icon: '💬', verb: 'kommenterte' }, import: { icon: '⬆', verb: 'importerte' }, seed: { icon: '◇', verb: 'lastet eksempeldata' }, }; // ---------- State ---------- const state = { user: 'anon', canSetName: false, tasks: [], activity: [], knownUsers: [], online: [], editingMap: {}, // taskId -> [brukere med modal åpen] filters: { status: 'open', category: 'all', priority: 'all', owner: 'all', search: '' }, sort: { field: 'priority', dir: 'asc' }, view: 'board', editingId: null, // id i åpen modal (null = ny oppgave) editingBaseV: null, // versjonen modalen redigerer fra wsOk: false, }; let charts = {}; let ws = null; let wsRetry = 0; let fallbackPoll = null; const flashIds = new Set(); // ids som skal blinke ved neste render (endret av andre) const $ = id => document.getElementById(id); // ---------- Persistens av UI-valg ---------- function loadPrefs() { try { const p = JSON.parse(localStorage.getItem('netops.prefs') || '{}'); if (p.filters) Object.assign(state.filters, p.filters); if (p.sort) state.sort = p.sort; if (p.view === 'table' || p.view === 'board') state.view = p.view; } catch { /* ignorer korrupt localStorage */ } } function savePrefs() { localStorage.setItem('netops.prefs', JSON.stringify({ filters: state.filters, sort: state.sort, view: state.view, })); } // ---------- API ---------- async function api(method, path, body = null) { const opts = { method, headers: {} }; if (body !== null) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } const res = await fetch(path, opts); const ct = res.headers.get('content-type') || ''; if (!ct.includes('application/json')) { throw new Error(`Uventet svar fra server (HTTP ${res.status})`); } const data = await res.json(); if (!res.ok) { const err = new Error(data.error || `HTTP ${res.status}`); err.status = res.status; err.data = data; throw err; } return data; } // ---------- Toast ---------- function toast(msg, opts = {}) { const stack = $('toast-stack'); const el = document.createElement('div'); el.className = 'toast' + (opts.kind ? ' ' + opts.kind : ''); const span = document.createElement('span'); span.textContent = msg; el.appendChild(span); if (opts.action) { const btn = document.createElement('button'); btn.textContent = opts.action.label; btn.addEventListener('click', () => { el.remove(); opts.action.fn(); }); el.appendChild(btn); } stack.appendChild(el); setTimeout(() => el.remove(), opts.ttl || 3500); } // ---------- Utils ---------- function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[m]); } function todayISO(offsetDays = 0) { const d = new Date(); d.setDate(d.getDate() + offsetDays); return d.toISOString().slice(0, 10); } function formatDeadline(dateStr) { if (!dateStr) return { text: '—', cls: '' }; const d = new Date(dateStr); const today = new Date(); today.setHours(0, 0, 0, 0); const diff = Math.round((d - today) / 86400000); if (diff < 0) return { text: `${Math.abs(diff)}d forfalt`, cls: 'overdue' }; if (diff === 0) return { text: 'I dag', cls: 'soon' }; if (diff === 1) return { text: 'I morgen', cls: 'soon' }; if (diff <= 3) return { text: `${diff}d igjen`, cls: 'soon' }; return { text: dateStr, cls: '' }; } function relTime(iso) { const diff = (Date.now() - Date.parse(iso)) / 1000; if (diff < 60) return 'nå nettopp'; if (diff < 3600) return `${Math.floor(diff / 60)} min siden`; if (diff < 86400) return `${Math.floor(diff / 3600)} t siden`; const d = new Date(iso); return d.toLocaleString('nb-NO', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); } function findTask(id) { return state.tasks.find(t => t.id === id); } function upsertTask(task) { const idx = state.tasks.findIndex(t => t.id === task.id); if (idx >= 0) state.tasks[idx] = task; else state.tasks.push(task); } function pushActivity(entry) { if (!entry) return; state.activity.unshift(entry); if (state.activity.length > 400) state.activity.length = 400; } // ---------- WebSocket ---------- function connectWS() { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}/ws`); ws.onopen = () => { wsRetry = 0; state.wsOk = true; setConnStatus(true); if (fallbackPoll) { clearInterval(fallbackPoll); fallbackPoll = null; } }; ws.onmessage = ev => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } handleWSMessage(msg); }; ws.onclose = () => { state.wsOk = false; setConnStatus(false); // Fallback: poll mens WS er nede, og prøv å koble til igjen med backoff. if (!fallbackPoll) fallbackPoll = setInterval(refreshState, 30000); const delay = Math.min(15000, 1000 * Math.pow(2, wsRetry++)); setTimeout(connectWS, delay); }; ws.onerror = () => ws.close(); } function sendWS(obj) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); } function setConnStatus(ok) { const dot = $('conn-dot'); const txt = $('conn-text'); if (ok) { dot.className = 'live'; txt.textContent = 'live · sanntidssynk aktiv'; } else { dot.className = 'off'; txt.textContent = 'frakoblet · prøver igjen …'; } } function handleWSMessage(msg) { switch (msg.type) { case 'hello': state.user = msg.user; state.online = msg.online || []; state.editingMap = msg.editing || {}; renderHeader(); break; case 'sync': state.tasks = msg.tasks || []; state.activity = msg.activity || []; if (msg.by && msg.by !== state.user) toast(`${msg.by} erstattet oppgavelisten (import)`); renderAll(); break; case 'presence': state.online = msg.online || []; renderHeader(); break; case 'editing': state.editingMap = msg.editing || {}; renderView(); updateModalWarning(); break; case 'task': { const mine = msg.by === state.user; pushActivity(msg.activity); if (msg.op === 'delete') { state.tasks = state.tasks.filter(t => t.id !== msg.id); if (!mine && state.editingId === msg.id) { closeModal(); toast(`Oppgaven du redigerte ble slettet av ${msg.by}`, { kind: 'warn', ttl: 6000 }); } } else if (msg.task) { upsertTask(msg.task); if (!mine) { flashIds.add(msg.task.id); // Noen andre lagret oppgaven jeg har åpen i modal → varsle, ikke overskriv skjemaet. if (state.editingId === msg.task.id) updateModalWarning(msg.by); if (state.editingId === msg.task.id) renderComments(msg.task); } } renderAll(); break; } } } // ---------- Hent state via REST (init + fallback) ---------- async function refreshState() { try { const data = await api('GET', '/api/state'); state.user = data.user; state.canSetName = !!data.canSetName; state.tasks = data.tasks || []; state.activity = data.activity || []; state.knownUsers = data.known_users || []; state.online = data.online || []; renderAll(); } catch (e) { toast('Kunne ikke laste: ' + e.message, { kind: 'err' }); } } // ---------- Quick add ---------- function parseQuickAdd(text) { const out = { title: '', tags: [] }; const words = []; for (const tok of text.trim().split(/\s+/)) { if (!tok) continue; let m; if (/^p[1-4]$/i.test(tok)) { out.priority = tok.toUpperCase(); continue; } if ((m = tok.match(/^#(.+)$/))) { const cat = CATEGORIES.find(c => c.id.toLowerCase() === m[1].toLowerCase()); if (cat && !out.category) out.category = cat.id; else out.tags.push(m[1]); continue; } if ((m = tok.match(/^@(.+)$/))) { out.owner = m[1].toLowerCase() === 'me' ? state.user : m[1]; continue; } if ((m = tok.match(/^!(.+)$/))) { const v = m[1].toLowerCase(); if (/^\d{4}-\d{2}-\d{2}$/.test(v)) out.deadline = v; else if ((m = v.match(/^\+(\d{1,3})d?$/))) out.deadline = todayISO(Number(m[1])); else if (v === 'idag' || v === 'today') out.deadline = todayISO(0); else if (v === 'imorgen' || v === 'tomorrow') out.deadline = todayISO(1); continue; } if ((m = tok.match(/^~(\d+(?:[.,]\d+)?)t?h?$/i))) { out.estHours = parseFloat(m[1].replace(',', '.')); continue; } if ((m = tok.match(/^loc:(.+)$/i))) { out.location = m[1]; continue; } words.push(tok); } out.title = words.join(' '); return out; } function renderQuickAddPreview() { const text = $('quickadd-input').value; const el = $('quickadd-preview'); if (!text.trim()) { el.innerHTML = ''; return; } const p = parseQuickAdd(text); const chips = []; const chip = (label, val) => `${label} ${escapeHtml(val)}`; chips.push(chip('tittel', p.title || '(mangler)')); if (p.category) chips.push(chip('kategori', (CATEGORIES.find(c => c.id === p.category) || {}).name || p.category)); if (p.priority) chips.push(chip('prio', p.priority)); if (p.owner) chips.push(chip('eier', p.owner)); if (p.deadline) chips.push(chip('frist', p.deadline)); if (p.estHours != null) chips.push(chip('est', p.estHours + 't')); if (p.location) chips.push(chip('lokasjon', p.location)); if (p.tags.length) chips.push(chip('tags', p.tags.join(', '))); el.innerHTML = chips.join(''); } async function submitQuickAdd() { const input = $('quickadd-input'); const p = parseQuickAdd(input.value); if (!p.title) { toast('Skriv en tittel først', { kind: 'warn' }); return; } try { const { task } = await api('POST', '/api/tasks', p); upsertTask(task); input.value = ''; renderQuickAddPreview(); renderAll(); toast('Opprettet: ' + p.title.slice(0, 40)); } catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); } } // ---------- Filtrering / sortering ---------- function applyFilters(tasks, { ignoreStatus = false } = {}) { const f = state.filters; return tasks.filter(t => { if (!ignoreStatus) { if (f.status === 'open') { if (t.status === 'done') return false; } else if (f.status !== 'all' && t.status !== f.status) return false; } if (f.category !== 'all' && t.category !== f.category) return false; if (f.priority !== 'all' && t.priority !== f.priority) return false; if (f.owner === '__me__') { if ((t.owner || '') !== state.user) return false; } else if (f.owner === '__unassigned__') { if (t.owner) return false; } else if (f.owner !== 'all') { if ((t.owner || '') !== f.owner) return false; } if (f.search) { const q = f.search.toLowerCase(); const hay = [t.title, t.desc, t.location, t.owner, (t.tags || []).join(' ')].join(' ').toLowerCase(); if (!hay.includes(q)) return false; } return true; }); } function sortTasks(tasks) { const { field, dir } = state.sort; const sign = dir === 'asc' ? 1 : -1; return [...tasks].sort((a, b) => { let av = a[field], bv = b[field]; if (field === 'priority') { av = PRIO_ORDER[av]; bv = PRIO_ORDER[bv]; } if (field === 'deadline') { av = av || '9999'; bv = bv || '9999'; } if (field === 'estHours') { av = av || 0; bv = bv || 0; } if (av == null) av = ''; if (bv == null) bv = ''; if (av < bv) return -1 * sign; if (av > bv) return 1 * sign; return 0; }); } // ---------- Render ---------- function renderAll() { renderHeader(); renderKPIs(); renderCharts(); renderView(); renderActivity(); updateOwnerSelects(); } function renderHeader() { $('user-chip').textContent = state.user; $('user-chip').className = 'user-chip' + (state.canSetName ? ' clickable' : ''); $('anon-banner').className = 'anon-banner' + (state.user === 'anon' && state.canSetName ? ' show' : ''); const others = state.online.filter(u => u !== state.user); $('online-row').innerHTML = others.length ? others.slice(0, 6).map(u => `${escapeHtml(u)}`).join('') + (others.length > 6 ? `+${others.length - 6}` : '') : ''; } function renderKPIs() { const open = state.tasks.filter(t => t.status !== 'done'); const mine = open.filter(t => (t.owner || '') === state.user); const p1 = open.filter(t => t.priority === 'P1').length; const hours = open.reduce((s, t) => s + (t.estHours || 0), 0); const today = todayISO(); const overdue = open.filter(t => t.deadline && t.deadline < today).length; const weekAgo = todayISO(-7); const done7 = state.tasks.filter(t => t.status === 'done' && t.completed && t.completed >= weekAgo).length; $('kpi-open').textContent = open.length; $('kpi-open-sub').textContent = `av ${state.tasks.length} totalt`; $('kpi-mine').textContent = mine.length; const mineHours = mine.reduce((s, t) => s + (t.estHours || 0), 0); $('kpi-mine-sub').textContent = mineHours > 0 ? `≈ ${fmtHours(mineHours)} arbeid` : 'tildelt meg'; $('kpi-p1').textContent = p1; $('kpi-hours').textContent = fmtHours(hours); $('kpi-hours-sub').textContent = hours > 8 ? `≈ ${(hours / 8).toFixed(1)} arbeidsdager` : 'arbeidstimer'; $('kpi-overdue').textContent = overdue; $('kpi-done').textContent = done7; } function fmtHours(h) { return h.toFixed(1).replace(/\.0$/, '') + 't'; } function renderCharts() { if (typeof Chart === 'undefined') { $('chart-row').style.display = 'none'; return; } const open = state.tasks.filter(t => t.status !== 'done'); const byCat = {}; open.forEach(t => { byCat[t.category] = (byCat[t.category] || 0) + 1; }); const catEntries = Object.entries(byCat).sort((a, b) => b[1] - a[1]); $('cat-total').textContent = `${open.length} åpne`; const byPrio = { P1: 0, P2: 0, P3: 0, P4: 0 }; open.forEach(t => { byPrio[t.priority]++; }); const byOwner = {}; open.forEach(t => { const o = t.owner || '(utildelt)'; byOwner[o] = (byOwner[o] || 0) + 1; }); const ownerEntries = Object.entries(byOwner).sort((a, b) => b[1] - a[1]).slice(0, 10); Object.values(charts).forEach(c => c && c.destroy()); const commonFont = { family: "-apple-system, 'Segoe UI', sans-serif", size: 11 }; const barOpts = { indexAxis: 'y', responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => `${ctx.parsed.x} oppgave${ctx.parsed.x === 1 ? '' : 'r'}` } } }, scales: { x: { beginAtZero: true, ticks: { color: '#8b98a9', font: commonFont, stepSize: 1 }, grid: { color: '#2a3340' } }, y: { ticks: { color: '#e6edf3', font: commonFont }, grid: { display: false } } } }; charts.cat = new Chart($('catChart'), { type: 'bar', data: { labels: catEntries.map(([k]) => (CATEGORIES.find(c => c.id === k) || { name: k }).name), datasets: [{ data: catEntries.map(([, v]) => v), backgroundColor: catEntries.map(([k]) => (CAT_COLORS[k] || '#666') + 'CC'), borderColor: catEntries.map(([k]) => CAT_COLORS[k] || '#666'), borderWidth: 1, borderRadius: 4, }] }, options: barOpts, }); charts.prio = new Chart($('prioChart'), { type: 'doughnut', data: { labels: ['P1 Kritisk', 'P2 Høy', 'P3 Middels', 'P4 Lav'], datasets: [{ data: [byPrio.P1, byPrio.P2, byPrio.P3, byPrio.P4], backgroundColor: ['#ff5370CC', '#ff9f43CC', '#ffd93dCC', '#7fb8ffCC'], borderColor: '#1a212b', borderWidth: 2, }] }, options: { responsive: true, maintainAspectRatio: false, cutout: '60%', plugins: { legend: { position: 'right', labels: { color: '#e6edf3', font: commonFont, usePointStyle: true, padding: 12 } }, tooltip: { callbacks: { label: ctx => { const tot = ctx.dataset.data.reduce((a, b) => a + b, 0); const pct = tot ? ((ctx.parsed / tot) * 100).toFixed(0) : 0; return `${ctx.label}: ${ctx.parsed} (${pct}%)`; } } } } } }); charts.owner = new Chart($('ownerChart'), { type: 'bar', data: { labels: ownerEntries.map(([k]) => k), datasets: [{ data: ownerEntries.map(([, v]) => v), backgroundColor: ownerEntries.map(([k]) => k === state.user ? '#4ade80CC' : '#bb86fcCC'), borderColor: ownerEntries.map(([k]) => k === state.user ? '#4ade80' : '#bb86fc'), borderWidth: 1, borderRadius: 4, }] }, options: barOpts, }); } function renderView() { $('view-board').className = state.view === 'board' ? 'active-view' : ''; $('view-table').className = state.view === 'table' ? 'active-view' : ''; $('fg-status').style.display = state.view === 'board' ? 'none' : ''; $('board').style.display = state.view === 'board' ? '' : 'none'; $('table-panel').style.display = state.view === 'table' ? '' : 'none'; if (state.view === 'board') renderBoard(); else renderTable(); flashIds.clear(); } // Felles småbiter for kort og rader function ownerBadge(t) { const cls = !t.owner ? 'unassigned' : (t.owner === state.user ? 'me' : ''); return `${t.owner ? escapeHtml(t.owner) : 'utildelt'}`; } function catBadge(t) { const cat = CATEGORIES.find(c => c.id === t.category); const col = CAT_COLORS[t.category] || '#4fc3f7'; return `${escapeHtml(t.category)}`; } function editingTag(t) { const editors = (state.editingMap[t.id] || []).filter(u => u !== state.user); return editors.length ? `✎ ${escapeHtml(editors[0])}` : ''; } function renderBoard() { const filtered = applyFilters(state.tasks, { ignoreStatus: true }); const board = $('board'); const showEmpty = state.tasks.length === 0; $('empty').style.display = showEmpty ? 'block' : 'none'; $('empty-seed').style.display = showEmpty ? 'block' : 'none'; board.innerHTML = BOARD_COLS.map(status => { let items = filtered.filter(t => t.status === status); let moreNote = ''; if (status === 'done') { items.sort((a, b) => (b.completed || '').localeCompare(a.completed || '')); if (items.length > 15) { moreNote = `