From 6b9dbf8254233681415be5232586ab89188badc1 Mon Sep 17 00:00:00 2001 From: Jon Vanvik Date: Fri, 12 Jun 2026 11:54:22 +0200 Subject: [PATCH] Legg til Node/npm-versjon med sanntidssynk + Debian 13-oppsettsskript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ny app i netops-todo-node/: Express + ws, WebSocket-push i stedet for polling, kanban med drag & drop, kommentarer, aktivitetslogg, optimistisk låsing, quick-add-parsing og presence. 12 integrasjonstester (node:test). setup-debian13.sh setter opp alt på en fersk LXC; README dekker Nginx Proxy Manager-oppsett. PHP-versjonen er uendret (kun handoff-peker). Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + netops-todo-node/.gitignore | 4 + netops-todo-node/CLAUDE.md | 127 ++++ netops-todo-node/README.md | 179 +++++ netops-todo-node/lib/store.js | 319 ++++++++ netops-todo-node/package-lock.json | 853 ++++++++++++++++++++++ netops-todo-node/package.json | 20 + netops-todo-node/public/app.js | 1081 ++++++++++++++++++++++++++++ netops-todo-node/public/index.html | 271 +++++++ netops-todo-node/public/style.css | 354 +++++++++ netops-todo-node/seed.json | 18 + netops-todo-node/server.js | 254 +++++++ netops-todo-node/setup-debian13.sh | 117 +++ netops-todo-node/test/api.test.js | 209 ++++++ netops-todo-server/CLAUDE.md | 5 + 15 files changed, 3814 insertions(+) create mode 100644 .gitignore create mode 100644 netops-todo-node/.gitignore create mode 100644 netops-todo-node/CLAUDE.md create mode 100644 netops-todo-node/README.md create mode 100644 netops-todo-node/lib/store.js create mode 100644 netops-todo-node/package-lock.json create mode 100644 netops-todo-node/package.json create mode 100644 netops-todo-node/public/app.js create mode 100644 netops-todo-node/public/index.html create mode 100644 netops-todo-node/public/style.css create mode 100644 netops-todo-node/seed.json create mode 100644 netops-todo-node/server.js create mode 100755 netops-todo-node/setup-debian13.sh create mode 100644 netops-todo-node/test/api.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..65b2908 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +node_modules/ +data/ diff --git a/netops-todo-node/.gitignore b/netops-todo-node/.gitignore new file mode 100644 index 0000000..e05ae32 --- /dev/null +++ b/netops-todo-node/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data/ +*.log +.DS_Store diff --git a/netops-todo-node/CLAUDE.md b/netops-todo-node/CLAUDE.md new file mode 100644 index 0000000..47bf795 --- /dev/null +++ b/netops-todo-node/CLAUDE.md @@ -0,0 +1,127 @@ +# NetOps To-Do (Node) — Handoff / Project context + +> Egnet som `CLAUDE.md` for fremtidige Claude-sesjoner. Dette er +> videreutviklingen av PHP-versjonen i `../netops-todo-server/` — samme formål +> og datamodell, men ombygd til Node/npm med sanntidssynk og flere +> samarbeidsfunksjoner. PHP-versjonen ligger urørt ved siden av. + +## Hva er dette + +To-do-applikasjon for en network engineer / et lite driftsteam. Kategorier +L1–L4, BGP, peering, security, NMS, automatisering, dokumentasjon, incident, +prosjekt. Hver oppgave har prioritet (P1–P4), status, estimat, lokasjon, frist, +tags, eier, kommentarer og audit-felter. + +Nytt mot PHP-versjonen: + +- **WebSocket-sanntidssynk** (erstatter 60s-polling) + presence + "redigerer nå" +- **Kanban-tavle** med drag & drop, i tillegg til tabellvisning +- **Kommentarer** per oppgave +- **Aktivitetslogg** (hvem gjorde hva, server-side, capped til 400 entries) +- **Optimistisk låsing** — `v`-felt per oppgave, 409 ved konflikt +- **Hurtig-registrering** med token-parsing (`#kat @eier p1 !frist ~timer loc:sted`) +- **Angre sletting**, tastatursnarveier, persistente filtre (localStorage) + +## Filer + +``` +netops-todo-node/ +├── server.js Express + ws. REST-API, WebSocket-hub, auth-oppslag. +├── lib/store.js Datalager: normalisering, validering, aktivitetslogg, +│ atomisk JSON-persistens. Kilden til sannhet for skjema. +├── public/ +│ ├── index.html Markup. Ingen templating — app.js fyller alt. +│ ├── app.js All frontend-logikk (vanilla JS, ingen rammeverk). +│ └── style.css Mørkt tema, samme visuelle identitet som PHP-versjonen. +├── seed.json 16 eksempel-oppgaver. +├── test/api.test.js Integrasjonstester (node:test) — REST + WS ende-til-ende. +├── data/ Opprettes ved kjøring: tasks.json, activity.json, users.json. +└── README.md Installasjon, proxy-oppsett, API-referanse. +``` + +## Arkitektur + +- **Alle mutasjoner via REST** (lett å curl-e/feilsøke). WebSocket (`/ws`) + brukes kun til push: endringer, presence, editing-signaler. +- Én node-prosess eier datakatalogen. Lagring i minnet, skriv til disk er + atomisk (tmp + rename) og serialisert gjennom en promise-kjede i `store.js`. +- Auth ligger utenfor appen: `X-Remote-User`-header > Basic Auth > cookie + (`netops_user`, kan skrus av med `DISABLE_COOKIE_AUTH=1`) > `anon`. + Samme oppslag for HTTP og WS-upgrade (`userFromRequest` i server.js). + +### WebSocket-protokoll (server → klient) + +| type | Innhold | Når | +|---|---|---| +| `hello` | `user`, `online[]`, `editing{}` | Ved tilkobling | +| `sync` | `tasks[]`, `activity[]`, evt. `by` | Ved tilkobling + etter replace/seed | +| `task` | `op: create/update/delete`, `task`/`id`, `by`, `activity` | Etter hver mutasjon | +| `presence` | `online[]` | Når noen kobler til/fra | +| `editing` | `editing{}` = `{taskId: [brukere]}` | Når noen åpner/lukker modal | + +Klient → server: kun `{ type: 'editing', id, active }`. + +### Task-skjema (kanonisk form — `normalizeTask()` i lib/store.js) + +Som PHP-versjonen, pluss: + +```json +{ + "v": 3, + "comments": [{ "id": "c_…", "by": "jon", "at": "ISO", "text": "≤1000" }] +} +``` + +`v` bumpes ved hver felt-oppdatering (ikke ved kommentarer — bevisst, så en +kommentar ikke gir falsk konflikt for noen som redigerer felter samtidig). +Klienten sender `v` den redigerte fra; mismatch → 409 med serverens task i +svaret. Frontend håndterer det i `handleSaveError()`: oppdaterer visningen, +viser varsel, og lar brukeren lagre på nytt (som da overskriver bevisst). + +## Designvalg verdt å vite + +- **Ingen database, ingen frontend-rammeverk, to npm-avhengigheter** + (express, ws). Samme "lett å patche for neste vakthavende"-filosofi som før. +- **Broadcast går til alle, inkludert avsender.** Klienten upserter idempotent + på id, så dobbel oppdatering er harmløs. REST-svaret brukes også, slik at + UI-et fungerer selv om WS er nede (da poller frontend hvert 30s). +- **Delvis update er lov:** `PUT /api/tasks/:id` med bare `{v, status}` — + normalisering merger mot eksisterende. Quick-actions bruker dette. +- **Kommentar-sletting er begrenset til egen bruker** (eneste + autorisasjonsregel i appen — alt annet er åpent for alle innloggede). +- Chart.js fra CDN; frontend degraderer pent (skjuler grafer) uten nett. + +## Sikkerhetsstatus + +Som PHP-versjonen (XSS-escaping i alle render-paths, whitelist-enums, +strenglengde-klamping, brukernavn-regex `[A-Za-z0-9._@-]{1,64}`), pluss: + +- `/api/login` avviser ugyldige navn i stedet for å sanitere stille. +- Cookie settes med `SameSite=Lax` — gir grunnleggende CSRF-vern for + cookie-auth-modus. Bak proxy-auth gjelder samme vurdering som før + (intern bruk OK; offentlig nett → legg på token). +- `express.json({ limit: '2mb' })` begrenser payload (import av store lister). + +## Vanlige utvidelser + +- **Nytt felt per oppgave:** input i modal (index.html) → `saveForm()` og + `parseQuickAdd()` om ønskelig (app.js) → `normalizeTask()` (store.js) → + `diffDetail()` for aktivitetsloggen → `renderCard()`/`renderTable()`. +- **Ny kategori:** `CATEGORIES`/`CAT_COLORS` i app.js + `VALID_CATEGORIES` + i store.js. +- **SQLite:** bytt persistens i `Store` (load/_persist) mot `node:sqlite` — + resten av appen er uberørt siden alt går gjennom Store-metodene. +- **Varsler ved frist:** server har allerede deadline per task; en + `setInterval` i server.js som broadcaster `{type:'due', …}` + Notification + API i frontend er den naturlige veien. + +## Testing-status + +- `npm test`: 12 integrasjonstester (node:test), booter ekte server på + tilfeldig port med temp-datakatalog. Dekker normalisering, versjonskonflikt, + kommentar-autorisasjon, aktivitetslogg, WS-broadcast, persistens på disk, + cookie-auth. Alle grønne per 2026-06-12 (Node 25). +- Manuell røyk-test utført: `/healthz`, statiske filer, seed, state, og + WS-push av REST-endring verifisert ende-til-ende. +- Ikke testet i nettleser med flere samtidige brukere — første ekte test bør + være to nettleservinduer mot `npm start` og flytte kort på tavla. diff --git a/netops-todo-node/README.md b/netops-todo-node/README.md new file mode 100644 index 0000000..b74410d --- /dev/null +++ b/netops-todo-node/README.md @@ -0,0 +1,179 @@ +# NetOps To-Do (Node-versjon) + +Sanntids oppgavetavle for et nettverksdrift-team. Node + Express + WebSockets. +Endringer fra én bruker dukker opp hos alle andre umiddelbart — ingen polling, +ingen manuell refresh. + +## Kom i gang + +```bash +npm install +npm start # → http://localhost:3000 +``` + +Utvikling med auto-restart ved kodeendring: + +```bash +npm run dev +``` + +Tester: + +```bash +npm test +``` + +## Miljøvariabler + +| Variabel | Default | Beskrivelse | +|---|---|---| +| `PORT` | `3000` | HTTP-port | +| `HOST` | `0.0.0.0` | Lytteadresse | +| `DATA_DIR` | `./data` | Katalog for tasks.json / activity.json / users.json | +| `DISABLE_COOKIE_AUTH` | (av) | Sett til `1` for å skru av selvvalgt navn via cookie — da gjelder kun proxy-headere | + +## Autentisering + +Påloggingslogikk er bevisst holdt utenfor appen. Brukernavn hentes fra, +i prioritert rekkefølge: + +1. `X-Remote-User`-header — satt av reverse proxy (oauth2-proxy, Authelia, …) +2. `Authorization: Basic` — Basic Auth terminert i proxy, brukernavnet gjenbrukes +3. `netops_user`-cookie — selvvalgt navn via "Sett navn"-knappen (hjemme/dev-bruk) +4. `anon` + +For team-bruk bak proxy: sett `DISABLE_COOKIE_AUTH=1` så ingen kan velge navn selv. + +## Rask installasjon på Debian 13 (LXC/VM) + +```bash +# Som root i containeren: +apt-get update && apt-get install -y git +git clone /opt/netops +bash /opt/netops/netops-todo-node/setup-debian13.sh +``` + +`setup-debian13.sh` installerer Node fra apt, kjører `npm ci` og setter opp +systemd-tjenesten (`DynamicUser` + `StateDirectory`, data i +`/var/lib/netops-todo`). Kjør skriptet på nytt etter `git pull` for å +oppgradere. Med proxy-auth: `DISABLE_COOKIE_AUTH=1 bash setup-debian13.sh`. + +## Bak reverse proxy + +WebSocket-endepunktet er `/ws` og krever upgrade-støtte i proxyen. +Appen må ligge på **rota av et (sub)domene** (f.eks. `todo.hjemme.lan`) — +frontend bruker absolutte stier, så sti-prefiks (`proxy.lan/todo/`) fungerer ikke. + +### Nginx Proxy Manager + +1. **Hosts → Proxy Hosts → Add Proxy Host** + - *Domain Names:* `todo.dittdomene.no` + - *Scheme:* `http` · *Forward Hostname/IP:* LXC-ens IP · *Forward Port:* `3000` + - **Websockets Support: PÅ** (påkrevd — sanntidssynken bruker `/ws`) + - *Cache Assets:* AV (ellers kan gammel `app.js` serveres etter oppgradering) + - *Block Common Exploits:* valgfritt, fungerer fint sammen med appen +2. **SSL-fanen:** *Request a new SSL Certificate* (Let's Encrypt) + *Force SSL*. + Frontend bytter selv til `wss:` når siden er https. +3. **Auth (valgfritt):** *Access Lists* → lag liste med brukere (Basic Auth) og + knytt den til proxy-hosten. NPM/nginx sender `Authorization`-headeren videre, + og appen bruker brukernavnet derfra — da får hver kollega riktig attribusjon. + Sett i så fall `DISABLE_COOKIE_AUTH=1` på tjenesten. Uten access list velger + folk navn selv i UI-et (cookie). + +**nginx:** + +```nginx +location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header X-Remote-User $remote_user; # ved basic auth i nginx +} +``` + +**Apache** (`mod_proxy_wstunnel`): + +```apache +ProxyPass /ws ws://127.0.0.1:3000/ws +ProxyPassReverse /ws ws://127.0.0.1:3000/ws +ProxyPass / http://127.0.0.1:3000/ +ProxyPassReverse / http://127.0.0.1:3000/ +RequestHeader set X-Remote-User %{REMOTE_USER}s +``` + +## Kjør som tjeneste (systemd) + +```ini +# /etc/systemd/system/netops-todo.service +[Unit] +Description=NetOps To-Do +After=network.target + +[Service] +# DynamicUser + StateDirectory: systemd lager bruker og /var/lib/netops-todo +# med riktige rettigheter automatisk — ingen useradd/chown nødvendig. +DynamicUser=yes +StateDirectory=netops-todo +WorkingDirectory=/opt/netops-todo +ExecStart=/usr/bin/node server.js +Environment=PORT=3000 +# HOST=127.0.0.1 hvis proxyen kjører på samme maskin. Står proxyen i en annen +# container/host: fjern linjen (lytt på alt) og brannmur port 3000 til kun proxyen. +Environment=HOST=127.0.0.1 +Environment=DATA_DIR=/var/lib/netops-todo +Environment=DISABLE_COOKIE_AUTH=1 +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +## Funksjoner + +- **Sanntidssynk** — alle endringer pushes til alle åpne faner via WebSocket +- **Presence** — se hvem som er pålogget akkurat nå +- **Kanban-tavle** med drag & drop mellom statuskolonner, pluss klassisk tabellvisning +- **Hurtig-registrering** med tokens: `Bytt SFP #l1 p2 @me !imorgen ~1h loc:DC-OSL-1` +- **Kommentarer** per oppgave +- **Aktivitetslogg** — hvem gjorde hva, når (sidepanel) +- **Konfliktdeteksjon** — versjonsnummer per oppgave; lagring fra utdatert + versjon avvises med tydelig varsel i stedet for stille overskriving +- **"Redigerer nå"-indikator** — ser om en kollega har samme oppgave åpen +- **Angre sletting** direkte fra toast +- KPI-er, grafer (kategori/prioritet/eier), filtre, sortering, søk +- Eksport/import av JSON, eksempeldata for demo +- Tastatursnarveier: `n` ny · `/` søk · `v` bytt visning · `a` aktivitet · `esc` lukk + +## API + +Alle endepunkt under `/api/`, JSON inn/ut. `v` er versjonsnummer for +optimistisk låsing — send med ved update, få 409 hvis noen andre har lagret. + +| Metode | Endepunkt | Beskrivelse | +|---|---|---| +| GET | `/api/state` | Bruker, oppgaver, aktivitet, kjente brukere, hvem som er online | +| POST | `/api/tasks` | Opprett oppgave (`title` påkrevd) | +| PUT | `/api/tasks/:id` | Oppdater (delvis OK; send `v` for konfliktsjekk) | +| DELETE | `/api/tasks/:id` | Slett | +| POST | `/api/tasks/:id/comments` | Legg til kommentar `{ text }` | +| DELETE | `/api/tasks/:id/comments/:cid` | Slett egen kommentar | +| POST | `/api/replace` | Erstatt hele listen (import) | +| POST | `/api/seed` | Last eksempeldata (kun hvis tomt) | +| POST | `/api/login` | Sett navn-cookie (hvis cookie-auth er på) | +| GET | `/healthz` | Helsesjekk | + +Eksempel: + +```bash +curl -s -X POST localhost:3000/api/tasks \ + -H 'Content-Type: application/json' \ + -H 'X-Remote-User: jon' \ + -d '{"title":"Sjekk BGP-sesjon mot AS2119","category":"BGP","priority":"P1"}' +``` + +## Lagring + +JSON-filer i `DATA_DIR` (atomisk skriv via tmp-fil + rename, serialisert i +prosessen). Én node-prosess per datakatalog. Holder fint til tusenvis av +oppgaver; blir det trangt er SQLite (`node:sqlite`) neste naturlige steg. diff --git a/netops-todo-node/lib/store.js b/netops-todo-node/lib/store.js new file mode 100644 index 0000000..91228d4 --- /dev/null +++ b/netops-todo-node/lib/store.js @@ -0,0 +1,319 @@ +'use strict'; +/** + * NetOps To-Do — datalager. + * + * Holder oppgaver + aktivitetslogg i minnet og persisterer til JSON-filer i + * data/. Skriving er atomisk (tmp-fil + rename) og serialisert gjennom en + * promise-kjede, så parallelle requests ikke ødelegger filene. Antakelsen er + * én node-prosess per datakatalog (samme antakelse som flock i PHP-versjonen). + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const VALID_STATUS = ['todo', 'progress', 'blocked', 'done']; +const VALID_PRIORITIES = ['P1', 'P2', 'P3', 'P4']; +const VALID_CATEGORIES = ['L1', 'L2', 'L3', 'L4', 'BGP', 'PEER', 'SEC', 'NMS', 'AUTO', 'DOC', 'INC', 'PROJ']; + +const STATUS_LABELS = { todo: 'Å gjøre', progress: 'Pågår', blocked: 'Blokkert', done: 'Ferdig' }; + +const MAX_ACTIVITY = 400; +const MAX_COMMENTS = 200; + +class NotFoundError extends Error {} +class ConflictError extends Error { + constructor(msg, task) { super(msg); this.task = task; } +} + +function uid(prefix) { + return prefix + '_' + crypto.randomBytes(5).toString('hex'); +} + +function sanitizeStr(v, max = 500) { + if (typeof v !== 'string') return ''; + // Fjern kontrolltegn (bortsett fra \n \r \t) + v = v.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '').trim(); + return v.length > max ? v.slice(0, max) : v; +} + +function sanitizeTags(v) { + if (!Array.isArray(v)) return []; + const out = []; + for (const t of v) { + const s = sanitizeStr(t, 40); + if (s) out.push(s); + if (out.length >= 20) break; + } + return out; +} + +function sanitizeComments(v) { + if (!Array.isArray(v)) return []; + const out = []; + for (const c of v) { + if (!c || typeof c !== 'object') continue; + const text = sanitizeStr(c.text, 1000); + if (!text) continue; + out.push({ + id: typeof c.id === 'string' ? c.id.replace(/[^a-z0-9_]/gi, '').slice(0, 32) || uid('c') : uid('c'), + by: sanitizeStr(c.by, 64) || 'anon', + at: typeof c.at === 'string' && !isNaN(Date.parse(c.at)) ? c.at : new Date().toISOString(), + text, + }); + if (out.length >= MAX_COMMENTS) break; + } + return out; +} + +function normalizeTask(input, user, existing = null, opts = {}) { + const inp = input && typeof input === 'object' ? input : {}; + const ex = existing || {}; + const t = {}; + + t.id = ex.id + || (typeof inp.id === 'string' ? inp.id.replace(/[^a-z0-9_]/gi, '').slice(0, 32) : '') + || uid('t'); + + t.title = sanitizeStr(inp.title ?? ex.title ?? '', 200); + t.desc = sanitizeStr(inp.desc ?? ex.desc ?? '', 4000); + + const cat = inp.category ?? ex.category ?? 'L3'; + t.category = VALID_CATEGORIES.includes(cat) ? cat : 'L3'; + + const prio = inp.priority ?? ex.priority ?? 'P3'; + t.priority = VALID_PRIORITIES.includes(prio) ? prio : 'P3'; + + const stat = inp.status ?? ex.status ?? 'todo'; + t.status = VALID_STATUS.includes(stat) ? stat : 'todo'; + + t.estHours = inp.estHours != null + ? Math.max(0, Math.min(999, Number(inp.estHours) || 0)) + : (ex.estHours ?? 0); + t.location = sanitizeStr(inp.location ?? ex.location ?? '', 120); + + const dl = inp.deadline ?? ex.deadline ?? ''; + t.deadline = typeof dl === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dl) ? dl : ''; + + t.tags = sanitizeTags(inp.tags ?? ex.tags ?? []); + + const owner = inp.owner != null ? sanitizeStr(inp.owner, 64) : (ex.owner ?? ''); + t.owner = owner; + + t.comments = opts.importComments ? sanitizeComments(inp.comments) : (ex.comments || []); + + // Audit + const now = new Date().toISOString(); + t.created = ex.created || now; + t.createdBy = ex.createdBy || user; + t.updated = now; + t.updatedBy = user; + t.v = (ex.v || 0) + 1; + + // Fullført-tidsstempel + const wasDone = ex.status === 'done'; + if (t.status === 'done' && !wasDone) { + t.completed = now.slice(0, 10); + t.completedBy = user; + } else if (t.status !== 'done') { + t.completed = null; + t.completedBy = null; + } else { + t.completed = ex.completed || now.slice(0, 10); + t.completedBy = ex.completedBy || user; + } + + return t; +} + +// Beskriver hva som endret seg, til aktivitetsloggen. +function diffDetail(before, after) { + const detail = []; + if (before.status !== after.status) { + detail.push(`${STATUS_LABELS[before.status]} → ${STATUS_LABELS[after.status]}`); + } + if (before.priority !== after.priority) detail.push(`${before.priority} → ${after.priority}`); + if ((before.owner || '') !== (after.owner || '')) detail.push(`eier → ${after.owner || 'utildelt'}`); + if (before.deadline !== after.deadline) detail.push(`frist → ${after.deadline || 'ingen'}`); + const silent = []; + if (before.title !== after.title) silent.push('tittel'); + if (before.desc !== after.desc) silent.push('beskrivelse'); + if (before.category !== after.category) silent.push('kategori'); + if (before.location !== after.location) silent.push('lokasjon'); + if (before.estHours !== after.estHours) silent.push('estimat'); + if (JSON.stringify(before.tags) !== JSON.stringify(after.tags)) silent.push('tags'); + if (silent.length) detail.push(silent.join(', ')); + return detail; +} + +class Store { + constructor(dataDir) { + this.dataDir = dataDir; + this.tasksFile = path.join(dataDir, 'tasks.json'); + this.activityFile = path.join(dataDir, 'activity.json'); + this.usersFile = path.join(dataDir, 'users.json'); + this.tasks = []; + this.activity = []; + this.knownUsers = new Set(); + this._writeChain = Promise.resolve(); + } + + load() { + fs.mkdirSync(this.dataDir, { recursive: true }); + this.tasks = this._readJson(this.tasksFile, []); + this.activity = this._readJson(this.activityFile, []); + for (const u of this._readJson(this.usersFile, [])) this.knownUsers.add(u); + for (const t of this.tasks) { + for (const k of ['owner', 'createdBy', 'updatedBy', 'completedBy']) { + if (t[k]) this.knownUsers.add(t[k]); + } + if (!t.v) t.v = 1; + if (!Array.isArray(t.comments)) t.comments = []; + } + } + + _readJson(file, fallback) { + try { + const data = JSON.parse(fs.readFileSync(file, 'utf8')); + return Array.isArray(data) ? data : fallback; + } catch { + return fallback; + } + } + + // Atomisk, serialisert skriv: tmp-fil i samme katalog, så rename. + _persist(file, data) { + this._writeChain = this._writeChain.then(async () => { + const tmp = file + '.tmp'; + await fs.promises.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8'); + await fs.promises.rename(tmp, file); + }).catch(err => { + console.error(`Klarte ikke å skrive ${file}:`, err.message); + }); + return this._writeChain; + } + + _persistTasks() { return this._persist(this.tasksFile, this.tasks); } + _persistActivity() { return this._persist(this.activityFile, this.activity); } + + rememberUser(user) { + if (user && !this.knownUsers.has(user)) { + this.knownUsers.add(user); + this._persist(this.usersFile, [...this.knownUsers].sort()); + } + } + + addActivity(user, action, { taskId = null, title = '', detail = [] } = {}) { + const entry = { + id: uid('a'), + at: new Date().toISOString(), + user, + action, + taskId, + title: sanitizeStr(title, 200), + detail, + }; + this.activity.unshift(entry); + if (this.activity.length > MAX_ACTIVITY) this.activity.length = MAX_ACTIVITY; + this._persistActivity(); + return entry; + } + + list() { return this.tasks; } + + get(id) { + return this.tasks.find(t => t.id === id) || null; + } + + create(input, user) { + // importComments: lar "angre sletting" og import gjenopprette kommentarer + const task = normalizeTask(input, user, null, { importComments: true }); + if (!task.title) throw new Error('title er påkrevd'); + if (this.get(task.id)) task.id = uid('t'); // unngå kollisjon ved gjenbrukt id (angre-slett) + this.tasks.push(task); + this.rememberUser(user); + if (task.owner) this.rememberUser(task.owner); + this._persistTasks(); + const activity = this.addActivity(user, 'create', { taskId: task.id, title: task.title }); + return { task, activity }; + } + + update(id, input, user) { + const existing = this.get(id); + if (!existing) throw new NotFoundError('Fant ikke oppgaven'); + // Optimistisk låsing: klienten sender versjonen den redigerte fra. + if (input && input.v != null && Number(input.v) !== existing.v) { + throw new ConflictError('Oppgaven er endret av noen andre', existing); + } + const updated = normalizeTask(input, user, existing); + const idx = this.tasks.indexOf(existing); + this.tasks[idx] = updated; + this.rememberUser(user); + if (updated.owner) this.rememberUser(updated.owner); + this._persistTasks(); + + let action = 'update'; + if (existing.status !== 'done' && updated.status === 'done') action = 'done'; + else if (existing.status === 'done' && updated.status !== 'done') action = 'reopen'; + const activity = this.addActivity(user, action, { + taskId: id, title: updated.title, detail: diffDetail(existing, updated), + }); + return { task: updated, activity }; + } + + remove(id, user) { + const existing = this.get(id); + if (!existing) throw new NotFoundError('Fant ikke oppgaven'); + this.tasks = this.tasks.filter(t => t.id !== id); + this._persistTasks(); + const activity = this.addActivity(user, 'delete', { taskId: id, title: existing.title }); + return { task: existing, activity }; + } + + addComment(id, text, user) { + const task = this.get(id); + if (!task) throw new NotFoundError('Fant ikke oppgaven'); + const clean = sanitizeStr(text, 1000); + if (!clean) throw new Error('Tom kommentar'); + const comment = { id: uid('c'), by: user, at: new Date().toISOString(), text: clean }; + task.comments.push(comment); + if (task.comments.length > MAX_COMMENTS) task.comments.shift(); + this.rememberUser(user); + this._persistTasks(); + const activity = this.addActivity(user, 'comment', { + taskId: id, title: task.title, detail: [clean.slice(0, 80)], + }); + return { task, comment, activity }; + } + + deleteComment(id, commentId, user) { + const task = this.get(id); + if (!task) throw new NotFoundError('Fant ikke oppgaven'); + const comment = task.comments.find(c => c.id === commentId); + if (!comment) throw new NotFoundError('Fant ikke kommentaren'); + if (comment.by !== user) throw new Error('Kan bare slette egne kommentarer'); + task.comments = task.comments.filter(c => c.id !== commentId); + this._persistTasks(); + return { task }; + } + + replaceAll(arr, user, action = 'import') { + if (!Array.isArray(arr)) throw new Error('Forventet array'); + const out = []; + for (const raw of arr) { + if (!raw || typeof raw !== 'object' || !raw.title) continue; + out.push(normalizeTask(raw, user, null, { importComments: true })); + } + this.tasks = out; + this._persistTasks(); + const activity = this.addActivity(user, action, { detail: [`${out.length} oppgaver`] }); + return { count: out.length, activity }; + } + + seed(seedArr, user) { + if (this.tasks.length > 0) throw new ConflictError('Data finnes allerede', null); + return this.replaceAll(seedArr, user, 'seed'); + } +} + +module.exports = { Store, NotFoundError, ConflictError, normalizeTask, VALID_CATEGORIES, VALID_PRIORITIES, VALID_STATUS }; diff --git a/netops-todo-node/package-lock.json b/netops-todo-node/package-lock.json new file mode 100644 index 0000000..d52a3ad --- /dev/null +++ b/netops-todo-node/package-lock.json @@ -0,0 +1,853 @@ +{ + "name": "netops-todo", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "netops-todo", + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "express": "^4.21.2", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/netops-todo-node/package.json b/netops-todo-node/package.json new file mode 100644 index 0000000..d054e09 --- /dev/null +++ b/netops-todo-node/package.json @@ -0,0 +1,20 @@ +{ + "name": "netops-todo", + "version": "2.0.0", + "description": "NetOps To-Do — sanntids oppgavetavle for nettverksdrift (Node + Express + WebSockets)", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js", + "test": "node --test \"test/**/*.test.js\"" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "express": "^4.21.2", + "ws": "^8.18.0" + }, + "license": "MIT", + "private": true +} diff --git a/netops-todo-node/public/app.js b/netops-todo-node/public/app.js new file mode 100644 index 0000000..8eba751 --- /dev/null +++ b/netops-todo-node/public/app.js @@ -0,0 +1,1081 @@ +'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 = `
+ ${items.length - 15} eldre ferdige (se tabell-visning)
`; + items = items.slice(0, 15); + } + } else { + items.sort((a, b) => + (PRIO_ORDER[a.priority] - PRIO_ORDER[b.priority]) + || (a.deadline || '9999').localeCompare(b.deadline || '9999')); + } + const est = items.reduce((s, t) => s + (t.estHours || 0), 0); + return ` +
+
+

${STATUS_LABELS[status]}

+ ${items.length}${est > 0 && status !== 'done' ? ` · ${fmtHours(est)}` : ''} +
+
+ ${items.map(renderCard).join('')} + ${moreNote} +
+
`; + }).join(''); + + bindBoardDnD(); +} + +function renderCard(t) { + const dl = formatDeadline(t.deadline); + const flash = flashIds.has(t.id) ? ' flash' : ''; + const doneCls = t.status === 'done' ? ' done-card' : ''; + const tags = (t.tags || []).slice(0, 4).map(tag => `#${escapeHtml(tag)}`).join(' '); + return ` +
+ ${editingTag(t)} +
+ ${t.priority} + ${catBadge(t)} + ${t.location ? `${escapeHtml(t.location)}` : ''} +
+
${escapeHtml(t.title)}
+
+ ${ownerBadge(t)} + ${t.deadline ? `${dl.text}` : ''} + ${t.estHours ? `~${t.estHours}t` : ''} + ${t.comments && t.comments.length ? `💬 ${t.comments.length}` : ''} + ${tags} +
+
`; +} + +function bindBoardDnD() { + document.querySelectorAll('.card[draggable]').forEach(card => { + card.addEventListener('dragstart', e => { + e.dataTransfer.setData('text/plain', card.dataset.id); + e.dataTransfer.effectAllowed = 'move'; + card.classList.add('dragging'); + }); + card.addEventListener('dragend', () => card.classList.remove('dragging')); + }); + document.querySelectorAll('.board-col').forEach(col => { + col.addEventListener('dragover', e => { e.preventDefault(); col.classList.add('drag-over'); }); + col.addEventListener('dragleave', () => col.classList.remove('drag-over')); + col.addEventListener('drop', e => { + e.preventDefault(); + col.classList.remove('drag-over'); + const id = e.dataTransfer.getData('text/plain'); + moveTask(id, col.dataset.status); + }); + }); +} + +async function moveTask(id, status) { + const t = findTask(id); + if (!t || t.status === status) return; + const prev = t.status; + t.status = status; // optimistisk — broadcast/svar korrigerer + renderAll(); + try { + const { task } = await api('PUT', `/api/tasks/${id}`, { ...t, status, v: t.v }); + upsertTask(task); + renderAll(); + } catch (e) { + t.status = prev; + renderAll(); + handleSaveError(e, id); + } +} + +function renderTable() { + const filtered = sortTasks(applyFilters(state.tasks)); + const tbody = $('tbl-body'); + $('tbl-count').textContent = filtered.length; + + const showEmpty = filtered.length === 0; + $('empty').style.display = showEmpty ? 'block' : 'none'; + $('empty-seed').style.display = state.tasks.length === 0 ? 'block' : 'none'; + if (showEmpty) { tbody.innerHTML = ''; return; } + + tbody.innerHTML = filtered.map(t => { + const dl = formatDeadline(t.deadline); + const flash = flashIds.has(t.id) ? ' flash' : ''; + const tags = (t.tags || []).map(tag => `#${escapeHtml(tag)}`).join(' '); + const editors = (state.editingMap[t.id] || []).filter(u => u !== state.user); + return ` + + ${t.priority} + +
${escapeHtml(t.title)} + ${t.comments && t.comments.length ? `💬${t.comments.length}` : ''} + ${editors.length ? `✎ ${escapeHtml(editors.join(', '))} redigerer` : ''} +
+ ${t.desc ? `
${escapeHtml(t.desc.slice(0, 160))}${t.desc.length > 160 ? '…' : ''}
` : ''} + ${tags ? `
${tags}
` : ''} + + ${catBadge(t)} + ${STATUS_LABELS[t.status] || t.status} + ${ownerBadge(t)} + ${escapeHtml(t.location || '—')} + ${t.estHours || '—'} + ${dl.text} + +
+ ${t.status !== 'done' + ? `` + : ``} + + ${!t.owner ? `` : ''} + +
+ + `; + }).join(''); +} + +function renderActivity() { + const body = $('drawer-body'); + if (!state.activity.length) { + body.innerHTML = '
Ingen aktivitet ennå.
'; + return; + } + body.innerHTML = state.activity.slice(0, 150).map(a => { + const meta = ACT_META[a.action] || { icon: '·', verb: a.action }; + const titleHtml = a.taskId && findTask(a.taskId) + ? `${escapeHtml(a.title)}` + : `${escapeHtml(a.title || '')}`; + const detail = (a.detail || []).join(' · '); + return ` +
+
+ ${meta.icon} + ${escapeHtml(a.user)} ${meta.verb} ${titleHtml} +
+ ${detail ? `
${escapeHtml(detail)}
` : ''} +
${relTime(a.at)}
+
`; + }).join(''); +} + +function updateOwnerSelects() { + const users = new Set(state.knownUsers); + state.tasks.forEach(t => { if (t.owner) users.add(t.owner); }); + state.online.forEach(u => users.add(u)); + const sorted = [...users].sort(); + + const sel = $('f-owner'); + const current = state.filters.owner; + sel.innerHTML = '' + + sorted.map(u => ``).join(''); + const ok = [...sel.options].some(o => o.value === current); + sel.value = ok ? current : 'all'; + if (!ok) state.filters.owner = 'all'; + + const msel = $('f-owner-select'); + msel.innerHTML = '' + + sorted.map(u => ``).join(''); +} + +// ---------- Hurtighandlinger (rad/kort) ---------- +async function quickStatus(id, status) { + const t = findTask(id); + if (!t) return; + try { + const { task } = await api('PUT', `/api/tasks/${id}`, { ...t, status, v: t.v }); + upsertTask(task); + renderAll(); + } catch (e) { handleSaveError(e, id); } +} +window.markDone = id => quickStatus(id, 'done'); +window.reopenTask = id => quickStatus(id, 'todo'); + +window.claimTask = async function (id) { + const t = findTask(id); + if (!t) return; + try { + const { task } = await api('PUT', `/api/tasks/${id}`, { ...t, owner: state.user, v: t.v }); + upsertTask(task); + renderAll(); + toast(`Du eier nå "${task.title.slice(0, 30)}${task.title.length > 30 ? '…' : ''}"`); + } catch (e) { handleSaveError(e, id); } +}; + +window.deleteTask = async function (id) { + const t = findTask(id); + if (!t) return; + const copy = { ...t }; + try { + await api('DELETE', `/api/tasks/${id}`); + state.tasks = state.tasks.filter(x => x.id !== id); + renderAll(); + toast(`Slettet "${copy.title.slice(0, 30)}"`, { + ttl: 7000, + action: { label: 'Angre', fn: async () => { + try { + const { task } = await api('POST', '/api/tasks', copy); + upsertTask(task); + renderAll(); + toast('Gjenopprettet'); + } catch (e) { toast('Kunne ikke gjenopprette: ' + e.message, { kind: 'err' }); } + } }, + }); + } catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); } +}; + +// 409-håndtering: ta inn serverens versjon og be brukeren prøve igjen. +function handleSaveError(e, id) { + if (e.status === 409 && e.data && e.data.task) { + upsertTask(e.data.task); + renderAll(); + const by = e.data.task.updatedBy || 'noen andre'; + if (state.editingId === id) { + state.editingBaseV = e.data.task.v; + updateModalWarning(by, true); + } else { + toast(`Endret av ${by} i mellomtiden — visningen er oppdatert, prøv igjen`, { kind: 'warn', ttl: 6000 }); + } + } else { + toast('Feil: ' + e.message, { kind: 'err' }); + } +} + +// ---------- Modal ---------- +window.openEdit = function (id) { + const t = findTask(id); + if (!t) return; + state.editingId = id; + state.editingBaseV = t.v; + $('modal-title').textContent = 'Rediger oppgave'; + $('f-title').value = t.title; + $('f-desc').value = t.desc || ''; + $('f-cat').value = t.category; + $('f-prio').value = t.priority; + $('f-stat').value = t.status; + $('f-est').value = t.estHours || ''; + $('f-owner-input').value = t.owner || ''; + $('f-owner-select').value = ''; + $('f-loc').value = t.location || ''; + $('f-dl').value = t.deadline || ''; + $('f-tags').value = (t.tags || []).join(', '); + $('modal-delete').style.display = 'inline-flex'; + $('comments-section').style.display = ''; + $('modal-audit').textContent = `opprettet ${relTime(t.created)} av ${t.createdBy} · sist endret ${relTime(t.updated)} av ${t.updatedBy}`; + renderComments(t); + updateModalWarning(); + $('modal').classList.add('open'); + sendWS({ type: 'editing', id, active: true }); +}; + +function openNew(prefill = {}) { + state.editingId = null; + state.editingBaseV = null; + $('modal-title').textContent = 'Ny oppgave'; + $('f-title').value = prefill.title || ''; + $('f-desc').value = ''; + $('f-cat').value = prefill.category || CATEGORIES[0].id; + $('f-prio').value = prefill.priority || 'P2'; + $('f-stat').value = 'todo'; + $('f-est').value = ''; + $('f-owner-input').value = state.user !== 'anon' ? state.user : ''; + $('f-owner-select').value = ''; + $('f-loc').value = ''; + $('f-dl').value = ''; + $('f-tags').value = ''; + $('modal-delete').style.display = 'none'; + $('comments-section').style.display = 'none'; + $('modal-audit').textContent = ''; + updateModalWarning(); + $('modal').classList.add('open'); + setTimeout(() => $('f-title').focus(), 50); +} + +function closeModal() { + if (state.editingId) sendWS({ type: 'editing', id: state.editingId, active: false }); + $('modal').classList.remove('open'); + state.editingId = null; + state.editingBaseV = null; +} + +// Varsellinje i modal: andre redigerer / noen lagret nettopp / 409. +function updateModalWarning(savedBy = null, conflict = false) { + const el = $('modal-warning'); + if (!$('modal').classList.contains('open')) { el.className = 'modal-warning'; return; } + const msgs = []; + if (state.editingId) { + const others = (state.editingMap[state.editingId] || []).filter(u => u !== state.user); + if (others.length) msgs.push(`✎ ${others.join(', ')} redigerer også denne oppgaven nå.`); + if (conflict) msgs.push(`⚠ ${savedBy} lagret en nyere versjon. Sjekk feltene — lagrer du igjen, overskriver du.`); + else if (savedBy) msgs.push(`⚠ ${savedBy} lagret nettopp endringer på denne oppgaven.`); + } + el.textContent = msgs.join(' '); + el.className = 'modal-warning' + (msgs.length ? ' show' : ''); +} + +async function saveForm() { + const title = $('f-title').value.trim(); + if (!title) { toast('Tittel er påkrevd', { kind: 'warn' }); return; } + const data = { + title, + desc: $('f-desc').value.trim(), + category: $('f-cat').value, + priority: $('f-prio').value, + status: $('f-stat').value, + estHours: parseFloat($('f-est').value) || 0, + owner: $('f-owner-input').value.trim(), + location: $('f-loc').value.trim(), + deadline: $('f-dl').value, + tags: $('f-tags').value.split(',').map(s => s.trim()).filter(Boolean), + }; + try { + let task; + if (state.editingId) { + data.v = state.editingBaseV; + ({ task } = await api('PUT', `/api/tasks/${state.editingId}`, data)); + toast('Lagret'); + } else { + ({ task } = await api('POST', '/api/tasks', data)); + toast('Opprettet'); + } + upsertTask(task); + closeModal(); + renderAll(); + } catch (e) { handleSaveError(e, state.editingId); } +} + +async function deleteFromModal() { + if (!state.editingId) return; + const id = state.editingId; + closeModal(); + window.deleteTask(id); +} + +// ---------- Kommentarer ---------- +function renderComments(t) { + const list = $('comments-list'); + $('comment-count').textContent = t.comments && t.comments.length ? `(${t.comments.length})` : ''; + if (!t.comments || !t.comments.length) { + list.innerHTML = '
Ingen kommentarer ennå.
'; + return; + } + list.innerHTML = t.comments.map(c => ` +
+
+ ${escapeHtml(c.by)} + ${relTime(c.at)} +
+
${escapeHtml(c.text)}
+ ${c.by === state.user ? `` : ''} +
`).join(''); +} + +async function sendComment() { + if (!state.editingId) return; + const input = $('comment-input'); + const text = input.value.trim(); + if (!text) return; + try { + const { task } = await api('POST', `/api/tasks/${state.editingId}/comments`, { text }); + upsertTask(task); + input.value = ''; + renderComments(task); + renderAll(); + } catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); } +} + +window.deleteComment = async function (taskId, commentId) { + try { + const { task } = await api('DELETE', `/api/tasks/${taskId}/comments/${commentId}`); + upsertTask(task); + renderComments(task); + renderAll(); + } catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); } +}; + +// ---------- Navnevalg (cookie-auth) ---------- +async function setName() { + const name = prompt('Brukernavn (a-z, 0-9, . _ @ -):', state.user === 'anon' ? '' : state.user); + if (!name) return; + try { + await api('POST', '/api/login', { name: name.trim() }); + location.reload(); + } catch (e) { toast(e.message, { kind: 'err' }); } +} + +// ---------- Import / eksport / seed ---------- +function exportJSON() { + const blob = new Blob([JSON.stringify(state.tasks, null, 2)], { type: 'application/json' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `netops-todo-${todayISO()}.json`; + a.click(); + URL.revokeObjectURL(a.href); +} + +async function importJSON(file) { + try { + const data = JSON.parse(await file.text()); + if (!Array.isArray(data)) throw new Error('Forventet array'); + if (!confirm(`Importere ${data.length} oppgaver? Dette erstatter alt på serveren for alle brukere.`)) return; + await api('POST', '/api/replace', data); + toast('Importert'); + } catch (e) { toast('Kunne ikke importere: ' + e.message, { kind: 'err' }); } +} + +async function seedSamples() { + try { + const { count } = await api('POST', '/api/seed'); + toast(`${count} eksempel-oppgaver lastet`); + } catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); } +} + +// ---------- Bindings ---------- +function bindUI() { + // Visning + $('view-board').addEventListener('click', () => { state.view = 'board'; savePrefs(); renderView(); }); + $('view-table').addEventListener('click', () => { state.view = 'table'; savePrefs(); renderView(); }); + + // Filtre + const f = state.filters; + $('f-status').value = f.status; $('f-category').value = f.category; + $('f-priority').value = f.priority; $('f-search').value = f.search; + $('f-status').addEventListener('change', e => { f.status = e.target.value; savePrefs(); renderView(); }); + $('f-category').addEventListener('change', e => { f.category = e.target.value; savePrefs(); renderView(); }); + $('f-priority').addEventListener('change', e => { f.priority = e.target.value; savePrefs(); renderView(); }); + $('f-owner').addEventListener('change', e => { f.owner = e.target.value; savePrefs(); renderView(); }); + $('f-search').addEventListener('input', e => { f.search = e.target.value; savePrefs(); renderView(); }); + $('clearFilters').addEventListener('click', () => { + state.filters = { status: 'open', category: 'all', priority: 'all', owner: 'all', search: '' }; + $('f-status').value = 'open'; $('f-category').value = 'all'; + $('f-priority').value = 'all'; $('f-owner').value = 'all'; $('f-search').value = ''; + savePrefs(); renderView(); + }); + + // Quick add + $('quickadd-input').addEventListener('input', renderQuickAddPreview); + $('quickadd-input').addEventListener('keydown', e => { if (e.key === 'Enter') submitQuickAdd(); }); + $('quickadd-btn').addEventListener('click', submitQuickAdd); + + // Modal + $('newTaskBtn').addEventListener('click', () => openNew()); + $('modal-close').addEventListener('click', closeModal); + $('modal-cancel').addEventListener('click', closeModal); + $('modal-save').addEventListener('click', saveForm); + $('modal-delete').addEventListener('click', deleteFromModal); + $('modal').addEventListener('click', e => { if (e.target.id === 'modal') closeModal(); }); + $('f-owner-select').addEventListener('change', e => { + if (e.target.value) { $('f-owner-input').value = e.target.value; e.target.value = ''; } + }); + $('comment-send').addEventListener('click', sendComment); + $('comment-input').addEventListener('keydown', e => { if (e.key === 'Enter') sendComment(); }); + + // Aktivitets-skuff + $('activityBtn').addEventListener('click', () => $('drawer').classList.toggle('open')); + $('drawer-close').addEventListener('click', () => $('drawer').classList.remove('open')); + + // Import / eksport / seed / navn + $('exportBtn').addEventListener('click', exportJSON); + $('importBtn').addEventListener('click', () => $('importFile').click()); + $('importFile').addEventListener('change', e => { + if (e.target.files[0]) importJSON(e.target.files[0]); + e.target.value = ''; + }); + $('seedBtn').addEventListener('click', seedSamples); + $('setNameBtn').addEventListener('click', setName); + $('user-chip').addEventListener('click', () => { if (state.canSetName) setName(); }); + + // Sortering (tabell) + document.querySelectorAll('thead th[data-sort]').forEach(th => { + th.addEventListener('click', () => { + const field = th.dataset.sort; + if (state.sort.field === field) { + state.sort.dir = state.sort.dir === 'asc' ? 'desc' : 'asc'; + } else { + state.sort.field = field; + state.sort.dir = field === 'priority' || field === 'deadline' ? 'asc' : 'desc'; + } + savePrefs(); renderView(); + }); + }); + + // Tastatursnarveier + document.addEventListener('keydown', e => { + const tag = e.target.tagName; + const typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; + if (e.key === 'Escape') { closeModal(); $('drawer').classList.remove('open'); return; } + if (typing) return; + if (e.key === 'n') { e.preventDefault(); openNew(); } + if (e.key === '/') { e.preventDefault(); $('f-search').focus(); } + if (e.key === 'v') { + state.view = state.view === 'board' ? 'table' : 'board'; + savePrefs(); renderView(); + } + if (e.key === 'a') $('drawer').classList.toggle('open'); + }); + + // Hent ferskt når fanen blir synlig igjen (mobil/sove-PC) + document.addEventListener('visibilitychange', () => { + if (!document.hidden && !state.wsOk) refreshState(); + }); +} + +function populateCategorySelects() { + const filterSel = $('f-category'); + const formSel = $('f-cat'); + CATEGORIES.forEach(c => { + filterSel.insertAdjacentHTML('beforeend', ``); + formSel.insertAdjacentHTML('beforeend', ``); + }); +} + +// ---------- Init ---------- +async function init() { + loadPrefs(); + populateCategorySelects(); + bindUI(); + await refreshState(); + connectWS(); + // Oppdater relative tidsstempler i aktivitetsfeeden jevnlig + setInterval(() => { if ($('drawer').classList.contains('open')) renderActivity(); }, 60000); +} + +init(); diff --git a/netops-todo-node/public/index.html b/netops-todo-node/public/index.html new file mode 100644 index 0000000..57d4132 --- /dev/null +++ b/netops-todo-node/public/index.html @@ -0,0 +1,271 @@ + + + + + +NetOps To-Do · Network Engineer + + + + +
+ +
+
+

NetOps To-Do

+
kobler til …
+
+
+ + + + + + + +
+
+ +
+ Du er pålogget som anon. Velg et navn så kollegene ser hvem som gjør hva. + +
+ +
+
+ + +
+
+
#kategori-eller-tag · @eier (@me) · p1–p4 · !frist (2026-06-20, +3d, idag, imorgen) · ~timer · loc:sted · resten blir tittel
+
+ +
+
+
Åpne oppgaver
+
0
+
av 0 totalt
+
+
+
Mine åpne
+
0
+
tildelt meg
+
+
+
P1 Kritisk
+
0
+
umiddelbar handling
+
+
+
Est. tid igjen
+
0t
+
arbeidstimer
+
+
+
Forfalte
+
0
+
over frist
+
+
+
Ferdig (7d)
+
0
+
siste 7 dager
+
+
+ +
+
+

Åpne oppgaver per kategori

+
+
+
+

Fordeling etter prioritet

+
+
+
+

Åpne oppgaver per eier

+
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + + + + + + +
+ Sanntidssynk via WebSocket · snarveier: + n ny · + / søk · + v tavle/tabell · + a aktivitet · + esc lukk +
+
+ + + + + +
+ + + + diff --git a/netops-todo-node/public/style.css b/netops-todo-node/public/style.css new file mode 100644 index 0000000..69bb776 --- /dev/null +++ b/netops-todo-node/public/style.css @@ -0,0 +1,354 @@ +:root { + --bg: #0f1419; + --bg-soft: #151b23; + --card: #1a212b; + --card-hover: #202836; + --border: #2a3340; + --border-strong: #3a4555; + --text: #e6edf3; + --text-dim: #8b98a9; + --text-faint: #5a6878; + --accent: #4fc3f7; + --accent-soft: rgba(79, 195, 247, 0.12); + --p1: #ff5370; + --p2: #ff9f43; + --p3: #ffd93d; + --p4: #7fb8ff; + --ok: #4ade80; + --warn: #ff9f43; + --err: #ff5370; + --purple: #bb86fc; + --radius: 8px; + --gap: 14px; + --mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; +} +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { + background: var(--bg); color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; line-height: 1.5; min-height: 100vh; +} +.container { max-width: 1600px; margin: 0 auto; padding: 20px; } + +/* ---------- Header ---------- */ +header { + display: flex; justify-content: space-between; align-items: center; + flex-wrap: wrap; gap: 12px; margin-bottom: 16px; + padding-bottom: 16px; border-bottom: 1px solid var(--border); +} +header h1 { font-size: 22px; font-weight: 600; display: flex; align-items: center; gap: 10px; } +#conn-dot { + width: 10px; height: 10px; border-radius: 50%; + background: var(--text-faint); transition: background 0.3s, box-shadow 0.3s; +} +#conn-dot.live { background: var(--ok); box-shadow: 0 0 12px var(--ok); animation: pulse 2s infinite; } +#conn-dot.off { background: var(--err); box-shadow: 0 0 8px var(--err); } +@keyframes pulse { 0%,100% {opacity:1;} 50% {opacity:0.5;} } +header .sub { color: var(--text-dim); font-size: 12px; font-family: var(--mono); } +.header-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } + +.user-chip { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 10px; background: var(--accent-soft); color: var(--accent); + border: 1px solid rgba(79,195,247,0.25); border-radius: 12px; + font-family: var(--mono); font-size: 12px; cursor: default; +} +.user-chip::before { content: "●"; } +.user-chip.clickable { cursor: pointer; } +.user-chip.clickable:hover { background: rgba(79,195,247,0.22); } + +.online-row { display: inline-flex; gap: 4px; align-items: center; } +.online-chip { + font-family: var(--mono); font-size: 11px; padding: 2px 8px; + background: rgba(74,222,128,0.08); color: var(--ok); + border: 1px solid rgba(74,222,128,0.25); border-radius: 10px; +} +.online-chip.me { opacity: 0.6; } + +button, .btn { + background: var(--card); color: var(--text); border: 1px solid var(--border); + padding: 8px 14px; border-radius: 6px; font-size: 13px; font-weight: 500; + cursor: pointer; transition: all 0.15s; + display: inline-flex; align-items: center; gap: 6px; +} +button:hover, .btn:hover { background: var(--card-hover); border-color: var(--border-strong); } +button.primary { background: var(--accent); color: #06151f; border-color: var(--accent); font-weight: 600; } +button.primary:hover { background: #7dd3fc; border-color: #7dd3fc; } +button.danger { background: transparent; color: var(--err); border-color: rgba(255,83,112,0.3); } +button.danger:hover { background: rgba(255,83,112,0.1); border-color: var(--err); } +button:disabled { opacity: 0.5; cursor: not-allowed; } +button.active-view { background: var(--accent-soft); color: var(--accent); border-color: rgba(79,195,247,0.4); } + +/* ---------- Quick add ---------- */ +.quickadd { + display: flex; flex-direction: column; gap: 6px; + margin-bottom: var(--gap); padding: 12px 16px; + background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); +} +.quickadd-row { display: flex; gap: 8px; } +.quickadd input { + flex: 1; background: var(--bg-soft); color: var(--text); + border: 1px solid var(--border); padding: 9px 12px; border-radius: 6px; + font-size: 13px; font-family: var(--mono); +} +.quickadd input:focus { outline: none; border-color: var(--accent); } +.quickadd-hint { font-size: 11px; color: var(--text-faint); font-family: var(--mono); } +.quickadd-preview { display: flex; gap: 6px; flex-wrap: wrap; min-height: 0; } +.qa-chip { + font-family: var(--mono); font-size: 11px; padding: 2px 8px; border-radius: 4px; + background: var(--bg-soft); border: 1px solid var(--border); color: var(--text-dim); +} +.qa-chip b { color: var(--accent); font-weight: 600; } + +/* ---------- KPI ---------- */ +.kpi-row { + display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: var(--gap); margin-bottom: var(--gap); +} +.kpi { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); + padding: 14px 16px; position: relative; overflow: hidden; } +.kpi::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); } +.kpi.kpi-p1::before { background: var(--p1); } +.kpi.kpi-warn::before { background: var(--warn); } +.kpi.kpi-ok::before { background: var(--ok); } +.kpi.kpi-me::before { background: var(--purple); } +.kpi-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-dim); margin-bottom: 4px; font-weight: 600; } +.kpi-value { font-size: 24px; font-weight: 700; font-family: var(--mono); line-height: 1.1; } +.kpi-sub { font-size: 12px; color: var(--text-dim); margin-top: 3px; } + +/* ---------- Charts ---------- */ +.chart-row { display: grid; grid-template-columns: 1.2fr 1fr 1fr; gap: var(--gap); margin-bottom: var(--gap); } +.panel { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; } +.panel h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-dim); + margin-bottom: 14px; font-weight: 600; display: flex; justify-content: space-between; align-items: center; } +.chart-wrap { position: relative; height: 200px; } +.charts-toggle-row { margin-bottom: var(--gap); display: none; } + +/* ---------- Filters ---------- */ +.filters { + display: flex; gap: 10px; align-items: center; flex-wrap: wrap; + margin-bottom: var(--gap); padding: 12px 16px; + background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); +} +.filter-group { display: flex; align-items: center; gap: 6px; } +.filter-group label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; } +select, input[type="text"], input[type="number"], input[type="date"], textarea { + background: var(--bg-soft); color: var(--text); border: 1px solid var(--border); + padding: 7px 10px; border-radius: 5px; font-size: 13px; font-family: inherit; +} +select:focus, input:focus, textarea:focus { outline: none; border-color: var(--accent); } +.search-input { flex: 1; min-width: 180px; } +.view-toggle { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } +.view-toggle button { border: none; border-radius: 0; padding: 7px 14px; } +.view-toggle button + button { border-left: 1px solid var(--border); } + +/* ---------- Kanban board ---------- */ +.board { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--gap); align-items: start; } +.board-col { + background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--radius); + min-height: 200px; display: flex; flex-direction: column; + transition: border-color 0.15s, background 0.15s; +} +.board-col.drag-over { border-color: var(--accent); background: rgba(79,195,247,0.04); } +.board-col-head { + display: flex; justify-content: space-between; align-items: baseline; + padding: 12px 14px 8px; position: sticky; top: 0; +} +.board-col-head h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.8px; font-weight: 600; } +.board-col-head .col-meta { font-family: var(--mono); font-size: 11px; color: var(--text-faint); } +.board-col[data-status="todo"] .board-col-head h3 { color: var(--p4); } +.board-col[data-status="progress"] .board-col-head h3 { color: var(--warn); } +.board-col[data-status="blocked"] .board-col-head h3 { color: var(--err); } +.board-col[data-status="done"] .board-col-head h3 { color: var(--ok); } +.board-col-body { display: flex; flex-direction: column; gap: 8px; padding: 4px 10px 12px; min-height: 60px; flex: 1; } +.board-more { text-align: center; font-size: 11px; color: var(--text-faint); font-family: var(--mono); padding: 4px; } + +.card { + background: var(--card); border: 1px solid var(--border); border-radius: 6px; + padding: 10px 12px; cursor: grab; transition: border-color 0.15s, box-shadow 0.15s; + position: relative; +} +.card:hover { border-color: var(--border-strong); box-shadow: 0 4px 12px rgba(0,0,0,0.3); } +.card.dragging { opacity: 0.4; cursor: grabbing; } +.card.flash { animation: cardflash 1.2s; } +@keyframes cardflash { + 0% { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } + 100% { border-color: var(--border); box-shadow: none; } +} +.card-top { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; flex-wrap: wrap; } +.card-title { font-weight: 500; font-size: 13px; cursor: pointer; line-height: 1.35; } +.card-title:hover { color: var(--accent); } +.card.done-card .card-title { text-decoration: line-through; color: var(--text-faint); } +.card-meta { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 7px; align-items: center; + font-size: 11px; font-family: var(--mono); color: var(--text-faint); } +.card-editing { + position: absolute; top: -8px; right: 8px; font-size: 10px; font-family: var(--mono); + background: var(--warn); color: #1a1206; padding: 1px 7px; border-radius: 8px; font-weight: 600; +} + +/* ---------- Badges ---------- */ +.badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; border-radius: 4px; + font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.4px; white-space: nowrap; } +.prio-P1 { background: rgba(255,83,112,0.15); color: var(--p1); border: 1px solid rgba(255,83,112,0.3); } +.prio-P2 { background: rgba(255,159,67,0.15); color: var(--p2); border: 1px solid rgba(255,159,67,0.3); } +.prio-P3 { background: rgba(255,217,61,0.15); color: var(--p3); border: 1px solid rgba(255,217,61,0.3); } +.prio-P4 { background: rgba(127,184,255,0.15); color: var(--p4); border: 1px solid rgba(127,184,255,0.3); } + +.status-badge { padding: 3px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; white-space: nowrap; } +.status-todo { background: rgba(127,184,255,0.12); color: #7fb8ff; } +.status-progress { background: rgba(255,159,67,0.15); color: var(--warn); } +.status-blocked { background: rgba(255,83,112,0.15); color: var(--err); } +.status-done { background: rgba(74,222,128,0.12); color: var(--ok); } + +.cat-badge { font-family: var(--mono); font-size: 10px; padding: 1px 6px; border-radius: 4px; + background: var(--accent-soft); color: var(--accent); border: 1px solid rgba(79,195,247,0.25); white-space: nowrap; } + +.owner-badge { display:inline-flex; align-items:center; gap:5px; font-family: var(--mono); font-size: 11px; + padding: 1px 7px; background: rgba(187,134,252,0.1); color: #d4b0ff; + border: 1px solid rgba(187,134,252,0.25); border-radius: 4px; white-space: nowrap; } +.owner-badge.me { background: rgba(74,222,128,0.1); color: var(--ok); border-color: rgba(74,222,128,0.3); } +.owner-badge.unassigned { background: rgba(139,152,169,0.1); color: var(--text-dim); border-color: var(--border); } + +.time-cell { font-family: var(--mono); font-size: 12px; color: var(--text-dim); white-space: nowrap; } +.time-cell.overdue { color: var(--err); font-weight: 600; } +.time-cell.soon { color: var(--warn); } +.tag-chip { font-family: var(--mono); font-size: 11px; color: var(--text-faint); } + +/* ---------- Table ---------- */ +.table-panel { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.table-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 18px; border-bottom: 1px solid var(--border); } +.table-head h3 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; } +.table-head .count { color: var(--text-dim); font-family: var(--mono); font-size: 12px; margin-left: 8px; } +.table-scroll { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; font-size: 13px; } +thead th { + text-align: left; padding: 10px 14px; background: var(--bg-soft); color: var(--text-dim); + font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; + cursor: pointer; user-select: none; white-space: nowrap; border-bottom: 1px solid var(--border); +} +thead th:hover { color: var(--text); } +tbody td { padding: 11px 14px; border-bottom: 1px solid var(--border); vertical-align: top; } +tbody tr { transition: background 0.1s; } +tbody tr:hover { background: var(--card-hover); } +tbody tr.flash { animation: rowflash 1.2s; } +@keyframes rowflash { 0% { background: rgba(79,195,247,0.12); } 100% { background: transparent; } } +tbody tr.status-done td { color: var(--text-faint); } +tbody tr.status-done .task-title { text-decoration: line-through; } +.task-title { font-weight: 500; color: var(--text); cursor: pointer; } +.task-title:hover { color: var(--accent); } +.task-desc { color: var(--text-dim); font-size: 12px; margin-top: 2px; max-width: 420px; } +.row-actions { display: flex; gap: 4px; justify-content: flex-end; } +.icon-btn { background: none; border: none; color: var(--text-dim); padding: 4px 6px; + font-size: 13px; cursor: pointer; border-radius: 3px; } +.icon-btn:hover { background: var(--bg-soft); color: var(--text); } +.icon-btn.do:hover { color: var(--ok); } +.icon-btn.del:hover { color: var(--err); } +.editing-tag { font-size: 10px; font-family: var(--mono); color: var(--warn); white-space: nowrap; } +.comment-count { font-family: var(--mono); font-size: 11px; color: var(--text-faint); } + +.empty-state { padding: 40px; text-align: center; color: var(--text-dim); } +.empty-state button { margin: 12px auto 0; } + +/* ---------- Activity drawer ---------- */ +.drawer { + position: fixed; top: 0; right: 0; bottom: 0; width: 360px; max-width: 90vw; + background: var(--bg-soft); border-left: 1px solid var(--border-strong); + transform: translateX(100%); transition: transform 0.22s ease; z-index: 90; + display: flex; flex-direction: column; box-shadow: -10px 0 40px rgba(0,0,0,0.4); +} +.drawer.open { transform: translateX(0); } +.drawer-head { padding: 16px 18px; border-bottom: 1px solid var(--border); + display: flex; justify-content: space-between; align-items: center; } +.drawer-head h2 { font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; } +.drawer-body { overflow-y: auto; flex: 1; padding: 10px 0; } +.act-entry { padding: 9px 18px; border-bottom: 1px solid var(--border); font-size: 13px; } +.act-entry:hover { background: var(--card); } +.act-line { display: flex; gap: 8px; align-items: baseline; } +.act-icon { width: 18px; text-align: center; flex-shrink: 0; } +.act-text a, .act-task { color: var(--accent); cursor: pointer; } +.act-task:hover { text-decoration: underline; } +.act-user { font-family: var(--mono); font-size: 12px; color: var(--purple); } +.act-detail { color: var(--text-dim); font-size: 12px; margin-left: 26px; } +.act-time { color: var(--text-faint); font-size: 11px; font-family: var(--mono); margin-left: 26px; } + +/* ---------- Modal ---------- */ +.modal-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(3px); + display: none; align-items: center; justify-content: center; z-index: 100; padding: 20px; +} +.modal-overlay.open { display: flex; } +.modal { background: var(--card); border: 1px solid var(--border-strong); border-radius: var(--radius); + width: 100%; max-width: 680px; max-height: 92vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,0.5); } +.modal-head { padding: 16px 22px; border-bottom: 1px solid var(--border); + display: flex; justify-content: space-between; align-items: center; gap: 10px; } +.modal-head h2 { font-size: 16px; font-weight: 600; } +.modal-warning { + margin: 0 22px; margin-top: 12px; padding: 8px 12px; border-radius: 6px; font-size: 12px; + background: rgba(255,159,67,0.12); color: var(--warn); border: 1px solid rgba(255,159,67,0.3); + display: none; +} +.modal-warning.show { display: block; } +.modal-body { padding: 18px 22px; display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +.form-field { display: flex; flex-direction: column; gap: 4px; } +.form-field.full { grid-column: 1 / -1; } +.form-field label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-dim); font-weight: 600; } +.form-field textarea { resize: vertical; min-height: 72px; font-family: inherit; } +.owner-input-row { display:flex; gap:6px; } +.owner-input-row input { flex:1; } +.owner-input-row select { flex:1; } +.modal-foot { padding: 14px 22px; border-top: 1px solid var(--border); + display: flex; justify-content: space-between; gap: 8px; align-items: center; } + +/* ---------- Comments ---------- */ +.comments-section { grid-column: 1 / -1; border-top: 1px solid var(--border); padding-top: 14px; } +.comments-section h4 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; + color: var(--text-dim); font-weight: 600; margin-bottom: 10px; } +.comment { padding: 8px 10px; background: var(--bg-soft); border: 1px solid var(--border); + border-radius: 6px; margin-bottom: 8px; font-size: 13px; position: relative; } +.comment-head { display: flex; gap: 8px; align-items: baseline; margin-bottom: 2px; } +.comment-by { font-family: var(--mono); font-size: 12px; color: var(--purple); font-weight: 600; } +.comment-by.me { color: var(--ok); } +.comment-at { font-size: 11px; color: var(--text-faint); font-family: var(--mono); } +.comment-del { position: absolute; top: 4px; right: 6px; } +.comment-text { white-space: pre-wrap; word-break: break-word; } +.comment-input-row { display: flex; gap: 8px; margin-top: 4px; } +.comment-input-row input { flex: 1; } +.no-comments { color: var(--text-faint); font-size: 12px; font-style: italic; } + +/* ---------- Toast ---------- */ +.toast-stack { position: fixed; bottom: 20px; right: 20px; z-index: 200; + display: flex; flex-direction: column; gap: 8px; align-items: flex-end; } +.toast { + padding: 11px 16px; background: var(--card); border: 1px solid var(--border-strong); + border-radius: 6px; color: var(--text); font-size: 13px; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); display: flex; gap: 12px; align-items: center; + animation: toastin 0.25s ease; +} +@keyframes toastin { from { transform: translateX(120%); } to { transform: translateX(0); } } +.toast.err { border-color: var(--err); color: var(--err); } +.toast.warn { border-color: var(--warn); color: var(--warn); } +.toast button { padding: 3px 10px; font-size: 12px; } + +/* ---------- Anon banner ---------- */ +.anon-banner { + display: none; margin-bottom: var(--gap); padding: 10px 16px; + background: rgba(255,159,67,0.08); border: 1px solid rgba(255,159,67,0.3); + border-radius: var(--radius); color: var(--warn); font-size: 13px; + justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; +} +.anon-banner.show { display: flex; } + +footer { margin-top: 28px; padding-top: 16px; border-top: 1px solid var(--border); + text-align: center; color: var(--text-faint); font-size: 12px; font-family: var(--mono); } +footer .kbd { display: inline-block; padding: 0 5px; border: 1px solid var(--border); + border-radius: 3px; background: var(--bg-soft); font-size: 11px; margin: 0 1px; } + +@media (max-width: 1250px) { .board { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 1100px) { .chart-row { grid-template-columns: 1fr 1fr; } } +@media (max-width: 720px) { + .chart-row { grid-template-columns: 1fr; } + .board { grid-template-columns: 1fr; } + .modal-body { grid-template-columns: 1fr; } + .kpi-value { font-size: 20px; } + header h1 { font-size: 18px; } +} diff --git a/netops-todo-node/seed.json b/netops-todo-node/seed.json new file mode 100644 index 0000000..04f7dfe --- /dev/null +++ b/netops-todo-node/seed.json @@ -0,0 +1,18 @@ +[ + {"title":"OSPF adjacency drops mellom RTR-OSL-01 og RTR-OSL-02","desc":"Intermittent flapping ca. hvert 15. min. Sjekk MTU, hello/dead timers og interface-feil. Logger på NMS.","category":"L3","priority":"P1","status":"progress","estHours":4,"location":"DC-OSL-1","tags":["feilsøking","prod"]}, + {"title":"Høy latency mot NIX peering","desc":"Pakketap 0.3%, latency 8→18ms mot AS-path via NIX. Kan være congestion – kontakt NIX NOC.","category":"PEER","priority":"P1","status":"todo","estHours":2,"location":"remote","tags":["peering","nix"]}, + {"title":"Konfigurer nye eBGP-peers mot Telenor AS2119","desc":"To nye sesjoner på redundant uplink. Husk MD5, max-prefix, og route-map inn/ut. Dokumentér AS-set.","category":"BGP","priority":"P2","status":"todo","estHours":3,"location":"DC-OSL-1","tags":["bgp","telenor"]}, + {"title":"Bytt defekt SFP+ i core-sw01 port 24","desc":"CRC-feil øker, link flapper. RMA er bestilt, reservedel på lager B-12.","category":"L1","priority":"P2","status":"todo","estHours":1,"location":"DC-OSL-1","tags":["hardware"]}, + {"title":"Sett opp HAProxy for nytt API-cluster","desc":"Backend: 4 noder, TLS termination, HTTP/2, health check /healthz. ACL for rate limit.","category":"L4","priority":"P2","status":"todo","estHours":4,"location":"remote","tags":["prosjekt"]}, + {"title":"Fiberkoble nytt rack B12","desc":"24x LC-LC single-mode, 3m patch. Dokumentér i kabelregister. Labels iht. standard.","category":"L1","priority":"P3","status":"todo","estHours":6,"location":"DC-BGO-1","tags":["felt","bergen"]}, + {"title":"Oppgrader IOS-XE på dist-sw03 til 05","desc":"Vedlikeholdsvindu 02:00–04:00. Backup konfig, ISSU der det støttes. Rollback plan.","category":"L2","priority":"P2","status":"todo","estHours":3,"location":"DC-OSL-1","tags":["vedlikehold","oppgradering"]}, + {"title":"Legg til route reflector i iBGP-mesh","desc":"Reduser full-mesh. RR-klienter: alle PE-rutere. Test failover mellom RR1/RR2.","category":"BGP","priority":"P3","status":"todo","estHours":5,"location":"remote","tags":["design"]}, + {"title":"Review FW-regler før Q2 audit","desc":"Finn og rydd vekk ubrukte regler, dupliserte objekter. Eksportér rapport til sec-team.","category":"SEC","priority":"P2","status":"blocked","estHours":8,"location":"remote","tags":["audit","compliance"]}, + {"title":"Bytt batteri på UPS i Trondheim POP","desc":"Batteritest feiler. Kontakt kjøpetjeneste for reservedel. Krever fysisk oppmøte.","category":"L1","priority":"P2","status":"todo","estHours":2,"location":"POP-TRD","tags":["felt","trondheim"]}, + {"title":"Oppdater LibreNMS poller-nodes","desc":"v24.x → v25.x. Backup MySQL, test med staging-poller først.","category":"NMS","priority":"P4","status":"todo","estHours":1,"location":"remote","tags":["vedlikehold"]}, + {"title":"Dokumentér nye VLAN-er (120-135) i IPAM","desc":"Oppdater NetBox. Inkluder prefix, site, role og tenant.","category":"DOC","priority":"P3","status":"progress","estHours":2,"location":"remote","tags":["netbox"]}, + {"title":"Ansible-playbook for standard switch-config","desc":"Base config, NTP, SNMP, AAA, banner. Idempotent. Test i lab først.","category":"AUTO","priority":"P3","status":"todo","estHours":6,"location":"remote","tags":["automatisering"]}, + {"title":"Postmortem for uptime-hendelse 2026-04-19","desc":"45 min utfall etter oppgradering. RCA + tiltaksliste. Del med ledergruppe.","category":"INC","priority":"P2","status":"progress","estHours":3,"location":"remote","tags":["postmortem"]}, + {"title":"Planlegg 100G oppgradering DC-BGO ↔ DC-OSL","desc":"Kostestimat, lead time optikk, migreringsvindu, rollback.","category":"PROJ","priority":"P3","status":"todo","estHours":10,"location":"remote","tags":["planlegging","capex"]}, + {"title":"Verifisér BFD timere på alle OSPF-lenker","desc":"Mål: 150ms detection. Sjekk at alle rutere støtter og har konfigurert.","category":"L3","priority":"P4","status":"todo","estHours":2,"location":"remote","tags":["optimalisering"]} +] diff --git a/netops-todo-node/server.js b/netops-todo-node/server.js new file mode 100644 index 0000000..7739a52 --- /dev/null +++ b/netops-todo-node/server.js @@ -0,0 +1,254 @@ +'use strict'; +/** + * NetOps To-Do — server. + * + * Express for REST-API + statiske filer, ws for sanntidspush. + * Alle mutasjoner går via REST (lett å curl-e og feilsøke); WebSocket brukes + * kun til å pushe endringer, presence og "redigerer nå"-signaler til klientene. + * + * Brukernavn hentes fra (i denne rekkefølgen): + * 1. X-Remote-User-header (satt av reverse proxy / oauth2-proxy o.l.) + * 2. Authorization: Basic (Basic Auth terminert i proxy, brukernavn gjenbrukes) + * 3. netops_user-cookie (selvvalgt navn — for hjemme/dev-bruk; + * skru av med DISABLE_COOKIE_AUTH=1) + * 4. 'anon' + */ + +const path = require('path'); +const http = require('http'); +const fs = require('fs'); +const express = require('express'); +const { WebSocketServer } = require('ws'); +const { Store, NotFoundError, ConflictError } = require('./lib/store'); + +const COOKIE_AUTH_ENABLED = process.env.DISABLE_COOKIE_AUTH !== '1'; + +function sanitizeUser(raw) { + if (typeof raw !== 'string') return ''; + return raw.replace(/[^A-Za-z0-9._@\-]/g, '').slice(0, 64); +} + +function parseCookies(header) { + const out = {}; + if (!header) return out; + for (const part of header.split(';')) { + const i = part.indexOf('='); + if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim()); + } + return out; +} + +// Felles for HTTP-requests og WebSocket-upgrade (begge har headers). +function userFromRequest(req) { + let u = sanitizeUser(req.headers['x-remote-user']); + if (u) return u; + const auth = req.headers.authorization || ''; + if (auth.startsWith('Basic ')) { + try { + const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8'); + u = sanitizeUser(decoded.split(':')[0]); + if (u) return u; + } catch { /* ignorer ugyldig base64 */ } + } + if (COOKIE_AUTH_ENABLED) { + u = sanitizeUser(parseCookies(req.headers.cookie).netops_user); + if (u) return u; + } + return 'anon'; +} + +function createServer({ dataDir = path.join(__dirname, 'data') } = {}) { + const store = new Store(dataDir); + store.load(); + + const app = express(); + app.disable('x-powered-by'); + app.use(express.json({ limit: '2mb' })); + app.use((req, res, next) => { + req.user = userFromRequest(req); + res.setHeader('X-Content-Type-Options', 'nosniff'); + if (req.path.startsWith('/api/')) res.setHeader('Cache-Control', 'no-store'); + next(); + }); + app.use(express.static(path.join(__dirname, 'public'))); + + const server = http.createServer(app); + + // ---------- WebSocket ---------- + const wss = new WebSocketServer({ noServer: true }); + // ws → { user, editing: taskId|null, isAlive } + const clients = new Map(); + + server.on('upgrade', (req, socket, head) => { + if (!req.url.startsWith('/ws')) { socket.destroy(); return; } + wss.handleUpgrade(req, socket, head, ws => { + wss.emit('connection', ws, req); + }); + }); + + function broadcast(obj) { + const msg = JSON.stringify(obj); + for (const ws of clients.keys()) { + if (ws.readyState === ws.OPEN) ws.send(msg); + } + } + + function onlineUsers() { + const set = new Set(); + for (const meta of clients.values()) set.add(meta.user); + return [...set].sort(); + } + + // { taskId: [brukernavn, ...] } — hvem som har redigeringsmodal åpen + function editingState() { + const out = {}; + for (const meta of clients.values()) { + if (!meta.editing) continue; + (out[meta.editing] ||= []).push(meta.user); + } + return out; + } + + function broadcastPresence() { + broadcast({ type: 'presence', online: onlineUsers() }); + } + + wss.on('connection', (ws, req) => { + const user = userFromRequest(req); + clients.set(ws, { user, editing: null, isAlive: true }); + store.rememberUser(user); + + ws.send(JSON.stringify({ type: 'hello', user, online: onlineUsers(), editing: editingState() })); + ws.send(JSON.stringify({ type: 'sync', tasks: store.list(), activity: store.activity })); + broadcastPresence(); + + ws.on('pong', () => { const m = clients.get(ws); if (m) m.isAlive = true; }); + + ws.on('message', raw => { + let msg; + try { msg = JSON.parse(raw); } catch { return; } + const meta = clients.get(ws); + if (!meta) return; + if (msg.type === 'editing') { + meta.editing = msg.active && typeof msg.id === 'string' ? msg.id.slice(0, 32) : null; + broadcast({ type: 'editing', editing: editingState() }); + } + }); + + ws.on('close', () => { + const meta = clients.get(ws); + clients.delete(ws); + if (meta && meta.editing) broadcast({ type: 'editing', editing: editingState() }); + broadcastPresence(); + }); + }); + + // Heartbeat: dropp døde tilkoblinger (klient bak NAT/proxy som forsvant). + const heartbeat = setInterval(() => { + for (const [ws, meta] of clients) { + if (!meta.isAlive) { ws.terminate(); clients.delete(ws); continue; } + meta.isAlive = false; + ws.ping(); + } + }, 30000); + heartbeat.unref(); + + // ---------- REST API ---------- + app.get('/healthz', (req, res) => res.json({ ok: true, tasks: store.tasks.length })); + + app.get('/api/state', (req, res) => { + store.rememberUser(req.user); + res.json({ + user: req.user, + canSetName: COOKIE_AUTH_ENABLED, + tasks: store.list(), + activity: store.activity, + known_users: [...store.knownUsers].sort(), + online: onlineUsers(), + }); + }); + + app.post('/api/login', (req, res) => { + if (!COOKIE_AUTH_ENABLED) return res.status(403).json({ error: 'Navnevalg er deaktivert — auth styres av proxy' }); + const raw = req.body && req.body.name; + // Ved eksplisitt navnevalg: avvis ugyldige tegn i stedet for å sanitere stille. + if (typeof raw !== 'string' || !/^[A-Za-z0-9._@\-]{1,64}$/.test(raw)) { + return res.status(400).json({ error: 'Ugyldig navn (a-z, 0-9, . _ @ -)' }); + } + const name = raw; + res.setHeader('Set-Cookie', `netops_user=${encodeURIComponent(name)}; Path=/; Max-Age=31536000; SameSite=Lax`); + store.rememberUser(name); + res.json({ user: name }); + }); + + app.post('/api/tasks', (req, res) => { + const { task, activity } = store.create(req.body, req.user); + broadcast({ type: 'task', op: 'create', task, by: req.user, activity }); + res.status(201).json({ task }); + }); + + app.put('/api/tasks/:id', (req, res) => { + const { task, activity } = store.update(req.params.id, req.body, req.user); + broadcast({ type: 'task', op: 'update', task, by: req.user, activity }); + res.json({ task }); + }); + + app.delete('/api/tasks/:id', (req, res) => { + const { task, activity } = store.remove(req.params.id, req.user); + broadcast({ type: 'task', op: 'delete', id: task.id, by: req.user, activity }); + res.json({ ok: true, task }); + }); + + app.post('/api/tasks/:id/comments', (req, res) => { + const { task, comment, activity } = store.addComment(req.params.id, req.body && req.body.text, req.user); + broadcast({ type: 'task', op: 'update', task, by: req.user, activity }); + res.status(201).json({ task, comment }); + }); + + app.delete('/api/tasks/:id/comments/:cid', (req, res) => { + const { task } = store.deleteComment(req.params.id, req.params.cid, req.user); + broadcast({ type: 'task', op: 'update', task, by: req.user }); + res.json({ task }); + }); + + app.post('/api/replace', (req, res) => { + const { count, activity } = store.replaceAll(req.body, req.user); + broadcast({ type: 'sync', tasks: store.list(), activity: store.activity, by: req.user }); + res.json({ ok: true, count }); + }); + + app.post('/api/seed', (req, res) => { + const seedFile = path.join(__dirname, 'seed.json'); + let seed; + try { seed = JSON.parse(fs.readFileSync(seedFile, 'utf8')); } + catch { return res.status(500).json({ error: 'Mangler seed.json' }); } + const { count } = store.seed(seed, req.user); + broadcast({ type: 'sync', tasks: store.list(), activity: store.activity, by: req.user }); + res.json({ ok: true, count }); + }); + + // Feilhåndtering — oversetter store-exceptions til HTTP-koder. + app.use((err, req, res, next) => { + if (err instanceof NotFoundError) return res.status(404).json({ error: err.message }); + if (err instanceof ConflictError) return res.status(409).json({ error: err.message, task: err.task }); + if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'Ugyldig JSON' }); + const code = err.status || 400; + res.status(code >= 500 ? 500 : code).json({ error: err.message || 'Ukjent feil' }); + }); + + return { app, server, store, wss, clients }; +} + +if (require.main === module) { + const port = Number(process.env.PORT) || 3000; + const host = process.env.HOST || '0.0.0.0'; + const dataDir = process.env.DATA_DIR || path.join(__dirname, 'data'); + const { server, store } = createServer({ dataDir }); + server.listen(port, host, () => { + console.log(`NetOps To-Do kjører på http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`); + console.log(`Datakatalog: ${dataDir} (${store.tasks.length} oppgaver)`); + if (!COOKIE_AUTH_ENABLED) console.log('Cookie-auth er deaktivert — bruker kun proxy-headere'); + }); +} + +module.exports = { createServer, userFromRequest }; diff --git a/netops-todo-node/setup-debian13.sh b/netops-todo-node/setup-debian13.sh new file mode 100755 index 0000000..9ed5287 --- /dev/null +++ b/netops-todo-node/setup-debian13.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# +# NetOps To-Do — oppsett på Debian 13 (trixie), f.eks. en fersk LXC. +# Installerer Node fra apt, henter koden, og setter opp systemd-tjeneste. +# +# Bruk (som root): +# ./setup-debian13.sh # kloner repoet til /opt/netops og setter opp +# ./setup-debian13.sh # bruker repoet skriptet allerede ligger i +# +# Eksempel: +# bash setup-debian13.sh ssh://forgejo@int.git.vanvikinternet.no/jvanvik/netops.git +# +# Valgfrie miljøvariabler: +# PORT=3000 lytteport (default 3000) +# APP_DIR=/opt/netops hvor repoet havner ved kloning +# DISABLE_COOKIE_AUTH=1 skru av selvvalgt navn i UI (når proxyen gjør auth) +# +# Kjør skriptet på nytt etter en `git pull` for å oppgradere (idempotent). + +set -euo pipefail + +PORT="${PORT:-3000}" +APP_DIR="${APP_DIR:-/opt/netops}" +SERVICE=netops-todo + +if [ "$(id -u)" -ne 0 ]; then + echo "Kjør som root — skriptet installerer pakker og systemd-tjeneste." >&2 + exit 1 +fi + +echo "==> Installerer pakker (git, nodejs, npm, curl) …" +apt-get update -qq +DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git nodejs npm curl ca-certificates >/dev/null + +node_major="$(node -e 'console.log(process.versions.node.split(".")[0])')" +if [ "$node_major" -lt 18 ]; then + echo "Node $node_major er for gammel (appen krever >=18). Debian 13 skal ha Node 20+." >&2 + exit 1 +fi +echo " Node $(node --version) OK" + +# ---------- Hent koden ---------- +if [ $# -ge 1 ]; then + if [ -d "$APP_DIR/.git" ]; then + echo "==> $APP_DIR finnes allerede — oppdaterer med git pull …" + git -C "$APP_DIR" pull --ff-only + else + echo "==> Kloner $1 → $APP_DIR …" + git clone "$1" "$APP_DIR" + fi +else + # Ingen URL oppgitt: anta at skriptet kjøres fra et allerede klonet repo + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + APP_DIR="$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null || true)" + if [ -z "$APP_DIR" ]; then + echo "Bruk: $0 — eller kjør skriptet fra et klonet repo." >&2 + exit 1 + fi + echo "==> Bruker eksisterende repo: $APP_DIR" +fi + +# Appen kan ligge i repo-rota eller i netops-todo-node/ +if [ -f "$APP_DIR/server.js" ]; then + NODE_DIR="$APP_DIR" +elif [ -f "$APP_DIR/netops-todo-node/server.js" ]; then + NODE_DIR="$APP_DIR/netops-todo-node" +else + echo "Fant ikke server.js under $APP_DIR." >&2 + exit 1 +fi + +echo "==> npm ci i $NODE_DIR …" +(cd "$NODE_DIR" && npm ci --omit=dev --no-audit --no-fund) + +echo "==> Skriver /etc/systemd/system/$SERVICE.service …" +# DynamicUser + StateDirectory: systemd oppretter tjenestebruker og +# /var/lib/netops-todo med riktige rettigheter — ingen useradd/chown nødvendig. +cat > "/etc/systemd/system/$SERVICE.service" </dev/null; then + ip="$(hostname -I | awk '{print $1}')" + echo + echo "✔ NetOps To-Do kjører: http://$ip:$PORT" + echo + echo "Neste steg — Nginx Proxy Manager (se også README.md):" + echo " Proxy Host → scheme http, forward $ip:$PORT, eget (sub)domene," + echo " og skru PÅ 'Websockets Support'. SSL via Let's Encrypt + Force SSL." + echo + echo "NB: Appen stoler på X-Remote-User/Basic Auth-headere fra proxyen." + echo " Sørg for at port $PORT kun er nåbar fra proxyen (brannmur/ACL)." +else + echo "✖ Tjenesten svarte ikke på /healthz — sjekk: journalctl -u $SERVICE -e" >&2 + exit 1 +fi diff --git a/netops-todo-node/test/api.test.js b/netops-todo-node/test/api.test.js new file mode 100644 index 0000000..f890e01 --- /dev/null +++ b/netops-todo-node/test/api.test.js @@ -0,0 +1,209 @@ +'use strict'; +/** + * Integrasjonstester: booter serveren på tilfeldig port med en midlertidig + * datakatalog og tester REST-API + WebSocket-broadcast ende-til-ende. + * Kjør med: npm test + */ + +const { test, before, after } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const WebSocket = require('ws'); +const { createServer } = require('../server'); + +let server, baseUrl, dataDir; + +function req(method, p, body = null, headers = {}) { + return fetch(baseUrl + p, { + method, + headers: { 'Content-Type': 'application/json', 'X-Remote-User': 'testuser', ...headers }, + body: body !== null ? JSON.stringify(body) : undefined, + }); +} + +before(async () => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'netops-test-')); + ({ server } = createServer({ dataDir })); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + baseUrl = `http://127.0.0.1:${server.address().port}`; +}); + +after(() => { + server.close(); + fs.rmSync(dataDir, { recursive: true, force: true }); +}); + +test('state starter tomt og identifiserer bruker fra header', async () => { + const res = await req('GET', '/api/state'); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.user, 'testuser'); + assert.deepEqual(data.tasks, []); +}); + +test('seed fyller tom database, og nekter når data finnes', async () => { + let res = await req('POST', '/api/seed'); + assert.equal(res.status, 200); + const { count } = await res.json(); + assert.equal(count, 16); + + res = await req('POST', '/api/seed'); + assert.equal(res.status, 409); +}); + +test('create normaliserer og setter audit-felter', async () => { + const res = await req('POST', '/api/tasks', { + title: 'Test BGP-peer', category: 'BGP', priority: 'P1', + status: 'ugyldig-status', estHours: 5000, tags: ['x', '', 'y'], + deadline: 'ikke-en-dato', desc: 'hei\x00der', + }); + assert.equal(res.status, 201); + const { task } = await res.json(); + assert.equal(task.status, 'todo'); // ugyldig → default + assert.equal(task.estHours, 999); // klampet + assert.deepEqual(task.tags, ['x', 'y']); // tomme fjernet + assert.equal(task.deadline, ''); // ugyldig format + assert.equal(task.desc, 'heider'); // kontrolltegn fjernet + assert.equal(task.createdBy, 'testuser'); + assert.equal(task.v, 1); +}); + +test('create uten tittel feiler', async () => { + const res = await req('POST', '/api/tasks', { desc: 'mangler tittel' }); + assert.equal(res.status, 400); +}); + +test('update med riktig versjon ok, feil versjon gir 409 med serverens task', async () => { + let res = await req('POST', '/api/tasks', { title: 'Versjonstest' }); + const { task } = await res.json(); + + res = await req('PUT', `/api/tasks/${task.id}`, { ...task, status: 'progress', v: task.v }); + assert.equal(res.status, 200); + const { task: t2 } = await res.json(); + assert.equal(t2.status, 'progress'); + assert.equal(t2.v, 2); + + // Lagring fra utdatert versjon skal avvises + res = await req('PUT', `/api/tasks/${task.id}`, { ...task, status: 'blocked', v: 1 }); + assert.equal(res.status, 409); + const conflict = await res.json(); + assert.equal(conflict.task.v, 2); + assert.equal(conflict.task.status, 'progress'); +}); + +test('done setter completed, gjenåpning nullstiller', async () => { + let res = await req('POST', '/api/tasks', { title: 'Ferdigtest' }); + let { task } = await res.json(); + + res = await req('PUT', `/api/tasks/${task.id}`, { v: task.v, status: 'done' }); + ({ task } = await res.json()); + assert.equal(task.completed, new Date().toISOString().slice(0, 10)); + assert.equal(task.completedBy, 'testuser'); + // Delvis update skal beholde øvrige felter + assert.equal(task.title, 'Ferdigtest'); + + res = await req('PUT', `/api/tasks/${task.id}`, { v: task.v, status: 'todo' }); + ({ task } = await res.json()); + assert.equal(task.completed, null); +}); + +test('kommentarer: legg til, slett egne, nekt andres', async () => { + let res = await req('POST', '/api/tasks', { title: 'Kommentartest' }); + const { task } = await res.json(); + + res = await req('POST', `/api/tasks/${task.id}/comments`, { text: 'Første kommentar' }); + assert.equal(res.status, 201); + const { comment } = await res.json(); + assert.equal(comment.by, 'testuser'); + + // Annen bruker får ikke slette + res = await req('DELETE', `/api/tasks/${task.id}/comments/${comment.id}`, null, { 'X-Remote-User': 'kari' }); + assert.equal(res.status, 400); + + res = await req('DELETE', `/api/tasks/${task.id}/comments/${comment.id}`); + assert.equal(res.status, 200); + const { task: after } = await res.json(); + assert.equal(after.comments.length, 0); +}); + +test('delete fjerner oppgave, 404 ved ukjent id', async () => { + let res = await req('POST', '/api/tasks', { title: 'Slettetest' }); + const { task } = await res.json(); + res = await req('DELETE', `/api/tasks/${task.id}`); + assert.equal(res.status, 200); + res = await req('DELETE', `/api/tasks/${task.id}`); + assert.equal(res.status, 404); +}); + +test('aktivitetslogg føres', async () => { + const res = await req('GET', '/api/state'); + const { activity } = await res.json(); + assert.ok(activity.length > 0); + assert.ok(activity.some(a => a.action === 'create')); + assert.ok(activity.some(a => a.action === 'done')); + assert.ok(activity.some(a => a.action === 'comment')); +}); + +test('websocket: mottar sync ved tilkobling og broadcast ved endringer', async () => { + const wsUrl = baseUrl.replace('http', 'ws') + '/ws'; + const wsClient = new WebSocket(wsUrl, { headers: { 'X-Remote-User': 'kari' } }); + const messages = []; + wsClient.on('message', raw => messages.push(JSON.parse(raw))); + await new Promise((resolve, reject) => { + wsClient.on('open', resolve); + wsClient.on('error', reject); + }); + + // Vent på hello + sync + await new Promise(r => setTimeout(r, 200)); + const hello = messages.find(m => m.type === 'hello'); + assert.equal(hello.user, 'kari'); + assert.ok(messages.some(m => m.type === 'sync' && Array.isArray(m.tasks))); + + // En REST-endring fra annen bruker skal pushes til kari + const res = await req('POST', '/api/tasks', { title: 'WS-broadcast-test' }); + const { task } = await res.json(); + await new Promise(r => setTimeout(r, 200)); + const broadcastMsg = messages.find(m => m.type === 'task' && m.op === 'create' && m.task.id === task.id); + assert.ok(broadcastMsg, 'broadcast ikke mottatt'); + assert.equal(broadcastMsg.by, 'testuser'); + assert.equal(broadcastMsg.activity.action, 'create'); + + wsClient.close(); +}); + +test('replace erstatter alt og persisterer til disk', async () => { + const res = await req('POST', '/api/replace', [ + { title: 'Eneste oppgave', category: 'NMS', priority: 'P4' }, + { ugyldig: 'uten tittel' }, + ]); + const { count } = await res.json(); + assert.equal(count, 1); + + // Vent på at skrivekøen tømmes, og verifiser fila på disk + await new Promise(r => setTimeout(r, 300)); + const onDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'tasks.json'), 'utf8')); + assert.equal(onDisk.length, 1); + assert.equal(onDisk[0].title, 'Eneste oppgave'); +}); + +test('cookie-auth: login setter cookie, ugyldig navn avvises', async () => { + let res = await fetch(baseUrl + '/api/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'jon.v' }), + }); + assert.equal(res.status, 200); + assert.match(res.headers.get('set-cookie'), /netops_user=jon\.v/); + + // Brukeren identifiseres via cookien + res = await fetch(baseUrl + '/api/state', { headers: { Cookie: 'netops_user=jon.v' } }); + assert.equal((await res.json()).user, 'jon.v'); + + res = await fetch(baseUrl + '/api/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: '