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:
parent
72a78b55fb
commit
6b9dbf8254
15 changed files with 3814 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
data/
|
||||||
4
netops-todo-node/.gitignore
vendored
Normal file
4
netops-todo-node/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
127
netops-todo-node/CLAUDE.md
Normal file
127
netops-todo-node/CLAUDE.md
Normal 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
|
||||||
|
L1–L4, BGP, peering, security, NMS, automatisering, dokumentasjon, incident,
|
||||||
|
prosjekt. Hver oppgave har prioritet (P1–P4), status, estimat, lokasjon, frist,
|
||||||
|
tags, eier, kommentarer og audit-felter.
|
||||||
|
|
||||||
|
Nytt mot PHP-versjonen:
|
||||||
|
|
||||||
|
- **WebSocket-sanntidssynk** (erstatter 60s-polling) + presence + "redigerer nå"
|
||||||
|
- **Kanban-tavle** med drag & drop, i tillegg til tabellvisning
|
||||||
|
- **Kommentarer** per oppgave
|
||||||
|
- **Aktivitetslogg** (hvem gjorde hva, server-side, capped til 400 entries)
|
||||||
|
- **Optimistisk låsing** — `v`-felt per oppgave, 409 ved konflikt
|
||||||
|
- **Hurtig-registrering** med token-parsing (`#kat @eier p1 !frist ~timer loc:sted`)
|
||||||
|
- **Angre sletting**, tastatursnarveier, persistente filtre (localStorage)
|
||||||
|
|
||||||
|
## Filer
|
||||||
|
|
||||||
|
```
|
||||||
|
netops-todo-node/
|
||||||
|
├── server.js Express + ws. REST-API, WebSocket-hub, auth-oppslag.
|
||||||
|
├── lib/store.js Datalager: normalisering, validering, aktivitetslogg,
|
||||||
|
│ atomisk JSON-persistens. Kilden til sannhet for skjema.
|
||||||
|
├── public/
|
||||||
|
│ ├── index.html Markup. Ingen templating — app.js fyller alt.
|
||||||
|
│ ├── app.js All frontend-logikk (vanilla JS, ingen rammeverk).
|
||||||
|
│ └── style.css Mørkt tema, samme visuelle identitet som PHP-versjonen.
|
||||||
|
├── seed.json 16 eksempel-oppgaver.
|
||||||
|
├── test/api.test.js Integrasjonstester (node:test) — REST + WS ende-til-ende.
|
||||||
|
├── data/ Opprettes ved kjøring: tasks.json, activity.json, users.json.
|
||||||
|
└── README.md Installasjon, proxy-oppsett, API-referanse.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Arkitektur
|
||||||
|
|
||||||
|
- **Alle mutasjoner via REST** (lett å curl-e/feilsøke). WebSocket (`/ws`)
|
||||||
|
brukes kun til push: endringer, presence, editing-signaler.
|
||||||
|
- Én node-prosess eier datakatalogen. Lagring i minnet, skriv til disk er
|
||||||
|
atomisk (tmp + rename) og serialisert gjennom en promise-kjede i `store.js`.
|
||||||
|
- Auth ligger utenfor appen: `X-Remote-User`-header > Basic Auth > cookie
|
||||||
|
(`netops_user`, kan skrus av med `DISABLE_COOKIE_AUTH=1`) > `anon`.
|
||||||
|
Samme oppslag for HTTP og WS-upgrade (`userFromRequest` i server.js).
|
||||||
|
|
||||||
|
### WebSocket-protokoll (server → klient)
|
||||||
|
|
||||||
|
| type | Innhold | Når |
|
||||||
|
|---|---|---|
|
||||||
|
| `hello` | `user`, `online[]`, `editing{}` | Ved tilkobling |
|
||||||
|
| `sync` | `tasks[]`, `activity[]`, evt. `by` | Ved tilkobling + etter replace/seed |
|
||||||
|
| `task` | `op: create/update/delete`, `task`/`id`, `by`, `activity` | Etter hver mutasjon |
|
||||||
|
| `presence` | `online[]` | Når noen kobler til/fra |
|
||||||
|
| `editing` | `editing{}` = `{taskId: [brukere]}` | Når noen åpner/lukker modal |
|
||||||
|
|
||||||
|
Klient → server: kun `{ type: 'editing', id, active }`.
|
||||||
|
|
||||||
|
### Task-skjema (kanonisk form — `normalizeTask()` i lib/store.js)
|
||||||
|
|
||||||
|
Som PHP-versjonen, pluss:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"v": 3,
|
||||||
|
"comments": [{ "id": "c_…", "by": "jon", "at": "ISO", "text": "≤1000" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`v` bumpes ved hver felt-oppdatering (ikke ved kommentarer — bevisst, så en
|
||||||
|
kommentar ikke gir falsk konflikt for noen som redigerer felter samtidig).
|
||||||
|
Klienten sender `v` den redigerte fra; mismatch → 409 med serverens task i
|
||||||
|
svaret. Frontend håndterer det i `handleSaveError()`: oppdaterer visningen,
|
||||||
|
viser varsel, og lar brukeren lagre på nytt (som da overskriver bevisst).
|
||||||
|
|
||||||
|
## Designvalg verdt å vite
|
||||||
|
|
||||||
|
- **Ingen database, ingen frontend-rammeverk, to npm-avhengigheter**
|
||||||
|
(express, ws). Samme "lett å patche for neste vakthavende"-filosofi som før.
|
||||||
|
- **Broadcast går til alle, inkludert avsender.** Klienten upserter idempotent
|
||||||
|
på id, så dobbel oppdatering er harmløs. REST-svaret brukes også, slik at
|
||||||
|
UI-et fungerer selv om WS er nede (da poller frontend hvert 30s).
|
||||||
|
- **Delvis update er lov:** `PUT /api/tasks/:id` med bare `{v, status}` —
|
||||||
|
normalisering merger mot eksisterende. Quick-actions bruker dette.
|
||||||
|
- **Kommentar-sletting er begrenset til egen bruker** (eneste
|
||||||
|
autorisasjonsregel i appen — alt annet er åpent for alle innloggede).
|
||||||
|
- Chart.js fra CDN; frontend degraderer pent (skjuler grafer) uten nett.
|
||||||
|
|
||||||
|
## Sikkerhetsstatus
|
||||||
|
|
||||||
|
Som PHP-versjonen (XSS-escaping i alle render-paths, whitelist-enums,
|
||||||
|
strenglengde-klamping, brukernavn-regex `[A-Za-z0-9._@-]{1,64}`), pluss:
|
||||||
|
|
||||||
|
- `/api/login` avviser ugyldige navn i stedet for å sanitere stille.
|
||||||
|
- Cookie settes med `SameSite=Lax` — gir grunnleggende CSRF-vern for
|
||||||
|
cookie-auth-modus. Bak proxy-auth gjelder samme vurdering som før
|
||||||
|
(intern bruk OK; offentlig nett → legg på token).
|
||||||
|
- `express.json({ limit: '2mb' })` begrenser payload (import av store lister).
|
||||||
|
|
||||||
|
## Vanlige utvidelser
|
||||||
|
|
||||||
|
- **Nytt felt per oppgave:** input i modal (index.html) → `saveForm()` og
|
||||||
|
`parseQuickAdd()` om ønskelig (app.js) → `normalizeTask()` (store.js) →
|
||||||
|
`diffDetail()` for aktivitetsloggen → `renderCard()`/`renderTable()`.
|
||||||
|
- **Ny kategori:** `CATEGORIES`/`CAT_COLORS` i app.js + `VALID_CATEGORIES`
|
||||||
|
i store.js.
|
||||||
|
- **SQLite:** bytt persistens i `Store` (load/_persist) mot `node:sqlite` —
|
||||||
|
resten av appen er uberørt siden alt går gjennom Store-metodene.
|
||||||
|
- **Varsler ved frist:** server har allerede deadline per task; en
|
||||||
|
`setInterval` i server.js som broadcaster `{type:'due', …}` + Notification
|
||||||
|
API i frontend er den naturlige veien.
|
||||||
|
|
||||||
|
## Testing-status
|
||||||
|
|
||||||
|
- `npm test`: 12 integrasjonstester (node:test), booter ekte server på
|
||||||
|
tilfeldig port med temp-datakatalog. Dekker normalisering, versjonskonflikt,
|
||||||
|
kommentar-autorisasjon, aktivitetslogg, WS-broadcast, persistens på disk,
|
||||||
|
cookie-auth. Alle grønne per 2026-06-12 (Node 25).
|
||||||
|
- Manuell røyk-test utført: `/healthz`, statiske filer, seed, state, og
|
||||||
|
WS-push av REST-endring verifisert ende-til-ende.
|
||||||
|
- Ikke testet i nettleser med flere samtidige brukere — første ekte test bør
|
||||||
|
være to nettleservinduer mot `npm start` og flytte kort på tavla.
|
||||||
179
netops-todo-node/README.md
Normal file
179
netops-todo-node/README.md
Normal 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.
|
||||||
319
netops-todo-node/lib/store.js
Normal file
319
netops-todo-node/lib/store.js
Normal 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, så parallelle requests ikke ødelegger filene. Antakelsen er
|
||||||
|
* én node-prosess per datakatalog (samme antakelse som flock i PHP-versjonen).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const VALID_STATUS = ['todo', 'progress', 'blocked', 'done'];
|
||||||
|
const VALID_PRIORITIES = ['P1', 'P2', 'P3', 'P4'];
|
||||||
|
const VALID_CATEGORIES = ['L1', 'L2', 'L3', 'L4', 'BGP', 'PEER', 'SEC', 'NMS', 'AUTO', 'DOC', 'INC', 'PROJ'];
|
||||||
|
|
||||||
|
const STATUS_LABELS = { todo: 'Å gjøre', progress: 'Pågår', blocked: 'Blokkert', done: 'Ferdig' };
|
||||||
|
|
||||||
|
const MAX_ACTIVITY = 400;
|
||||||
|
const MAX_COMMENTS = 200;
|
||||||
|
|
||||||
|
class NotFoundError extends Error {}
|
||||||
|
class ConflictError extends Error {
|
||||||
|
constructor(msg, task) { super(msg); this.task = task; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function uid(prefix) {
|
||||||
|
return prefix + '_' + crypto.randomBytes(5).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeStr(v, max = 500) {
|
||||||
|
if (typeof v !== 'string') return '';
|
||||||
|
// Fjern kontrolltegn (bortsett fra \n \r \t)
|
||||||
|
v = v.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '').trim();
|
||||||
|
return v.length > max ? v.slice(0, max) : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTags(v) {
|
||||||
|
if (!Array.isArray(v)) return [];
|
||||||
|
const out = [];
|
||||||
|
for (const t of v) {
|
||||||
|
const s = sanitizeStr(t, 40);
|
||||||
|
if (s) out.push(s);
|
||||||
|
if (out.length >= 20) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeComments(v) {
|
||||||
|
if (!Array.isArray(v)) return [];
|
||||||
|
const out = [];
|
||||||
|
for (const c of v) {
|
||||||
|
if (!c || typeof c !== 'object') continue;
|
||||||
|
const text = sanitizeStr(c.text, 1000);
|
||||||
|
if (!text) continue;
|
||||||
|
out.push({
|
||||||
|
id: typeof c.id === 'string' ? c.id.replace(/[^a-z0-9_]/gi, '').slice(0, 32) || uid('c') : uid('c'),
|
||||||
|
by: sanitizeStr(c.by, 64) || 'anon',
|
||||||
|
at: typeof c.at === 'string' && !isNaN(Date.parse(c.at)) ? c.at : new Date().toISOString(),
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
if (out.length >= MAX_COMMENTS) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTask(input, user, existing = null, opts = {}) {
|
||||||
|
const inp = input && typeof input === 'object' ? input : {};
|
||||||
|
const ex = existing || {};
|
||||||
|
const t = {};
|
||||||
|
|
||||||
|
t.id = ex.id
|
||||||
|
|| (typeof inp.id === 'string' ? inp.id.replace(/[^a-z0-9_]/gi, '').slice(0, 32) : '')
|
||||||
|
|| uid('t');
|
||||||
|
|
||||||
|
t.title = sanitizeStr(inp.title ?? ex.title ?? '', 200);
|
||||||
|
t.desc = sanitizeStr(inp.desc ?? ex.desc ?? '', 4000);
|
||||||
|
|
||||||
|
const cat = inp.category ?? ex.category ?? 'L3';
|
||||||
|
t.category = VALID_CATEGORIES.includes(cat) ? cat : 'L3';
|
||||||
|
|
||||||
|
const prio = inp.priority ?? ex.priority ?? 'P3';
|
||||||
|
t.priority = VALID_PRIORITIES.includes(prio) ? prio : 'P3';
|
||||||
|
|
||||||
|
const stat = inp.status ?? ex.status ?? 'todo';
|
||||||
|
t.status = VALID_STATUS.includes(stat) ? stat : 'todo';
|
||||||
|
|
||||||
|
t.estHours = inp.estHours != null
|
||||||
|
? Math.max(0, Math.min(999, Number(inp.estHours) || 0))
|
||||||
|
: (ex.estHours ?? 0);
|
||||||
|
t.location = sanitizeStr(inp.location ?? ex.location ?? '', 120);
|
||||||
|
|
||||||
|
const dl = inp.deadline ?? ex.deadline ?? '';
|
||||||
|
t.deadline = typeof dl === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dl) ? dl : '';
|
||||||
|
|
||||||
|
t.tags = sanitizeTags(inp.tags ?? ex.tags ?? []);
|
||||||
|
|
||||||
|
const owner = inp.owner != null ? sanitizeStr(inp.owner, 64) : (ex.owner ?? '');
|
||||||
|
t.owner = owner;
|
||||||
|
|
||||||
|
t.comments = opts.importComments ? sanitizeComments(inp.comments) : (ex.comments || []);
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
t.created = ex.created || now;
|
||||||
|
t.createdBy = ex.createdBy || user;
|
||||||
|
t.updated = now;
|
||||||
|
t.updatedBy = user;
|
||||||
|
t.v = (ex.v || 0) + 1;
|
||||||
|
|
||||||
|
// Fullført-tidsstempel
|
||||||
|
const wasDone = ex.status === 'done';
|
||||||
|
if (t.status === 'done' && !wasDone) {
|
||||||
|
t.completed = now.slice(0, 10);
|
||||||
|
t.completedBy = user;
|
||||||
|
} else if (t.status !== 'done') {
|
||||||
|
t.completed = null;
|
||||||
|
t.completedBy = null;
|
||||||
|
} else {
|
||||||
|
t.completed = ex.completed || now.slice(0, 10);
|
||||||
|
t.completedBy = ex.completedBy || user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beskriver hva som endret seg, til aktivitetsloggen.
|
||||||
|
function diffDetail(before, after) {
|
||||||
|
const detail = [];
|
||||||
|
if (before.status !== after.status) {
|
||||||
|
detail.push(`${STATUS_LABELS[before.status]} → ${STATUS_LABELS[after.status]}`);
|
||||||
|
}
|
||||||
|
if (before.priority !== after.priority) detail.push(`${before.priority} → ${after.priority}`);
|
||||||
|
if ((before.owner || '') !== (after.owner || '')) detail.push(`eier → ${after.owner || 'utildelt'}`);
|
||||||
|
if (before.deadline !== after.deadline) detail.push(`frist → ${after.deadline || 'ingen'}`);
|
||||||
|
const silent = [];
|
||||||
|
if (before.title !== after.title) silent.push('tittel');
|
||||||
|
if (before.desc !== after.desc) silent.push('beskrivelse');
|
||||||
|
if (before.category !== after.category) silent.push('kategori');
|
||||||
|
if (before.location !== after.location) silent.push('lokasjon');
|
||||||
|
if (before.estHours !== after.estHours) silent.push('estimat');
|
||||||
|
if (JSON.stringify(before.tags) !== JSON.stringify(after.tags)) silent.push('tags');
|
||||||
|
if (silent.length) detail.push(silent.join(', '));
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Store {
|
||||||
|
constructor(dataDir) {
|
||||||
|
this.dataDir = dataDir;
|
||||||
|
this.tasksFile = path.join(dataDir, 'tasks.json');
|
||||||
|
this.activityFile = path.join(dataDir, 'activity.json');
|
||||||
|
this.usersFile = path.join(dataDir, 'users.json');
|
||||||
|
this.tasks = [];
|
||||||
|
this.activity = [];
|
||||||
|
this.knownUsers = new Set();
|
||||||
|
this._writeChain = Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
load() {
|
||||||
|
fs.mkdirSync(this.dataDir, { recursive: true });
|
||||||
|
this.tasks = this._readJson(this.tasksFile, []);
|
||||||
|
this.activity = this._readJson(this.activityFile, []);
|
||||||
|
for (const u of this._readJson(this.usersFile, [])) this.knownUsers.add(u);
|
||||||
|
for (const t of this.tasks) {
|
||||||
|
for (const k of ['owner', 'createdBy', 'updatedBy', 'completedBy']) {
|
||||||
|
if (t[k]) this.knownUsers.add(t[k]);
|
||||||
|
}
|
||||||
|
if (!t.v) t.v = 1;
|
||||||
|
if (!Array.isArray(t.comments)) t.comments = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_readJson(file, fallback) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
return Array.isArray(data) ? data : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomisk, serialisert skriv: tmp-fil i samme katalog, så rename.
|
||||||
|
_persist(file, data) {
|
||||||
|
this._writeChain = this._writeChain.then(async () => {
|
||||||
|
const tmp = file + '.tmp';
|
||||||
|
await fs.promises.writeFile(tmp, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
||||||
|
await fs.promises.rename(tmp, file);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(`Klarte ikke å skrive ${file}:`, err.message);
|
||||||
|
});
|
||||||
|
return this._writeChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistTasks() { return this._persist(this.tasksFile, this.tasks); }
|
||||||
|
_persistActivity() { return this._persist(this.activityFile, this.activity); }
|
||||||
|
|
||||||
|
rememberUser(user) {
|
||||||
|
if (user && !this.knownUsers.has(user)) {
|
||||||
|
this.knownUsers.add(user);
|
||||||
|
this._persist(this.usersFile, [...this.knownUsers].sort());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addActivity(user, action, { taskId = null, title = '', detail = [] } = {}) {
|
||||||
|
const entry = {
|
||||||
|
id: uid('a'),
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
user,
|
||||||
|
action,
|
||||||
|
taskId,
|
||||||
|
title: sanitizeStr(title, 200),
|
||||||
|
detail,
|
||||||
|
};
|
||||||
|
this.activity.unshift(entry);
|
||||||
|
if (this.activity.length > MAX_ACTIVITY) this.activity.length = MAX_ACTIVITY;
|
||||||
|
this._persistActivity();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
list() { return this.tasks; }
|
||||||
|
|
||||||
|
get(id) {
|
||||||
|
return this.tasks.find(t => t.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
create(input, user) {
|
||||||
|
// importComments: lar "angre sletting" og import gjenopprette kommentarer
|
||||||
|
const task = normalizeTask(input, user, null, { importComments: true });
|
||||||
|
if (!task.title) throw new Error('title er påkrevd');
|
||||||
|
if (this.get(task.id)) task.id = uid('t'); // unngå kollisjon ved gjenbrukt id (angre-slett)
|
||||||
|
this.tasks.push(task);
|
||||||
|
this.rememberUser(user);
|
||||||
|
if (task.owner) this.rememberUser(task.owner);
|
||||||
|
this._persistTasks();
|
||||||
|
const activity = this.addActivity(user, 'create', { taskId: task.id, title: task.title });
|
||||||
|
return { task, activity };
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id, input, user) {
|
||||||
|
const existing = this.get(id);
|
||||||
|
if (!existing) throw new NotFoundError('Fant ikke oppgaven');
|
||||||
|
// Optimistisk låsing: klienten sender versjonen den redigerte fra.
|
||||||
|
if (input && input.v != null && Number(input.v) !== existing.v) {
|
||||||
|
throw new ConflictError('Oppgaven er endret av noen andre', existing);
|
||||||
|
}
|
||||||
|
const updated = normalizeTask(input, user, existing);
|
||||||
|
const idx = this.tasks.indexOf(existing);
|
||||||
|
this.tasks[idx] = updated;
|
||||||
|
this.rememberUser(user);
|
||||||
|
if (updated.owner) this.rememberUser(updated.owner);
|
||||||
|
this._persistTasks();
|
||||||
|
|
||||||
|
let action = 'update';
|
||||||
|
if (existing.status !== 'done' && updated.status === 'done') action = 'done';
|
||||||
|
else if (existing.status === 'done' && updated.status !== 'done') action = 'reopen';
|
||||||
|
const activity = this.addActivity(user, action, {
|
||||||
|
taskId: id, title: updated.title, detail: diffDetail(existing, updated),
|
||||||
|
});
|
||||||
|
return { task: updated, activity };
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id, user) {
|
||||||
|
const existing = this.get(id);
|
||||||
|
if (!existing) throw new NotFoundError('Fant ikke oppgaven');
|
||||||
|
this.tasks = this.tasks.filter(t => t.id !== id);
|
||||||
|
this._persistTasks();
|
||||||
|
const activity = this.addActivity(user, 'delete', { taskId: id, title: existing.title });
|
||||||
|
return { task: existing, activity };
|
||||||
|
}
|
||||||
|
|
||||||
|
addComment(id, text, user) {
|
||||||
|
const task = this.get(id);
|
||||||
|
if (!task) throw new NotFoundError('Fant ikke oppgaven');
|
||||||
|
const clean = sanitizeStr(text, 1000);
|
||||||
|
if (!clean) throw new Error('Tom kommentar');
|
||||||
|
const comment = { id: uid('c'), by: user, at: new Date().toISOString(), text: clean };
|
||||||
|
task.comments.push(comment);
|
||||||
|
if (task.comments.length > MAX_COMMENTS) task.comments.shift();
|
||||||
|
this.rememberUser(user);
|
||||||
|
this._persistTasks();
|
||||||
|
const activity = this.addActivity(user, 'comment', {
|
||||||
|
taskId: id, title: task.title, detail: [clean.slice(0, 80)],
|
||||||
|
});
|
||||||
|
return { task, comment, activity };
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteComment(id, commentId, user) {
|
||||||
|
const task = this.get(id);
|
||||||
|
if (!task) throw new NotFoundError('Fant ikke oppgaven');
|
||||||
|
const comment = task.comments.find(c => c.id === commentId);
|
||||||
|
if (!comment) throw new NotFoundError('Fant ikke kommentaren');
|
||||||
|
if (comment.by !== user) throw new Error('Kan bare slette egne kommentarer');
|
||||||
|
task.comments = task.comments.filter(c => c.id !== commentId);
|
||||||
|
this._persistTasks();
|
||||||
|
return { task };
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceAll(arr, user, action = 'import') {
|
||||||
|
if (!Array.isArray(arr)) throw new Error('Forventet array');
|
||||||
|
const out = [];
|
||||||
|
for (const raw of arr) {
|
||||||
|
if (!raw || typeof raw !== 'object' || !raw.title) continue;
|
||||||
|
out.push(normalizeTask(raw, user, null, { importComments: true }));
|
||||||
|
}
|
||||||
|
this.tasks = out;
|
||||||
|
this._persistTasks();
|
||||||
|
const activity = this.addActivity(user, action, { detail: [`${out.length} oppgaver`] });
|
||||||
|
return { count: out.length, activity };
|
||||||
|
}
|
||||||
|
|
||||||
|
seed(seedArr, user) {
|
||||||
|
if (this.tasks.length > 0) throw new ConflictError('Data finnes allerede', null);
|
||||||
|
return this.replaceAll(seedArr, user, 'seed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Store, NotFoundError, ConflictError, normalizeTask, VALID_CATEGORIES, VALID_PRIORITIES, VALID_STATUS };
|
||||||
853
netops-todo-node/package-lock.json
generated
Normal file
853
netops-todo-node/package-lock.json
generated
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
netops-todo-node/package.json
Normal file
20
netops-todo-node/package.json
Normal 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
|
||||||
|
}
|
||||||
1081
netops-todo-node/public/app.js
Normal file
1081
netops-todo-node/public/app.js
Normal file
File diff suppressed because it is too large
Load diff
271
netops-todo-node/public/index.html
Normal file
271
netops-todo-node/public/index.html
Normal 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) · p1–p4 · !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>
|
||||||
354
netops-todo-node/public/style.css
Normal file
354
netops-todo-node/public/style.css
Normal 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; }
|
||||||
|
}
|
||||||
18
netops-todo-node/seed.json
Normal file
18
netops-todo-node/seed.json
Normal 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:00–04:00. Backup konfig, ISSU der det støttes. Rollback plan.","category":"L2","priority":"P2","status":"todo","estHours":3,"location":"DC-OSL-1","tags":["vedlikehold","oppgradering"]},
|
||||||
|
{"title":"Legg til route reflector i iBGP-mesh","desc":"Reduser full-mesh. RR-klienter: alle PE-rutere. Test failover mellom RR1/RR2.","category":"BGP","priority":"P3","status":"todo","estHours":5,"location":"remote","tags":["design"]},
|
||||||
|
{"title":"Review FW-regler før Q2 audit","desc":"Finn og rydd vekk ubrukte regler, dupliserte objekter. Eksportér rapport til sec-team.","category":"SEC","priority":"P2","status":"blocked","estHours":8,"location":"remote","tags":["audit","compliance"]},
|
||||||
|
{"title":"Bytt batteri på UPS i Trondheim POP","desc":"Batteritest feiler. Kontakt kjøpetjeneste for reservedel. Krever fysisk oppmøte.","category":"L1","priority":"P2","status":"todo","estHours":2,"location":"POP-TRD","tags":["felt","trondheim"]},
|
||||||
|
{"title":"Oppdater LibreNMS poller-nodes","desc":"v24.x → v25.x. Backup MySQL, test med staging-poller først.","category":"NMS","priority":"P4","status":"todo","estHours":1,"location":"remote","tags":["vedlikehold"]},
|
||||||
|
{"title":"Dokumentér nye VLAN-er (120-135) i IPAM","desc":"Oppdater NetBox. Inkluder prefix, site, role og tenant.","category":"DOC","priority":"P3","status":"progress","estHours":2,"location":"remote","tags":["netbox"]},
|
||||||
|
{"title":"Ansible-playbook for standard switch-config","desc":"Base config, NTP, SNMP, AAA, banner. Idempotent. Test i lab først.","category":"AUTO","priority":"P3","status":"todo","estHours":6,"location":"remote","tags":["automatisering"]},
|
||||||
|
{"title":"Postmortem for uptime-hendelse 2026-04-19","desc":"45 min utfall etter oppgradering. RCA + tiltaksliste. Del med ledergruppe.","category":"INC","priority":"P2","status":"progress","estHours":3,"location":"remote","tags":["postmortem"]},
|
||||||
|
{"title":"Planlegg 100G oppgradering DC-BGO ↔ DC-OSL","desc":"Kostestimat, lead time optikk, migreringsvindu, rollback.","category":"PROJ","priority":"P3","status":"todo","estHours":10,"location":"remote","tags":["planlegging","capex"]},
|
||||||
|
{"title":"Verifisér BFD timere på alle OSPF-lenker","desc":"Mål: 150ms detection. Sjekk at alle rutere støtter og har konfigurert.","category":"L3","priority":"P4","status":"todo","estHours":2,"location":"remote","tags":["optimalisering"]}
|
||||||
|
]
|
||||||
254
netops-todo-node/server.js
Normal file
254
netops-todo-node/server.js
Normal 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 };
|
||||||
117
netops-todo-node/setup-debian13.sh
Executable file
117
netops-todo-node/setup-debian13.sh
Executable 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
|
||||||
209
netops-todo-node/test/api.test.js
Normal file
209
netops-todo-node/test/api.test.js
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Integrasjonstester: booter serveren på tilfeldig port med en midlertidig
|
||||||
|
* datakatalog og tester REST-API + WebSocket-broadcast ende-til-ende.
|
||||||
|
* Kjør med: npm test
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { test, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const { createServer } = require('../server');
|
||||||
|
|
||||||
|
let server, baseUrl, dataDir;
|
||||||
|
|
||||||
|
function req(method, p, body = null, headers = {}) {
|
||||||
|
return fetch(baseUrl + p, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Remote-User': 'testuser', ...headers },
|
||||||
|
body: body !== null ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'netops-test-'));
|
||||||
|
({ server } = createServer({ dataDir }));
|
||||||
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
server.close();
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state starter tomt og identifiserer bruker fra header', async () => {
|
||||||
|
const res = await req('GET', '/api/state');
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const data = await res.json();
|
||||||
|
assert.equal(data.user, 'testuser');
|
||||||
|
assert.deepEqual(data.tasks, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('seed fyller tom database, og nekter når data finnes', async () => {
|
||||||
|
let res = await req('POST', '/api/seed');
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const { count } = await res.json();
|
||||||
|
assert.equal(count, 16);
|
||||||
|
|
||||||
|
res = await req('POST', '/api/seed');
|
||||||
|
assert.equal(res.status, 409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create normaliserer og setter audit-felter', async () => {
|
||||||
|
const res = await req('POST', '/api/tasks', {
|
||||||
|
title: 'Test BGP-peer', category: 'BGP', priority: 'P1',
|
||||||
|
status: 'ugyldig-status', estHours: 5000, tags: ['x', '', 'y'],
|
||||||
|
deadline: 'ikke-en-dato', desc: 'hei\x00der',
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
const { task } = await res.json();
|
||||||
|
assert.equal(task.status, 'todo'); // ugyldig → default
|
||||||
|
assert.equal(task.estHours, 999); // klampet
|
||||||
|
assert.deepEqual(task.tags, ['x', 'y']); // tomme fjernet
|
||||||
|
assert.equal(task.deadline, ''); // ugyldig format
|
||||||
|
assert.equal(task.desc, 'heider'); // kontrolltegn fjernet
|
||||||
|
assert.equal(task.createdBy, 'testuser');
|
||||||
|
assert.equal(task.v, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create uten tittel feiler', async () => {
|
||||||
|
const res = await req('POST', '/api/tasks', { desc: 'mangler tittel' });
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update med riktig versjon ok, feil versjon gir 409 med serverens task', async () => {
|
||||||
|
let res = await req('POST', '/api/tasks', { title: 'Versjonstest' });
|
||||||
|
const { task } = await res.json();
|
||||||
|
|
||||||
|
res = await req('PUT', `/api/tasks/${task.id}`, { ...task, status: 'progress', v: task.v });
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const { task: t2 } = await res.json();
|
||||||
|
assert.equal(t2.status, 'progress');
|
||||||
|
assert.equal(t2.v, 2);
|
||||||
|
|
||||||
|
// Lagring fra utdatert versjon skal avvises
|
||||||
|
res = await req('PUT', `/api/tasks/${task.id}`, { ...task, status: 'blocked', v: 1 });
|
||||||
|
assert.equal(res.status, 409);
|
||||||
|
const conflict = await res.json();
|
||||||
|
assert.equal(conflict.task.v, 2);
|
||||||
|
assert.equal(conflict.task.status, 'progress');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('done setter completed, gjenåpning nullstiller', async () => {
|
||||||
|
let res = await req('POST', '/api/tasks', { title: 'Ferdigtest' });
|
||||||
|
let { task } = await res.json();
|
||||||
|
|
||||||
|
res = await req('PUT', `/api/tasks/${task.id}`, { v: task.v, status: 'done' });
|
||||||
|
({ task } = await res.json());
|
||||||
|
assert.equal(task.completed, new Date().toISOString().slice(0, 10));
|
||||||
|
assert.equal(task.completedBy, 'testuser');
|
||||||
|
// Delvis update skal beholde øvrige felter
|
||||||
|
assert.equal(task.title, 'Ferdigtest');
|
||||||
|
|
||||||
|
res = await req('PUT', `/api/tasks/${task.id}`, { v: task.v, status: 'todo' });
|
||||||
|
({ task } = await res.json());
|
||||||
|
assert.equal(task.completed, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kommentarer: legg til, slett egne, nekt andres', async () => {
|
||||||
|
let res = await req('POST', '/api/tasks', { title: 'Kommentartest' });
|
||||||
|
const { task } = await res.json();
|
||||||
|
|
||||||
|
res = await req('POST', `/api/tasks/${task.id}/comments`, { text: 'Første kommentar' });
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
const { comment } = await res.json();
|
||||||
|
assert.equal(comment.by, 'testuser');
|
||||||
|
|
||||||
|
// Annen bruker får ikke slette
|
||||||
|
res = await req('DELETE', `/api/tasks/${task.id}/comments/${comment.id}`, null, { 'X-Remote-User': 'kari' });
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
|
||||||
|
res = await req('DELETE', `/api/tasks/${task.id}/comments/${comment.id}`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const { task: after } = await res.json();
|
||||||
|
assert.equal(after.comments.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete fjerner oppgave, 404 ved ukjent id', async () => {
|
||||||
|
let res = await req('POST', '/api/tasks', { title: 'Slettetest' });
|
||||||
|
const { task } = await res.json();
|
||||||
|
res = await req('DELETE', `/api/tasks/${task.id}`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
res = await req('DELETE', `/api/tasks/${task.id}`);
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aktivitetslogg føres', async () => {
|
||||||
|
const res = await req('GET', '/api/state');
|
||||||
|
const { activity } = await res.json();
|
||||||
|
assert.ok(activity.length > 0);
|
||||||
|
assert.ok(activity.some(a => a.action === 'create'));
|
||||||
|
assert.ok(activity.some(a => a.action === 'done'));
|
||||||
|
assert.ok(activity.some(a => a.action === 'comment'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('websocket: mottar sync ved tilkobling og broadcast ved endringer', async () => {
|
||||||
|
const wsUrl = baseUrl.replace('http', 'ws') + '/ws';
|
||||||
|
const wsClient = new WebSocket(wsUrl, { headers: { 'X-Remote-User': 'kari' } });
|
||||||
|
const messages = [];
|
||||||
|
wsClient.on('message', raw => messages.push(JSON.parse(raw)));
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
wsClient.on('open', resolve);
|
||||||
|
wsClient.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vent på hello + sync
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
const hello = messages.find(m => m.type === 'hello');
|
||||||
|
assert.equal(hello.user, 'kari');
|
||||||
|
assert.ok(messages.some(m => m.type === 'sync' && Array.isArray(m.tasks)));
|
||||||
|
|
||||||
|
// En REST-endring fra annen bruker skal pushes til kari
|
||||||
|
const res = await req('POST', '/api/tasks', { title: 'WS-broadcast-test' });
|
||||||
|
const { task } = await res.json();
|
||||||
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
const broadcastMsg = messages.find(m => m.type === 'task' && m.op === 'create' && m.task.id === task.id);
|
||||||
|
assert.ok(broadcastMsg, 'broadcast ikke mottatt');
|
||||||
|
assert.equal(broadcastMsg.by, 'testuser');
|
||||||
|
assert.equal(broadcastMsg.activity.action, 'create');
|
||||||
|
|
||||||
|
wsClient.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replace erstatter alt og persisterer til disk', async () => {
|
||||||
|
const res = await req('POST', '/api/replace', [
|
||||||
|
{ title: 'Eneste oppgave', category: 'NMS', priority: 'P4' },
|
||||||
|
{ ugyldig: 'uten tittel' },
|
||||||
|
]);
|
||||||
|
const { count } = await res.json();
|
||||||
|
assert.equal(count, 1);
|
||||||
|
|
||||||
|
// Vent på at skrivekøen tømmes, og verifiser fila på disk
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
const onDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'tasks.json'), 'utf8'));
|
||||||
|
assert.equal(onDisk.length, 1);
|
||||||
|
assert.equal(onDisk[0].title, 'Eneste oppgave');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cookie-auth: login setter cookie, ugyldig navn avvises', async () => {
|
||||||
|
let res = await fetch(baseUrl + '/api/login', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: 'jon.v' }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.match(res.headers.get('set-cookie'), /netops_user=jon\.v/);
|
||||||
|
|
||||||
|
// Brukeren identifiseres via cookien
|
||||||
|
res = await fetch(baseUrl + '/api/state', { headers: { Cookie: 'netops_user=jon.v' } });
|
||||||
|
assert.equal((await res.json()).user, 'jon.v');
|
||||||
|
|
||||||
|
res = await fetch(baseUrl + '/api/login', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: '<script>' }),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
|
|
||||||
> Også egnet som `CLAUDE.md` for fremtidige Claude-sesjoner som jobber videre med koden.
|
> Også egnet som `CLAUDE.md` for fremtidige Claude-sesjoner som jobber videre med koden.
|
||||||
|
|
||||||
|
> **Merk (2026-06-12):** Det finnes nå en videreutviklet Node/npm-versjon i
|
||||||
|
> `../netops-todo-node/` med WebSocket-sanntidssynk, kanban-tavle, kommentarer,
|
||||||
|
> aktivitetslogg og konfliktdeteksjon. Se `CLAUDE.md` der. Denne PHP-versjonen
|
||||||
|
> beholdes som den er for ren Apache/mod_php-hosting.
|
||||||
|
|
||||||
## Hva er dette
|
## Hva er dette
|
||||||
|
|
||||||
En enkel, selvstendig to-do-applikasjon tilpasset en network engineer som jobber bredt: L1 felt-kabling, L2 switching, L3 ruting, L4 lastbalansering, BGP, peering, security, NMS, automatisering, dokumentasjon, incident og prosjekt. Hver oppgave har prioritet (P1–P4), estimert tid, status, lokasjon/site, frist, tags og eier.
|
En enkel, selvstendig to-do-applikasjon tilpasset en network engineer som jobber bredt: L1 felt-kabling, L2 switching, L3 ruting, L4 lastbalansering, BGP, peering, security, NMS, automatisering, dokumentasjon, incident og prosjekt. Hver oppgave har prioritet (P1–P4), estimert tid, status, lokasjon/site, frist, tags og eier.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue