393 lines
17 KiB
Nix
393 lines
17 KiB
Nix
{ 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; }
|