netops/netops-todo.html
2026-06-12 11:17:07 +02:00

1312 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1"></script>
<style>
: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;
--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: 1500px;
margin: 0 auto;
padding: 20px;
}
/* Header */
header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
header h1 {
font-size: 22px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
header h1::before {
content: "";
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 12px var(--ok);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
header .sub {
color: var(--text-dim);
font-size: 13px;
font-family: var(--mono);
}
.header-actions {
display: flex;
gap: 8px;
}
/* Buttons */
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, .btn.primary {
background: var(--accent);
color: #06151f;
border-color: var(--accent);
font-weight: 600;
}
button.primary:hover, .btn.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);
}
/* KPI row */
.kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: var(--gap);
margin-bottom: var(--gap);
}
.kpi {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
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-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-dim);
margin-bottom: 6px;
font-weight: 600;
}
.kpi-value {
font-size: 26px;
font-weight: 700;
font-family: var(--mono);
line-height: 1.1;
}
.kpi-sub {
font-size: 12px;
color: var(--text-dim);
margin-top: 4px;
}
/* Chart row */
.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: 220px;
}
/* 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: 200px;
}
/* Task list 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.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: 400px;
}
/* Badges */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
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;
}
.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: 11px;
padding: 2px 7px;
border-radius: 4px;
background: var(--accent-soft);
color: var(--accent);
border: 1px solid rgba(79, 195, 247, 0.25);
}
.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); }
.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); }
/* 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: 620px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.modal-head {
padding: 18px 22px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-head h2 { font-size: 16px; font-weight: 600; }
.modal-body {
padding: 20px 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; }
.modal-foot {
padding: 14px 22px;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
gap: 8px;
}
.empty-state {
padding: 40px;
text-align: center;
color: var(--text-dim);
}
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);
}
/* Responsive */
@media (max-width: 1100px) {
.chart-row { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 720px) {
.chart-row { grid-template-columns: 1fr; }
.modal-body { grid-template-columns: 1fr; }
.kpi-value { font-size: 22px; }
header h1 { font-size: 18px; }
}
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1>NetOps To-Do</h1>
<div class="sub" id="clock">network-engineer@nms:~$</div>
</div>
<div class="header-actions">
<button id="exportBtn" title="Eksporter JSON">⬇ Eksporter</button>
<button id="importBtn" title="Importer JSON">⬆ Importer</button>
<input type="file" id="importFile" accept=".json" style="display:none">
<button class="primary" id="newTaskBtn">+ Ny oppgave</button>
</div>
</header>
<!-- KPIs -->
<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-p1">
<div class="kpi-label">P1 Kritisk</div>
<div class="kpi-value" id="kpi-p1">0</div>
<div class="kpi-sub" id="kpi-p1-sub">krever 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" id="kpi-done-sub">siste 7 dager</div>
</div>
</section>
<!-- Charts -->
<section class="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>Ferdigstilt siste 14 dager</h3>
<div class="chart-wrap"><canvas id="trendChart"></canvas></div>
</div>
</section>
<!-- Filters -->
<div class="filters">
<div class="filter-group">
<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>
<input class="search-input" type="text" id="f-search" placeholder="Søk tittel, beskrivelse, lokasjon, tag ...">
<button id="clearFilters">Nullstill</button>
</div>
<!-- Table -->
<div class="table-panel">
<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="location" style="width:130px">Lokasjon</th>
<th data-sort="estHours" style="width:70px;text-align:right">Est (t)</th>
<th data-sort="deadline" style="width:120px">Frist</th>
<th style="width:110px;text-align:right">Handling</th>
</tr>
</thead>
<tbody id="tbl-body"></tbody>
</table>
</div>
<div id="empty" class="empty-state" style="display:none">
Ingen oppgaver som matcher filtrene. Prøv å nullstille filtre eller legg til en ny oppgave.
</div>
</div>
<footer>
Lagres lokalt i nettleseren · Kan eksporteres til JSON for backup
</footer>
</div>
<!-- Task Modal -->
<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-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">
<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>
<div class="modal-foot">
<button class="danger" id="modal-delete" style="display:none">Slett</button>
<div style="flex:1"></div>
<button id="modal-cancel">Avbryt</button>
<button class="primary" id="modal-save">Lagre</button>
</div>
</div>
</div>
<script>
// ---------- Constants ----------
const CATEGORIES = [
{ id: 'L1', name: 'L1 Fysisk', desc: 'Kabling, fiber, patch, SFP, rack' },
{ id: 'L2', name: 'L2 Switching', desc: 'VLAN, STP, MAC, trunking' },
{ id: 'L3', name: 'L3 Ruting', desc: 'OSPF, ISIS, statisk, VRF' },
{ id: 'L4', name: 'L4 Lastbalansering',desc: 'HAProxy, NGINX, F5, BIG-IP' },
{ id: 'BGP', name: 'BGP', desc: 'iBGP / eBGP, policies, RR' },
{ id: 'PEER', name: 'Peering / IX', desc: 'IXP, private peering, PeeringDB' },
{ id: 'SEC', name: 'Firewall / Security', desc: 'ACL, firewall, IPS, audit' },
{ id: 'NMS', name: 'Overvåking / NMS', desc: 'LibreNMS, Grafana, alerts' },
{ id: 'AUTO', name: 'Automatisering', desc: 'Ansible, scripts, CI/CD' },
{ id: 'DOC', name: 'Dokumentasjon', desc: 'Diagrammer, IPAM, runbooks' },
{ id: 'INC', name: 'Incident / On-call',desc: 'Feilsøking, postmortem' },
{ id: 'PROJ', name: 'Prosjekt / Plan', desc: 'Design, anskaffelse, oppgraderinger' },
];
const CAT_COLORS = {
L1: '#ff9f43', L2: '#4fc3f7', L3: '#7fb8ff', L4: '#bb86fc',
BGP: '#4ade80', PEER: '#26de81', SEC: '#ff5370', NMS: '#ffd93d',
AUTO: '#00d2d3', DOC: '#a0a0a0', INC: '#ff6b6b', PROJ: '#8172B3'
};
const STATUS_LABELS = {
todo: 'Å gjøre', progress: 'Pågår', blocked: 'Blokkert', done: 'Ferdig'
};
const PRIO_ORDER = { P1: 1, P2: 2, P3: 3, P4: 4 };
// ---------- Sample seed data ----------
const SAMPLE_TASKS = [
{ 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', deadline: daysFromNow(0), 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', deadline: daysFromNow(1), 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', deadline: daysFromNow(3), 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', deadline: daysFromNow(2), 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', deadline: daysFromNow(5), 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', deadline: daysFromNow(7), tags: ['felt','bergen'] },
{ title: 'Oppgrader IOS-XE på dist-sw03 til 05',
desc: 'Vedlikeholdsvindu 02:0004:00. Backup konfig, ISSU der det støttes. Rollback plan.',
category: 'L2', priority: 'P2', status: 'todo', estHours: 3,
location: 'DC-OSL-1', deadline: daysFromNow(4), 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', deadline: daysFromNow(10), 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', deadline: daysFromNow(14), 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', deadline: daysFromNow(6), 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', deadline: daysFromNow(12), 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', deadline: daysFromNow(3), 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', deadline: daysFromNow(14), 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', deadline: daysFromNow(2), 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', deadline: daysFromNow(30), 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', deadline: daysFromNow(20), tags: ['optimalisering'] },
// A few completed tasks for the trend chart
{ title: 'Ferdig: Bytt strømkurs i rack A3', category: 'L1', priority: 'P3',
status: 'done', estHours: 2, location: 'DC-OSL-1',
completed: daysFromNow(-2), tags: ['felt'] },
{ title: 'Ferdig: Aktivér BFD på eBGP mot AS1299', category: 'BGP', priority: 'P3',
status: 'done', estHours: 1, location: 'remote',
completed: daysFromNow(-4), tags: ['optimalisering'] },
{ title: 'Ferdig: Oppdater runbook for core-sw reboot', category: 'DOC', priority: 'P4',
status: 'done', estHours: 2, location: 'remote',
completed: daysFromNow(-1), tags: ['runbook'] },
{ title: 'Ferdig: Løst MAC-flap alarm på acc-sw14', category: 'L2', priority: 'P2',
status: 'done', estHours: 2, location: 'DC-OSL-1',
completed: daysFromNow(-6), tags: ['feilsøking'] },
];
function daysFromNow(d) {
const date = new Date();
date.setDate(date.getDate() + d);
return date.toISOString().slice(0, 10);
}
// ---------- State / Storage ----------
const STORAGE_KEY = 'netops-todo-v1';
function uid() {
return 't_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36).slice(-4);
}
function loadTasks() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch (e) { console.warn('Load error', e); }
// First run: seed with sample tasks
const seeded = SAMPLE_TASKS.map(t => ({
id: uid(),
title: t.title,
desc: t.desc || '',
category: t.category,
priority: t.priority,
status: t.status,
estHours: t.estHours || 0,
location: t.location || '',
deadline: t.deadline || '',
tags: t.tags || [],
created: new Date().toISOString(),
completed: t.completed || null
}));
saveTasks(seeded);
return seeded;
}
function saveTasks(tasks) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)); }
catch (e) { console.warn('Save error', e); }
}
let state = {
tasks: loadTasks(),
filters: { status: 'open', category: 'all', priority: 'all', search: '' },
sort: { field: 'priority', dir: 'asc' },
editingId: null,
};
let charts = {};
// ---------- Rendering ----------
function formatDeadline(dateStr) {
if (!dateStr) return { text: '—', cls: '' };
const d = new Date(dateStr);
const today = new Date(); today.setHours(0,0,0,0);
const diff = Math.round((d - today) / 86400000);
let text, cls = '';
if (diff < 0) { text = `${Math.abs(diff)}d forfalt`; cls = 'overdue'; }
else if (diff === 0) { text = 'I dag'; cls = 'soon'; }
else if (diff === 1) { text = 'I morgen'; cls = 'soon'; }
else if (diff <= 3) { text = `${diff}d igjen`; cls = 'soon'; }
else { text = dateStr; }
return { text, cls };
}
function applyFilters(tasks) {
const f = state.filters;
return tasks.filter(t => {
if (f.status === 'open') { if (t.status === 'done') return false; }
else if (f.status !== 'all' && t.status !== f.status) return false;
if (f.category !== 'all' && t.category !== f.category) return false;
if (f.priority !== 'all' && t.priority !== f.priority) return false;
if (f.search) {
const q = f.search.toLowerCase();
const hay = [t.title, t.desc, t.location, (t.tags||[]).join(' ')].join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}
function sortTasks(tasks) {
const { field, dir } = state.sort;
const sign = dir === 'asc' ? 1 : -1;
return [...tasks].sort((a, b) => {
let av = a[field], bv = b[field];
if (field === 'priority') { av = PRIO_ORDER[av]; bv = PRIO_ORDER[bv]; }
if (field === 'deadline') { av = av || '9999'; bv = bv || '9999'; }
if (field === 'estHours') { av = av || 0; bv = bv || 0; }
if (av == null) av = '';
if (bv == null) bv = '';
if (av < bv) return -1 * sign;
if (av > bv) return 1 * sign;
return 0;
});
}
function render() {
renderKPIs();
renderCharts();
renderTable();
}
function renderKPIs() {
const open = state.tasks.filter(t => t.status !== 'done');
const total = state.tasks.length;
const p1 = open.filter(t => t.priority === 'P1').length;
const hours = open.reduce((sum, t) => sum + (t.estHours || 0), 0);
const today = new Date().toISOString().slice(0,10);
const overdue = open.filter(t => t.deadline && t.deadline < today).length;
const weekAgo = daysFromNow(-7);
const done7 = state.tasks.filter(t => t.status === 'done' && t.completed && t.completed >= weekAgo).length;
document.getElementById('kpi-open').textContent = open.length;
document.getElementById('kpi-open-sub').textContent = `av ${total} totalt`;
document.getElementById('kpi-p1').textContent = p1;
document.getElementById('kpi-hours').textContent = hours.toFixed(1).replace(/\.0$/,'') + 't';
document.getElementById('kpi-hours-sub').textContent = hours > 8 ? `${(hours/8).toFixed(1)} arbeidsdager` : 'arbeidstimer';
document.getElementById('kpi-overdue').textContent = overdue;
document.getElementById('kpi-done').textContent = done7;
}
function renderCharts() {
const open = state.tasks.filter(t => t.status !== 'done');
// By category
const byCat = {};
CATEGORIES.forEach(c => { byCat[c.id] = 0; });
open.forEach(t => { byCat[t.category] = (byCat[t.category] || 0) + 1; });
const catEntries = Object.entries(byCat).filter(([,v]) => v > 0)
.sort((a,b) => b[1] - a[1]);
document.getElementById('cat-total').textContent = `${open.length} åpne`;
// By priority
const byPrio = { P1:0, P2:0, P3:0, P4:0 };
open.forEach(t => { byPrio[t.priority]++; });
// Trend (last 14 days)
const days = [];
for (let i = 13; i >= 0; i--) days.push(daysFromNow(-i));
const completedByDay = days.map(d => state.tasks.filter(t => t.status === 'done' && t.completed === d).length);
// Destroy existing
Object.values(charts).forEach(c => c && c.destroy());
const commonFont = { family: "-apple-system, 'Segoe UI', sans-serif", size: 11 };
charts.cat = new Chart(document.getElementById('catChart'), {
type: 'bar',
data: {
labels: catEntries.map(([k]) => CATEGORIES.find(c => c.id === k).name),
datasets: [{
data: catEntries.map(([,v]) => v),
backgroundColor: catEntries.map(([k]) => CAT_COLORS[k] + 'CC'),
borderColor: catEntries.map(([k]) => CAT_COLORS[k]),
borderWidth: 1,
borderRadius: 4,
}]
},
options: {
indexAxis: 'y',
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => `${ctx.parsed.x} oppgave${ctx.parsed.x===1?'':'r'}` } }
},
scales: {
x: { beginAtZero: true, ticks: { color: '#8b98a9', font: commonFont, stepSize: 1 }, grid: { color: '#2a3340' } },
y: { ticks: { color: '#e6edf3', font: commonFont }, grid: { display: false } }
}
}
});
charts.prio = new Chart(document.getElementById('prioChart'), {
type: 'doughnut',
data: {
labels: ['P1 Kritisk','P2 Høy','P3 Middels','P4 Lav'],
datasets: [{
data: [byPrio.P1, byPrio.P2, byPrio.P3, byPrio.P4],
backgroundColor: ['#ff5370CC','#ff9f43CC','#ffd93dCC','#7fb8ffCC'],
borderColor: '#1a212b',
borderWidth: 2,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
cutout: '60%',
plugins: {
legend: { position: 'right', labels: { color: '#e6edf3', font: commonFont, usePointStyle: true, padding: 12 } },
tooltip: {
callbacks: {
label: ctx => {
const total = ctx.dataset.data.reduce((a,b)=>a+b,0);
const pct = total ? ((ctx.parsed/total)*100).toFixed(0) : 0;
return `${ctx.label}: ${ctx.parsed} (${pct}%)`;
}
}
}
}
}
});
charts.trend = new Chart(document.getElementById('trendChart'), {
type: 'bar',
data: {
labels: days.map(d => d.slice(5)),
datasets: [{
label: 'Ferdige',
data: completedByDay,
backgroundColor: '#4ade80CC',
borderColor: '#4ade80',
borderWidth: 1,
borderRadius: 3,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: ctx => `${ctx.parsed.y} ferdig` } }
},
scales: {
x: { ticks: { color: '#8b98a9', font: { ...commonFont, size: 10 }, maxRotation: 0, autoSkip: true, autoSkipPadding: 10 }, grid: { display: false } },
y: { beginAtZero: true, ticks: { color: '#8b98a9', font: commonFont, stepSize: 1 }, grid: { color: '#2a3340' } }
}
}
});
}
function renderTable() {
const filtered = sortTasks(applyFilters(state.tasks));
const tbody = document.getElementById('tbl-body');
const empty = document.getElementById('empty');
document.getElementById('tbl-count').textContent = filtered.length;
if (filtered.length === 0) {
tbody.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
tbody.innerHTML = filtered.map(t => {
const cat = CATEGORIES.find(c => c.id === t.category);
const dl = formatDeadline(t.deadline);
const tags = (t.tags||[]).map(tag => `<span style="font-family:var(--mono);font-size:11px;color:var(--text-faint)">#${tag}</span>`).join(' ');
return `
<tr class="status-${t.status}" data-id="${t.id}">
<td><span class="badge prio-${t.priority}">${t.priority}</span></td>
<td>
<div class="task-title" onclick="openEdit('${t.id}')">${escapeHtml(t.title)}</div>
${t.desc ? `<div class="task-desc">${escapeHtml(t.desc).slice(0,160)}${t.desc.length>160?'…':''}</div>` : ''}
${tags ? `<div style="margin-top:4px">${tags}</div>` : ''}
</td>
<td><span class="cat-badge" style="color:${CAT_COLORS[t.category]};border-color:${CAT_COLORS[t.category]}40;background:${CAT_COLORS[t.category]}15" title="${cat?cat.desc:''}">${cat?cat.name:t.category}</span></td>
<td><span class="status-badge status-${t.status}">${STATUS_LABELS[t.status]}</span></td>
<td style="font-family:var(--mono);font-size:12px;color:var(--text-dim)">${escapeHtml(t.location||'—')}</td>
<td style="text-align:right;font-family:var(--mono)">${t.estHours||'—'}</td>
<td class="time-cell ${dl.cls}">${dl.text}</td>
<td>
<div class="row-actions">
${t.status !== 'done'
? `<button class="icon-btn do" onclick="markDone('${t.id}')" title="Marker ferdig">✓</button>`
: `<button class="icon-btn" onclick="unmarkDone('${t.id}')" title="Gjenåpne">↺</button>`}
<button class="icon-btn" onclick="openEdit('${t.id}')" title="Rediger">✎</button>
<button class="icon-btn del" onclick="deleteTask('${t.id}')" title="Slett">🗑</button>
</div>
</td>
</tr>`;
}).join('');
}
function escapeHtml(s) {
return String(s||'').replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[m]);
}
// ---------- Actions ----------
window.markDone = function(id) {
const t = state.tasks.find(x => x.id === id);
if (!t) return;
t.status = 'done';
t.completed = new Date().toISOString().slice(0,10);
saveTasks(state.tasks);
render();
};
window.unmarkDone = function(id) {
const t = state.tasks.find(x => x.id === id);
if (!t) return;
t.status = 'todo';
t.completed = null;
saveTasks(state.tasks);
render();
};
window.deleteTask = function(id) {
const t = state.tasks.find(x => x.id === id);
if (!t) return;
if (!confirm(`Slette oppgaven "${t.title}"?`)) return;
state.tasks = state.tasks.filter(x => x.id !== id);
saveTasks(state.tasks);
render();
};
window.openEdit = function(id) {
const t = state.tasks.find(x => x.id === id);
if (!t) return;
state.editingId = id;
document.getElementById('modal-title').textContent = 'Rediger oppgave';
document.getElementById('f-title').value = t.title;
document.getElementById('f-desc').value = t.desc || '';
document.getElementById('f-cat').value = t.category;
document.getElementById('f-prio').value = t.priority;
document.getElementById('f-stat').value = t.status;
document.getElementById('f-est').value = t.estHours || '';
document.getElementById('f-loc').value = t.location || '';
document.getElementById('f-dl').value = t.deadline || '';
document.getElementById('f-tags').value = (t.tags||[]).join(', ');
document.getElementById('modal-delete').style.display = 'inline-flex';
document.getElementById('modal').classList.add('open');
};
function openNew() {
state.editingId = null;
document.getElementById('modal-title').textContent = 'Ny oppgave';
document.getElementById('f-title').value = '';
document.getElementById('f-desc').value = '';
document.getElementById('f-cat').value = CATEGORIES[0].id;
document.getElementById('f-prio').value = 'P2';
document.getElementById('f-stat').value = 'todo';
document.getElementById('f-est').value = '';
document.getElementById('f-loc').value = '';
document.getElementById('f-dl').value = '';
document.getElementById('f-tags').value = '';
document.getElementById('modal-delete').style.display = 'none';
document.getElementById('modal').classList.add('open');
setTimeout(() => document.getElementById('f-title').focus(), 50);
}
function closeModal() {
document.getElementById('modal').classList.remove('open');
state.editingId = null;
}
function saveForm() {
const title = document.getElementById('f-title').value.trim();
if (!title) { alert('Tittel er påkrevd'); return; }
const data = {
title,
desc: document.getElementById('f-desc').value.trim(),
category: document.getElementById('f-cat').value,
priority: document.getElementById('f-prio').value,
status: document.getElementById('f-stat').value,
estHours: parseFloat(document.getElementById('f-est').value) || 0,
location: document.getElementById('f-loc').value.trim(),
deadline: document.getElementById('f-dl').value,
tags: document.getElementById('f-tags').value.split(',').map(s=>s.trim()).filter(Boolean),
};
if (state.editingId) {
const t = state.tasks.find(x => x.id === state.editingId);
const wasDone = t.status === 'done';
Object.assign(t, data);
if (data.status === 'done' && !wasDone) t.completed = new Date().toISOString().slice(0,10);
if (data.status !== 'done') t.completed = null;
} else {
state.tasks.push({
id: uid(), ...data,
created: new Date().toISOString(),
completed: data.status === 'done' ? new Date().toISOString().slice(0,10) : null
});
}
saveTasks(state.tasks);
closeModal();
render();
}
function deleteFromModal() {
if (!state.editingId) return;
const t = state.tasks.find(x => x.id === state.editingId);
if (!t) return;
if (!confirm(`Slette oppgaven "${t.title}"?`)) return;
state.tasks = state.tasks.filter(x => x.id !== state.editingId);
saveTasks(state.tasks);
closeModal();
render();
}
// ---------- Filters ----------
function bindFilters() {
document.getElementById('f-status').addEventListener('change', e => { state.filters.status = e.target.value; render(); });
document.getElementById('f-category').addEventListener('change', e => { state.filters.category = e.target.value; render(); });
document.getElementById('f-priority').addEventListener('change', e => { state.filters.priority = e.target.value; render(); });
document.getElementById('f-search').addEventListener('input', e => { state.filters.search = e.target.value; render(); });
document.getElementById('clearFilters').addEventListener('click', () => {
state.filters = { status: 'open', category: 'all', priority: 'all', search: '' };
document.getElementById('f-status').value = 'open';
document.getElementById('f-category').value = 'all';
document.getElementById('f-priority').value = 'all';
document.getElementById('f-search').value = '';
render();
});
}
function populateCategorySelects() {
const filterSel = document.getElementById('f-category');
const formSel = document.getElementById('f-cat');
CATEGORIES.forEach(c => {
filterSel.insertAdjacentHTML('beforeend', `<option value="${c.id}">${c.name}</option>`);
formSel.insertAdjacentHTML('beforeend', `<option value="${c.id}">${c.name}</option>`);
});
}
function bindSort() {
document.querySelectorAll('thead th[data-sort]').forEach(th => {
th.addEventListener('click', () => {
const field = th.dataset.sort;
if (state.sort.field === field) {
state.sort.dir = state.sort.dir === 'asc' ? 'desc' : 'asc';
} else {
state.sort.field = field;
state.sort.dir = field === 'priority' || field === 'deadline' ? 'asc' : 'desc';
}
render();
});
});
}
// ---------- Import / Export ----------
function exportJSON() {
const data = JSON.stringify(state.tasks, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `netops-todo-${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(a.href);
}
function importJSON(file) {
const reader = new FileReader();
reader.onload = e => {
try {
const data = JSON.parse(e.target.result);
if (!Array.isArray(data)) throw new Error('Forventet array');
if (!confirm(`Importere ${data.length} oppgaver? Dette erstatter eksisterende data.`)) return;
state.tasks = data;
saveTasks(state.tasks);
render();
} catch (err) { alert('Kunne ikke lese JSON: ' + err.message); }
};
reader.readAsText(file);
}
// ---------- Clock ----------
function updateClock() {
const now = new Date();
const s = now.toLocaleString('nb-NO', { weekday:'short', day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit' });
document.getElementById('clock').textContent = `$ date → ${s}`;
}
// ---------- Init ----------
function init() {
populateCategorySelects();
bindFilters();
bindSort();
document.getElementById('newTaskBtn').addEventListener('click', openNew);
document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('modal-cancel').addEventListener('click', closeModal);
document.getElementById('modal-save').addEventListener('click', saveForm);
document.getElementById('modal-delete').addEventListener('click', deleteFromModal);
document.getElementById('modal').addEventListener('click', e => { if (e.target.id === 'modal') closeModal(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
if (e.key === 'n' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA' && e.target.tagName !== 'SELECT') {
e.preventDefault(); openNew();
}
});
document.getElementById('exportBtn').addEventListener('click', exportJSON);
document.getElementById('importBtn').addEventListener('click', () => document.getElementById('importFile').click());
document.getElementById('importFile').addEventListener('change', e => {
if (e.target.files[0]) importJSON(e.target.files[0]);
e.target.value = '';
});
updateClock();
setInterval(updateClock, 30000);
render();
}
init();
</script>
</body>
</html>