nix/modules/voip/dashboard.nix

393 lines
17 KiB
Nix
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.

{ lib, pkgs, cfg, models, allPhones, intercomEntries }:
let
hasTrunk = cfg.sipTrunks != {};
# For HTML table cells: produces either the literal value or a @@/full/path@@
# marker that the nginx Lua handler opens directly at request time.
rtv = val: file: if file != null then "@@${file}@@" else val;
# For DOT node labels (end up in SVG): never use @@ markers — graphviz may
# split long strings at hyphens across <tspan> elements, breaking substitution
# and corrupting the SVG. Show the key filename as a static label instead.
rtvLabel = val: file: if file != null then baseNameOf file else val;
# Sanitize a string for use as a DOT node identifier
nid = prefix: s: "${prefix}_${lib.replaceStrings
[ "-" "+" "*" "@" "." " " "/" ]
[ "_" "p" "s" "at" "_" "_" "_" ] s}";
# Whether a phone gets an L2 line (same logic as asterisk.nix)
phoneHasL2 = key:
let phone = allPhones.${key};
in phone.personKey != null && phone.sharedLine && models.${phone.model}.hasMultiLine;
# ── Nodes ───────────────────────────────────────────────────────────────
trunkNodes = lib.mapAttrsToList (name: t:
'' ${nid "trunk" name} [label="${name}\n${rtvLabel t.host t.hostFile}" shape=box style="rounded,filled" fillcolor="#AED6F1"]''
) cfg.sipTrunks;
didNodes = lib.mapAttrsToList (id: didCfg:
let numLabel = rtvLabel didCfg.number didCfg.numberFile; in
'' ${nid "did" id} [label="${numLabel}\n${didCfg.displayName}" shape=box style="rounded,filled" fillcolor="#FAD7A0"]''
) cfg.dids;
personNodes = lib.mapAttrsToList (key: person:
'' ${nid "person" key} [label="${person.displayName}\next. ${person.extension}" shape=ellipse style=filled fillcolor="#A9DFBF"]''
) cfg.persons;
phoneNodes = lib.mapAttrsToList (key: phone:
let
modelLabel = if phone.personKey != null then
cfg.persons.${phone.personKey}.phones.${key}.model
else
cfg.sharedPhones.${key}.model;
isSoftClient = modelLabel == "sip-client";
style = if isSoftClient then "dashed,filled" else "filled";
lines =
[ phone.displayName ]
++ lib.optional (phone.personKey == null) "ext. ${phone.extension}"
++ [ modelLabel ]
++ lib.optional (!isSoftClient) "(${key})"
++ lib.optional isSoftClient key;
in
'' ${nid "phone" key} [label="${lib.concatStringsSep "\\n" lines}" shape=box style="${style}" fillcolor="#D5D8DC"]''
) allPhones;
mailboxNodes =
lib.optional (cfg.sharedMailbox != null)
'' ${nid "mbox" cfg.sharedMailbox.mailboxId} [label="${cfg.sharedMailbox.displayName}\nbox ${cfg.sharedMailbox.mailboxId}" shape=cylinder style=filled fillcolor="#D7BDE2"]''
++ lib.mapAttrsToList (key: person:
lib.optionalString person.mailbox
'' ${nid "mbox" person.extension} [label="${person.displayName}\nbox ${person.extension}" shape=cylinder style=filled fillcolor="#D7BDE2"]''
) cfg.persons;
extensionNodes = lib.mapAttrsToList (ext: extCfg:
let icon = if extCfg.mode == "page" then "📢 " else " "; in
'' ${nid "ext" ext} [label="${ext}\n${extCfg.displayName}\n(${extCfg.mode})" shape=diamond style=filled fillcolor="#D6EAF8"]''
) cfg.extensions;
intercomNodes = map (ic:
'' ${nid "ic" ic.extension} [label="${ic.extension}\n${ic.displayName}" shape=diamond style=filled fillcolor="#FDEBD0"]''
) intercomEntries;
# ── Edges ───────────────────────────────────────────────────────────────
# Trunk → DID
trunkDidEdges = lib.mapAttrsToList (id: didCfg:
'' ${nid "trunk" didCfg.trunk} -> ${nid "did" id}''
) cfg.dids;
# DID → phone targets (routing)
didRoutingEdges = lib.concatLists (lib.mapAttrsToList (id: didCfg:
let
r = didCfg.routing;
lbl = label: ''label="${label}" fontsize=9'';
edge = target: lineLabel:
'' ${nid "did" id} -> ${nid "phone" target} [${lbl lineLabel}]'';
in
if r.type == "all" then
lib.mapAttrsToList (key: _:
edge key (if phoneHasL2 key then "L2" else "L1")
) allPhones
else if r.type == "person" then
lib.mapAttrsToList (key: _: edge key "L1")
cfg.persons.${r.person}.phones
else # persons
lib.concatMap (personKey:
lib.mapAttrsToList (key: _: edge key "L2")
cfg.persons.${personKey}.phones
) r.persons
) cfg.dids);
# DID → mailbox (no-answer fallback, dashed)
didMailboxEdges = lib.mapAttrsToList (id: didCfg:
let
r = didCfg.routing;
dashAttr = ''style=dashed color="#999999" fontsize=9 label="no answer"'';
in
if didCfg.mailbox == "shared" then
'' ${nid "did" id} -> ${nid "mbox" cfg.sharedMailbox.mailboxId} [${dashAttr}]''
else if didCfg.mailbox == "person" then
'' ${nid "did" id} -> ${nid "mbox" cfg.persons.${r.person}.extension} [${dashAttr}]''
else ""
) cfg.dids;
# Person → their phones
personPhoneEdges = lib.concatLists (lib.mapAttrsToList (key: person:
lib.mapAttrsToList (pkey: _:
'' ${nid "person" key} -> ${nid "phone" pkey} [arrowhead=open]''
) person.phones
) cfg.persons);
# Intercom edges: phone → intercom extension
intercomEdges = map (ic:
'' ${nid "phone" ic.phoneKey} -> ${nid "ic" ic.extension} [style=dotted arrowhead=open label="intercom" fontsize=9]''
) intercomEntries;
# Page extension → intercom-capable phones
pagePhones = lib.filterAttrs (key: phone:
models.${phone.model}.hasIntercom && cfg.intercomPrefix != null
) allPhones;
pageEdges = lib.concatLists (lib.mapAttrsToList (ext: extCfg:
lib.optionals (extCfg.mode == "page")
(lib.mapAttrsToList (key: _:
'' ${nid "ext" ext} -> ${nid "phone" key} [style=dotted color="#5D6D7E"]''
) pagePhones)
) cfg.extensions);
# ── Assemble DOT ────────────────────────────────────────────────────────
dotSource = ''
digraph voip {
rankdir=LR;
graph [fontname="Helvetica,Arial,sans-serif" fontsize=11 splines=ortho];
node [fontname="Helvetica,Arial,sans-serif" fontsize=10];
edge [fontname="Helvetica,Arial,sans-serif" fontsize=9];
subgraph cluster_trunks {
label="SIP Trunks"; style=filled; fillcolor="#EBF5FB";
${lib.concatStringsSep "\n" trunkNodes}
}
subgraph cluster_dids {
label="DIDs (Inbound)"; style=filled; fillcolor="#FEF9E7";
${lib.concatStringsSep "\n" didNodes}
}
${lib.optionalString (cfg.persons != {}) ''
subgraph cluster_persons {
label="Persons"; style=filled; fillcolor="#EAFAF1";
${lib.concatStringsSep "\n" personNodes}
}
''}
subgraph cluster_phones {
label="Phones"; style=filled; fillcolor="#F2F3F4";
${lib.concatStringsSep "\n" phoneNodes}
}
${lib.optionalString (cfg.sharedMailbox != null || lib.any (p: p.mailbox) (lib.attrValues cfg.persons)) ''
subgraph cluster_mailboxes {
label="Voicemail"; style=filled; fillcolor="#F5EEF8";
${lib.concatStringsSep "\n" mailboxNodes}
}
''}
${lib.optionalString (cfg.extensions != {} || intercomEntries != []) ''
subgraph cluster_extensions {
label="Extensions"; style=filled; fillcolor="#EBF5FB";
${lib.concatStringsSep "\n" extensionNodes}
${lib.concatStringsSep "\n" intercomNodes}
}
''}
// Trunk DID
${lib.concatStringsSep "\n" trunkDidEdges}
// DID routing phones
${lib.concatStringsSep "\n" didRoutingEdges}
// No-answer mailbox
${lib.concatStringsSep "\n" didMailboxEdges}
// Person phones
${lib.concatStringsSep "\n" personPhoneEdges}
// Intercom
${lib.concatStringsSep "\n" intercomEdges}
// Page
${lib.concatStringsSep "\n" pageEdges}
}
'';
dotFile = pkgs.writeText "voip.dot" dotSource;
svgFile = pkgs.runCommand "voip-diagram.svg" {
nativeBuildInputs = [ pkgs.graphviz ];
} "dot -Tsvg ${dotFile} -o $out";
# HTML head — everything up to and including the diagram container opening tag
htmlHead = pkgs.writeText "voip-head.html" ''
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${cfg.directoryName} VoIP</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; background: #f5f5f5; color: #333; }
header { background: #2c3e50; color: white; padding: 1em 2em; }
header h1 { margin: 0; font-size: 1.4em; font-weight: 500; }
main { padding: 2em; }
.card { background: white; border-radius: 6px; box-shadow: 0 1px 4px rgba(0,0,0,.1); padding: 1.5em; margin-bottom: 1.5em; overflow: auto; }
.card h2 { margin: 0 0 1em; font-size: 1em; color: #555; text-transform: uppercase; letter-spacing: .05em; }
.diagram svg { max-width: 100%; height: auto; display: block; }
table { border-collapse: collapse; width: 100%; font-size: .9em; }
th { text-align: left; padding: .4em .8em; background: #f0f0f0; border-bottom: 2px solid #ddd; }
td { padding: .4em .8em; border-bottom: 1px solid #eee; }
code { background: #f0f0f0; padding: .1em .3em; border-radius: 3px; font-size: .9em; }
</style>
</head>
<body>
<header><h1>${cfg.directoryName} VoIP Routing</h1></header>
<main>
<div class="card">
<h2>Routing Diagram</h2>
<div class="diagram">
'';
# HTML tail — closes the diagram div and adds all info tables
htmlTail = pkgs.writeText "voip-tail.html" ''
</div>
</div>
${lib.optionalString hasTrunk ''
<div class="card">
<h2>SIP Trunks</h2>
<table>
<tr><th>Name</th><th>Host</th><th>Username</th><th>Transport</th><th>Caller ID</th><th>Codecs</th></tr>
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: t:
let callerIdCell =
if t.callerIdFile != null || t.callerId != "" then rtv t.callerId t.callerIdFile
else "<em>provider default</em>";
in
''<tr><td><code>${name}</code></td><td>${rtv t.host t.hostFile}</td><td><code>${rtv t.username t.usernameFile}</code></td><td>${t.transport}</td><td>${callerIdCell}</td><td><code>${lib.concatStringsSep " " t.codecs}</code></td></tr>''
) cfg.sipTrunks)}
</table>
</div>
<div class="card">
<h2>DIDs (Inbound)</h2>
<table>
<tr><th>ID</th><th>Number</th><th>Name</th><th>Trunk</th><th>Routing</th><th>Timeout</th><th>Mailbox</th><th>Music on Hold</th></tr>
${lib.concatStringsSep "\n" (lib.mapAttrsToList (id: didCfg:
let r = didCfg.routing;
numCell = if didCfg.numberFile != null then "<code>@@${didCfg.numberFile}@@</code>"
else if didCfg.number != "" then "<code>${didCfg.number}</code>"
else "<em>(none)</em>";
routeStr = if r.type == "all" then "all phones (L2)"
else if r.type == "person" then " ${r.person} (L1)"
else " ${lib.concatStringsSep ", " r.persons} (L2)";
mohCell = if didCfg.musicOnHold != null then didCfg.musicOnHold else "<em>ringback</em>";
in
''<tr><td><code>${id}</code></td><td>${numCell}</td><td>${didCfg.displayName}</td><td><code>${didCfg.trunk}</code></td><td>${routeStr}</td><td>${toString didCfg.routing.timeout}s</td><td>${didCfg.mailbox}</td><td>${mohCell}</td></tr>''
) cfg.dids)}
</table>
</div>
''}
<div class="card">
<h2>Persons</h2>
<table>
<tr><th>Key</th><th>Name</th><th>Extension</th><th>Mailbox</th><th>Ring timeout</th><th>Greeting</th><th>Devices</th></tr>
${lib.concatStringsSep "\n" (lib.mapAttrsToList (key: person:
let
devList = lib.concatStringsSep "<br>" (lib.mapAttrsToList (pkey: ph:
"<code>${pkey}</code> (${ph.model})"
) person.phones);
mailboxCell = if person.mailbox then "ext. ${person.extension}" else "<em>none</em>";
greetingCell = if person.mailboxGreeting != null then "custom" else "<em>default</em>";
in
''<tr><td><code>${key}</code></td><td>${person.displayName}</td><td><code>${person.extension}</code></td><td>${mailboxCell}</td><td>${toString person.ringTimeout}s</td><td>${greetingCell}</td><td>${devList}</td></tr>''
) cfg.persons)}
</table>
</div>
${lib.optionalString (cfg.sharedPhones != {}) ''
<div class="card">
<h2>Shared Phones</h2>
<table>
<tr><th>Key / MAC</th><th>Name</th><th>Extension</th><th>Model</th><th>Label</th></tr>
${lib.concatStringsSep "\n" (lib.mapAttrsToList (key: phone:
''<tr><td><code>${key}</code></td><td>${phone.displayName}</td><td><code>${phone.extension}</code></td><td>${phone.model}</td><td>${phone.label}</td></tr>''
) cfg.sharedPhones)}
</table>
</div>
''}
${lib.optionalString (cfg.sharedMailbox != null) ''
<div class="card">
<h2>Shared Mailbox</h2>
<table>
<tr><th>Name</th><th>Mailbox ID</th><th>Check extension</th><th>Greeting</th></tr>
<tr>
<td>${cfg.sharedMailbox.displayName}</td>
<td><code>${cfg.sharedMailbox.mailboxId}</code></td>
<td><code>${cfg.sharedMailbox.checkExtension}</code></td>
<td>${if cfg.sharedMailbox.greeting != null then "custom" else "<em>default</em>"}</td>
</tr>
</table>
</div>
''}
${lib.optionalString (cfg.extensions != {} || intercomEntries != []) ''
<div class="card">
<h2>Extensions</h2>
<table>
<tr><th>Extension</th><th>Name</th><th>Type</th><th>Detail</th></tr>
${lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg:
let detail = if extCfg.mode == "page" then "all phones" else extCfg.app; in
''<tr><td><code>${ext}</code></td><td>${extCfg.displayName}</td><td>${extCfg.mode}</td><td><code>${detail}</code></td></tr>''
) cfg.extensions)}
${lib.concatStringsSep "\n" (map (ic:
''<tr><td><code>${ic.extension}</code></td><td>${ic.displayName}</td><td>intercom</td><td> <code>${ic.phoneKey}</code></td></tr>''
) intercomEntries)}
</table>
</div>
''}
${lib.optionalString (cfg.mohClasses != {}) ''
<div class="card">
<h2>Music on Hold</h2>
<table>
<tr><th>Class</th><th>Files</th><th>Sort</th></tr>
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: cls:
''<tr><td><code>${name}</code></td><td>${toString (lib.length cls.files)}</td><td>${cls.sort}</td></tr>''
) cfg.mohClasses)}
</table>
</div>
''}
<div class="card">
<h2>Codecs</h2>
<table>
<tr><th>Endpoint type</th><th>Preference order</th></tr>
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: m:
''<tr><td>${name}</td><td><code>${lib.concatStringsSep " " m.codecs}</code></td></tr>''
) models)}
${lib.concatStringsSep "\n" (lib.mapAttrsToList (name: t:
''<tr><td>trunk: ${name}</td><td><code>${lib.concatStringsSep " " t.codecs}</code></td></tr>''
) cfg.sipTrunks)}
</table>
</div>
<div class="card">
<h2>Server</h2>
<table>
<tr><th>Parameter</th><th>Value</th></tr>
<tr><td>Address</td><td><code>${cfg.serverAddress}</code></td></tr>
<tr><td>SIP port</td><td><code>${toString cfg.sipPort}</code></td></tr>
<tr><td>RTP range</td><td><code>${toString cfg.rtpStart} ${toString cfg.rtpEnd}</code></td></tr>
<tr><td>Directory port</td><td><code>${toString cfg.directoryPort}</code></td></tr>
${lib.optionalString (cfg.intercomPrefix != null)
''<tr><td>Intercom prefix</td><td><code>${cfg.intercomPrefix}</code></td></tr>''}
</table>
</div>
</main>
</body>
</html>
'';
# Web root directory: index.html with inlined SVG (head + svg + tail), plus raw .dot
webRoot = pkgs.runCommand "voip-webroot" {} ''
mkdir -p $out
cat ${htmlHead} ${svgFile} ${htmlTail} > $out/index.html
cp ${dotFile} $out/voip.dot
'';
in { inherit webRoot svgFile dotFile; }