Legg til Node/npm-versjon med sanntidssynk + Debian 13-oppsettsskript

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 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-12 11:54:22 +02:00
parent 72a78b55fb
commit 6b9dbf8254
15 changed files with 3814 additions and 0 deletions

4
netops-todo-node/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
data/
*.log
.DS_Store

127
netops-todo-node/CLAUDE.md Normal file
View file

@ -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
L1L4, BGP, peering, security, NMS, automatisering, dokumentasjon, incident,
prosjekt. Hver oppgave har prioritet (P1P4), 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.

179
netops-todo-node/README.md Normal file
View file

@ -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 <din-remote> /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.

View file

@ -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, 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 };

853
netops-todo-node/package-lock.json generated Normal file
View file

@ -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
}
}
}
}
}

View file

@ -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
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,271 @@
<!DOCTYPE html>
<html lang="no">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NetOps To-Do · Network Engineer</title>
<link rel="stylesheet" href="style.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1"></script>
</head>
<body>
<div class="container">
<header>
<div>
<h1><span id="conn-dot" title="Tilkoblingsstatus"></span> NetOps To-Do</h1>
<div class="sub" id="conn-text">kobler til …</div>
</div>
<div class="header-actions">
<span class="online-row" id="online-row" title="Pålogget nå"></span>
<span class="user-chip" id="user-chip" title="Pålogget bruker"></span>
<button id="activityBtn" title="Aktivitetslogg (a)">☰ Aktivitet</button>
<button id="exportBtn" title="Eksporter JSON"></button>
<button id="importBtn" title="Importer JSON (erstatter alt)"></button>
<input type="file" id="importFile" accept=".json" style="display:none">
<button class="primary" id="newTaskBtn" title="Ny oppgave (n)">+ Ny oppgave</button>
</div>
</header>
<div class="anon-banner" id="anon-banner">
<span>Du er pålogget som <b>anon</b>. Velg et navn så kollegene ser hvem som gjør hva.</span>
<button class="primary" id="setNameBtn">Sett navn</button>
</div>
<div class="quickadd">
<div class="quickadd-row">
<input type="text" id="quickadd-input" autocomplete="off"
placeholder='Hurtig-registrering: f.eks. "Bytt SFP i core-sw01 #l1 p2 @me !imorgen ~1h loc:DC-OSL-1"'>
<button class="primary" id="quickadd-btn">Legg til</button>
</div>
<div class="quickadd-preview" id="quickadd-preview"></div>
<div class="quickadd-hint">#kategori-eller-tag · @eier (@me) · p1p4 · !frist (2026-06-20, +3d, idag, imorgen) · ~timer · loc:sted · resten blir tittel</div>
</div>
<section class="kpi-row">
<div class="kpi">
<div class="kpi-label">Åpne oppgaver</div>
<div class="kpi-value" id="kpi-open">0</div>
<div class="kpi-sub" id="kpi-open-sub">av 0 totalt</div>
</div>
<div class="kpi kpi-me">
<div class="kpi-label">Mine åpne</div>
<div class="kpi-value" id="kpi-mine">0</div>
<div class="kpi-sub" id="kpi-mine-sub">tildelt meg</div>
</div>
<div class="kpi kpi-p1">
<div class="kpi-label">P1 Kritisk</div>
<div class="kpi-value" id="kpi-p1">0</div>
<div class="kpi-sub">umiddelbar handling</div>
</div>
<div class="kpi kpi-warn">
<div class="kpi-label">Est. tid igjen</div>
<div class="kpi-value" id="kpi-hours">0t</div>
<div class="kpi-sub" id="kpi-hours-sub">arbeidstimer</div>
</div>
<div class="kpi">
<div class="kpi-label">Forfalte</div>
<div class="kpi-value" id="kpi-overdue">0</div>
<div class="kpi-sub">over frist</div>
</div>
<div class="kpi kpi-ok">
<div class="kpi-label">Ferdig (7d)</div>
<div class="kpi-value" id="kpi-done">0</div>
<div class="kpi-sub">siste 7 dager</div>
</div>
</section>
<section class="chart-row" id="chart-row">
<div class="panel">
<h3>Åpne oppgaver per kategori <span id="cat-total" style="color:var(--text-faint);font-family:var(--mono);text-transform:none;letter-spacing:0"></span></h3>
<div class="chart-wrap"><canvas id="catChart"></canvas></div>
</div>
<div class="panel">
<h3>Fordeling etter prioritet</h3>
<div class="chart-wrap"><canvas id="prioChart"></canvas></div>
</div>
<div class="panel">
<h3>Åpne oppgaver per eier</h3>
<div class="chart-wrap"><canvas id="ownerChart"></canvas></div>
</div>
</section>
<div class="filters">
<div class="view-toggle">
<button id="view-board" title="Tavle (v)">▦ Tavle</button>
<button id="view-table" title="Tabell (v)">☰ Tabell</button>
</div>
<div class="filter-group" id="fg-status">
<label>Status</label>
<select id="f-status">
<option value="open">Åpne</option>
<option value="all">Alle</option>
<option value="todo">Å gjøre</option>
<option value="progress">Pågår</option>
<option value="blocked">Blokkert</option>
<option value="done">Ferdig</option>
</select>
</div>
<div class="filter-group">
<label>Kategori</label>
<select id="f-category"><option value="all">Alle</option></select>
</div>
<div class="filter-group">
<label>Prioritet</label>
<select id="f-priority">
<option value="all">Alle</option>
<option value="P1">P1 Kritisk</option>
<option value="P2">P2 Høy</option>
<option value="P3">P3 Middels</option>
<option value="P4">P4 Lav</option>
</select>
</div>
<div class="filter-group">
<label>Eier</label>
<select id="f-owner">
<option value="all">Alle</option>
<option value="__me__">Meg</option>
<option value="__unassigned__">Utildelt</option>
</select>
</div>
<input class="search-input" type="text" id="f-search" placeholder="Søk tittel, beskrivelse, lokasjon, tag … (/)">
<button id="clearFilters">Nullstill</button>
</div>
<section class="board" id="board" style="display:none"></section>
<div class="table-panel" id="table-panel" style="display:none">
<div class="table-head">
<h3>Oppgaveliste <span class="count" id="tbl-count">0</span></h3>
<div class="sub" style="color:var(--text-faint); font-family:var(--mono); font-size:11px">klikk kolonne for å sortere</div>
</div>
<div class="table-scroll">
<table id="tbl">
<thead>
<tr>
<th data-sort="priority" style="width:70px">Prio</th>
<th data-sort="title">Oppgave</th>
<th data-sort="category" style="width:110px">Kategori</th>
<th data-sort="status" style="width:100px">Status</th>
<th data-sort="owner" style="width:130px">Eier</th>
<th data-sort="location" style="width:120px">Lokasjon</th>
<th data-sort="estHours" style="width:70px;text-align:right">Est (t)</th>
<th data-sort="deadline" style="width:110px">Frist</th>
<th style="width:120px;text-align:right">Handling</th>
</tr>
</thead>
<tbody id="tbl-body"></tbody>
</table>
</div>
</div>
<div id="empty" class="empty-state" style="display:none">
Ingen oppgaver matcher filtrene.
<div id="empty-seed" style="display:none;margin-top:14px">
Tom database. <button id="seedBtn" class="primary">Last inn eksempel-data</button>
</div>
</div>
<footer>
Sanntidssynk via WebSocket · snarveier:
<span class="kbd">n</span> ny ·
<span class="kbd">/</span> søk ·
<span class="kbd">v</span> tavle/tabell ·
<span class="kbd">a</span> aktivitet ·
<span class="kbd">esc</span> lukk
</footer>
</div>
<aside class="drawer" id="drawer">
<div class="drawer-head">
<h2>Aktivitet</h2>
<button class="icon-btn" id="drawer-close" style="font-size:18px">×</button>
</div>
<div class="drawer-body" id="drawer-body"></div>
</aside>
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-head">
<h2 id="modal-title">Ny oppgave</h2>
<button class="icon-btn" id="modal-close" style="font-size:18px">×</button>
</div>
<div class="modal-warning" id="modal-warning"></div>
<div class="modal-body">
<div class="form-field full">
<label>Tittel *</label>
<input type="text" id="f-title" placeholder="F.eks. Konfigurer BGP-peer mot AS2119">
</div>
<div class="form-field full">
<label>Beskrivelse / notater</label>
<textarea id="f-desc" placeholder="Kommandoer, konfigurasjon, referanser …"></textarea>
</div>
<div class="form-field">
<label>Kategori *</label>
<select id="f-cat"></select>
</div>
<div class="form-field">
<label>Prioritet</label>
<select id="f-prio">
<option value="P1">P1 Kritisk</option>
<option value="P2" selected>P2 Høy</option>
<option value="P3">P3 Middels</option>
<option value="P4">P4 Lav</option>
</select>
</div>
<div class="form-field">
<label>Status</label>
<select id="f-stat">
<option value="todo" selected>Å gjøre</option>
<option value="progress">Pågår</option>
<option value="blocked">Blokkert</option>
<option value="done">Ferdig</option>
</select>
</div>
<div class="form-field">
<label>Est. tid (timer)</label>
<input type="number" id="f-est" min="0" step="0.5" placeholder="2">
</div>
<div class="form-field full">
<label>Eier / tildelt</label>
<div class="owner-input-row">
<input type="text" id="f-owner-input" placeholder="brukernavn, eller blank for utildelt">
<select id="f-owner-select" title="Velg eksisterende bruker">
<option value="">— velg —</option>
</select>
</div>
</div>
<div class="form-field">
<label>Lokasjon / site</label>
<input type="text" id="f-loc" placeholder="DC-OSL-1, Bergen POP, remote …">
</div>
<div class="form-field">
<label>Frist</label>
<input type="date" id="f-dl">
</div>
<div class="form-field full">
<label>Tags (komma-separert)</label>
<input type="text" id="f-tags" placeholder="vedlikeholdsvindu, kunde-X, audit">
</div>
<div class="comments-section" id="comments-section" style="display:none">
<h4>Kommentarer <span id="comment-count" style="font-family:var(--mono)"></span></h4>
<div id="comments-list"></div>
<div class="comment-input-row">
<input type="text" id="comment-input" placeholder="Skriv en kommentar … (enter for å sende)">
<button id="comment-send">Send</button>
</div>
</div>
</div>
<div class="modal-foot">
<button class="danger" id="modal-delete" style="display:none">Slett</button>
<span id="modal-audit" style="flex:1;font-size:11px;color:var(--text-faint);font-family:var(--mono)"></span>
<button id="modal-cancel">Avbryt</button>
<button class="primary" id="modal-save">Lagre</button>
</div>
</div>
</div>
<div class="toast-stack" id="toast-stack"></div>
<script src="app.js"></script>
</body>
</html>

View file

@ -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; }
}

View file

@ -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:0004: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"]}
]

254
netops-todo-node/server.js Normal file
View file

@ -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 };

View file

@ -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 <git-url> # 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 <git-url> — 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" <<EOF
[Unit]
Description=NetOps To-Do
After=network.target
[Service]
DynamicUser=yes
StateDirectory=$SERVICE
WorkingDirectory=$NODE_DIR
ExecStart=$(command -v node) server.js
Environment=PORT=$PORT
Environment=HOST=0.0.0.0
Environment=DATA_DIR=/var/lib/$SERVICE
${DISABLE_COOKIE_AUTH:+Environment=DISABLE_COOKIE_AUTH=1}
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now "$SERVICE"
systemctl restart "$SERVICE" # plukk opp ny kode ved re-kjøring/oppgradering
sleep 1
if curl -fsS "http://127.0.0.1:$PORT/healthz" >/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

View file

@ -0,0 +1,209 @@
'use strict';
/**
* Integrasjonstester: booter serveren 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: '<script>' }),
});
assert.equal(res.status, 400);
});