keywright-server 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. keywright_server/__init__.py +3 -0
  2. keywright_server/__main__.py +71 -0
  3. keywright_server/access.py +131 -0
  4. keywright_server/access_view.py +144 -0
  5. keywright_server/actions.py +30 -0
  6. keywright_server/approvals.py +433 -0
  7. keywright_server/audit.py +58 -0
  8. keywright_server/bundles.py +87 -0
  9. keywright_server/config.py +102 -0
  10. keywright_server/db.py +119 -0
  11. keywright_server/discovery.py +277 -0
  12. keywright_server/events.py +78 -0
  13. keywright_server/friction.py +242 -0
  14. keywright_server/integrations.py +64 -0
  15. keywright_server/inventory.py +415 -0
  16. keywright_server/inventory_mcp.py +288 -0
  17. keywright_server/jev.py +80 -0
  18. keywright_server/knowledge.py +403 -0
  19. keywright_server/knowledge_chunking.py +118 -0
  20. keywright_server/knowledge_index.py +233 -0
  21. keywright_server/knowledge_mcp.py +82 -0
  22. keywright_server/llm.py +338 -0
  23. keywright_server/llm_proxy.py +271 -0
  24. keywright_server/llm_usage.py +99 -0
  25. keywright_server/mail.py +312 -0
  26. keywright_server/mcp_oauth.py +422 -0
  27. keywright_server/mcp_proxy.py +128 -0
  28. keywright_server/mcp_server.py +1025 -0
  29. keywright_server/models.py +442 -0
  30. keywright_server/network_map.py +128 -0
  31. keywright_server/notify/__init__.py +8 -0
  32. keywright_server/notify/base.py +156 -0
  33. keywright_server/notify/homeassistant.py +221 -0
  34. keywright_server/notify/log.py +30 -0
  35. keywright_server/notify/manager.py +131 -0
  36. keywright_server/notify/webhook.py +79 -0
  37. keywright_server/nudges.py +121 -0
  38. keywright_server/omada.py +179 -0
  39. keywright_server/policy.py +265 -0
  40. keywright_server/policy_preview.py +151 -0
  41. keywright_server/reconciliation.py +188 -0
  42. keywright_server/result_inspection.py +293 -0
  43. keywright_server/semantic.py +451 -0
  44. keywright_server/semantic_questions.json +120 -0
  45. keywright_server/settings_store.py +44 -0
  46. keywright_server/ssh.py +290 -0
  47. keywright_server/tool_access.py +260 -0
  48. keywright_server/usage.py +121 -0
  49. keywright_server/web/__init__.py +0 -0
  50. keywright_server/web/admin.py +1126 -0
  51. keywright_server/web/app.py +154 -0
  52. keywright_server/web/auth.py +144 -0
  53. keywright_server/web/discovery_ui.py +112 -0
  54. keywright_server/web/friction_ui.py +92 -0
  55. keywright_server/web/inventory_ui.py +192 -0
  56. keywright_server/web/knowledge_ui.py +177 -0
  57. keywright_server/web/mail_ui.py +190 -0
  58. keywright_server/web/mcp_oauth_ui.py +87 -0
  59. keywright_server/web/network_map_ui.py +37 -0
  60. keywright_server/web/oidc.py +231 -0
  61. keywright_server/web/security.py +77 -0
  62. keywright_server/web/templates/_activity_row.html +25 -0
  63. keywright_server/web/templates/_agent_setup.html +50 -0
  64. keywright_server/web/templates/_collections_style.html +88 -0
  65. keywright_server/web/templates/_constraints.html +6 -0
  66. keywright_server/web/templates/_decide.html +39 -0
  67. keywright_server/web/templates/_device_kind_picker.html +66 -0
  68. keywright_server/web/templates/_hazards.html +6 -0
  69. keywright_server/web/templates/_icons.html +19 -0
  70. keywright_server/web/templates/_infrastructure_nav.html +8 -0
  71. keywright_server/web/templates/_integration_style.html +30 -0
  72. keywright_server/web/templates/_inventory_forms.html +36 -0
  73. keywright_server/web/templates/_inventory_style.html +36 -0
  74. keywright_server/web/templates/_live.html +10 -0
  75. keywright_server/web/templates/_mail_nav.html +5 -0
  76. keywright_server/web/templates/_mail_style.html +10 -0
  77. keywright_server/web/templates/_network_map_script.html +5 -0
  78. keywright_server/web/templates/_nudge_card.html +12 -0
  79. keywright_server/web/templates/_patterns.html +6 -0
  80. keywright_server/web/templates/_policy_preview_results.html +13 -0
  81. keywright_server/web/templates/_request_card.html +46 -0
  82. keywright_server/web/templates/_semantic.html +10 -0
  83. keywright_server/web/templates/_topology_controller.js +217 -0
  84. keywright_server/web/templates/_topology_layout.js +174 -0
  85. keywright_server/web/templates/_topology_render.js +117 -0
  86. keywright_server/web/templates/access.html +119 -0
  87. keywright_server/web/templates/agents.html +49 -0
  88. keywright_server/web/templates/audit.html +6 -0
  89. keywright_server/web/templates/base.html +198 -0
  90. keywright_server/web/templates/dashboard.html +226 -0
  91. keywright_server/web/templates/decision.html +26 -0
  92. keywright_server/web/templates/friction.html +36 -0
  93. keywright_server/web/templates/friction_review.html +21 -0
  94. keywright_server/web/templates/grants.html +61 -0
  95. keywright_server/web/templates/integration_picker.html +47 -0
  96. keywright_server/web/templates/inventory.html +21 -0
  97. keywright_server/web/templates/inventory_asset.html +42 -0
  98. keywright_server/web/templates/inventory_discovery.html +32 -0
  99. keywright_server/web/templates/inventory_discovery_review.html +34 -0
  100. keywright_server/web/templates/inventory_map.html +45 -0
  101. keywright_server/web/templates/inventory_networks.html +12 -0
  102. keywright_server/web/templates/inventory_subnet.html +41 -0
  103. keywright_server/web/templates/inventory_topology.html +22 -0
  104. keywright_server/web/templates/knowledge.html +25 -0
  105. keywright_server/web/templates/knowledge_document.html +7 -0
  106. keywright_server/web/templates/knowledge_proposal.html +7 -0
  107. keywright_server/web/templates/login.html +9 -0
  108. keywright_server/web/templates/mail.html +45 -0
  109. keywright_server/web/templates/mail_rules.html +24 -0
  110. keywright_server/web/templates/mail_settings.html +19 -0
  111. keywright_server/web/templates/mcp_oauth_continue.html +14 -0
  112. keywright_server/web/templates/policies.html +30 -0
  113. keywright_server/web/templates/request_detail.html +27 -0
  114. keywright_server/web/templates/requests.html +9 -0
  115. keywright_server/web/templates/resource_form.html +198 -0
  116. keywright_server/web/templates/resources.html +76 -0
  117. keywright_server/web/templates/settings.html +53 -0
  118. keywright_server/web/templates/setup.html +8 -0
  119. keywright_server/web/templates/usage.html +84 -0
  120. keywright_server/web/usage_ui.py +41 -0
  121. keywright_server-0.0.1.dist-info/METADATA +152 -0
  122. keywright_server-0.0.1.dist-info/RECORD +127 -0
  123. keywright_server-0.0.1.dist-info/WHEEL +4 -0
  124. keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
  125. keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
  126. keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
  127. keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
@@ -0,0 +1,6 @@
1
+ {# Destructive-operation flags on a request. `level` decides the tone: high/critical override standing access. #}
2
+ {% set hz = hazards_of(req.detail) %}{% if hz %}{% set level = hazard_level(req.detail) %}
3
+ <div class="hazard {{ level }}"><b>{% if hazard_blocks(level) %}⚠ Flagged destructive ({{ level }}){% else %}Flagged ({{ level }}){% endif %}</b>
4
+ <ul>{% for h in hz %}<li><span class="pill {{ h.severity }}">{{ h.severity }}</span> {{ h.reason }}</li>{% endfor %}</ul>
5
+ <div class="help">{% if hazard_blocks(level) %}Standing rules and timed grants do not cover this. It can be allowed once, for exactly the operation shown, or denied.{% else %}Runs under existing access; recorded in the audit log as flagged.{% endif %}</div></div>
6
+ {% endif %}
@@ -0,0 +1,19 @@
1
+ {% macro icon(name) -%}
2
+ <svg class="ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
3
+ {% if name == 'shield' %}<path d="m12 3 8 3v5c0 5-4 8-8 10-4-2-8-5-8-10V6l8-3Z"/><rect x="9" y="10" width="6" height="5" rx="1"/><path d="M10 10V8a2 2 0 0 1 4 0v2"/>
4
+ {% elif name == 'dashboard' %}<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>
5
+ {% elif name == 'requests' %}<rect x="4" y="3" width="16" height="18" rx="1"/><path d="M8 8h8M8 12h8M8 16h5"/>
6
+ {% elif name == 'activity' %}<path d="M3 12h4l3-7 4 14 3-7h4"/>
7
+ {% elif name == 'ssh' %}<rect x="3" y="4" width="18" height="16" rx="3"/><path d="m7 9 3 3-3 3m6 0h4"/>
8
+ {% elif name == 'mcp' %}<rect x="8" y="8" width="8" height="8" rx="2"/><path d="M12 3v5m0 8v5M3 12h5m8 0h5M5 5l3 3m8 8 3 3M5 19l3-3m8-8 3-3"/>
9
+ {% elif name == 'omada' %}<circle cx="12" cy="6" r="3"/><circle cx="6" cy="17" r="3"/><circle cx="18" cy="17" r="3"/><path d="m10.5 8.6-3 5.8m6-5.8 3 5.8M9 17h6"/>
10
+ {% elif name == 'llm' %}<circle cx="12" cy="12" r="9"/><path d="M12 6.5v11M9.5 9.25h3.75a1.75 1.75 0 0 1 0 3.5h-2.5a1.75 1.75 0 0 0 0 3.5H14.5"/>
11
+ {% elif name == 'search' %}<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>
12
+ {% elif name == 'chevron' %}<path d="m9 5 7 7-7 7"/>
13
+ {% elif name == 'more' %}<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>
14
+ {% elif name == 'check' %}<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>
15
+ {% elif name == 'deny' %}<circle cx="12" cy="12" r="9"/><path d="m6 6 12 12"/>
16
+ {% elif name == 'warning' %}<path d="m12 3 10 18H2L12 3Zm0 6v5m0 3v.1"/>
17
+ {% else %}<path d="m5 6 7 6-7 6V6Zm10 12h5"/>{% endif %}
18
+ </svg>
19
+ {%- endmacro %}
@@ -0,0 +1,8 @@
1
+ <div class="infra-nav" aria-label="Infrastructure views">
2
+ <a href="/ui/resources" {{ 'aria-current=page' if request.url.path.startswith('/ui/resources') }}>Resources</a>
3
+ <a href="/ui/inventory" {{ 'aria-current=page' if request.url.path == '/ui/inventory' or '/assets/' in request.url.path }}>Inventory</a>
4
+ <a href="/ui/inventory/map" {{ 'aria-current=page' if request.url.path == '/ui/inventory/map' }}>Network map</a>
5
+ <a href="/ui/inventory/topology" {{ 'aria-current=page' if request.url.path == '/ui/inventory/topology' }}>Topology</a>
6
+ <a href="/ui/inventory/networks" {{ 'aria-current=page' if '/networks' in request.url.path }}>IP allocations</a>
7
+ <a href="/ui/inventory/discovery" {{ 'aria-current=page' if request.url.path.startswith('/ui/inventory/discovery') }}>Discovery</a>
8
+ </div>
@@ -0,0 +1,30 @@
1
+ <style>
2
+ .integration-page{max-width:820px;margin:0 auto}
3
+ .integration-back{display:inline-flex;align-items:center;gap:6px;margin:4px 0 20px;color:var(--mut);font-size:13px}
4
+ .integration-back .ui-icon{width:14px;height:14px;transform:rotate(180deg)}
5
+ .integration-heading{display:flex;align-items:start;justify-content:space-between;gap:16px;margin-bottom:22px}
6
+ .integration-heading h1{font-size:28px;margin-bottom:7px}.integration-heading p{margin:0;color:var(--mut);font-size:15px}
7
+ .integration-search{display:flex;align-items:center;gap:12px;border:1px solid var(--line);background:var(--panel);border-radius:10px;padding:4px 14px;margin-bottom:28px}
8
+ .integration-search .ui-icon{width:20px;height:20px;color:var(--mut);flex-shrink:0}
9
+ .integration-search input[type=search]{width:100%;min-height:48px;border:0;background:transparent;color:var(--fg);font:inherit;font-size:16px;outline:none}
10
+ .integration-search:focus-within{outline:2px solid var(--acc);outline-offset:2px}
11
+ .integration-group{margin-bottom:28px}.integration-group h2{font-size:13px;color:var(--mut);font-weight:600;margin:0 0 10px;letter-spacing:.02em}
12
+ .integration-list{border:1px solid var(--line);border-radius:12px;overflow:hidden;background:var(--panel)}
13
+ .integration-choice{display:flex;align-items:center;gap:16px;padding:19px 20px;color:var(--fg);min-height:94px}
14
+ .integration-choice+.integration-choice{border-top:1px solid var(--line)}
15
+ .integration-choice:hover{background:var(--panel-raised);text-decoration:none}
16
+ .integration-choice:focus-visible{outline:2px solid var(--acc);outline-offset:-3px;border-radius:8px}
17
+ .integration-mark{display:flex;align-items:center;justify-content:center;flex:0 0 48px;width:48px;height:48px;border-radius:12px;background:var(--panel-raised);color:var(--acc)}
18
+ .integration-mark .ui-icon{width:27px;height:27px}.integration-mark.notion{background:#fafafa;color:#191919;border:1px solid #c9c9c9;font:700 35px/1 Georgia,serif;border-radius:10px}
19
+ .integration-mark.omada{background:#e1f5ef;color:#087b68}.integration-mark.shield{background:#e5ecff;color:#315f99}
20
+ .integration-copy{flex:1;min-width:0}.integration-copy strong{display:block;font-size:16px;font-weight:600;line-height:1.3}.integration-copy p{margin:5px 0 0;color:var(--mut);font-size:13px}
21
+ .integration-method{color:var(--mut);font-size:12px;white-space:nowrap}.integration-arrow{display:flex;color:var(--mut)}.integration-arrow .ui-icon{width:17px;height:17px}
22
+ .integration-custom .integration-mark{width:40px;height:40px;flex-basis:40px;border-radius:9px}.integration-custom .integration-choice{min-height:80px;padding-top:15px;padding-bottom:15px}
23
+ .integration-count{font-size:12px;color:var(--mut);margin:12px 0 0}.integration-empty{text-align:center;padding:30px 20px;border:1px dashed var(--line);border-radius:12px}.integration-empty p{color:var(--mut)}
24
+ .integration-intro{display:flex;align-items:center;gap:16px;margin-bottom:24px}.integration-intro h1{margin:0 0 5px;font-size:25px}.integration-intro p{margin:0;color:var(--mut)}
25
+ .integration-steps{display:flex;gap:20px;list-style:none;padding:0;margin:0 0 24px;color:var(--mut);font-size:13px}.integration-steps li{display:flex;align-items:center;gap:7px}.integration-steps span{border:1px solid var(--line);border-radius:50%;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center}.integration-steps [aria-current=step]{color:var(--fg)}.integration-steps [aria-current=step] span{background:var(--button-bg);border-color:var(--button-bg);color:var(--button-fg)}
26
+ .integration-form>.card{padding:22px;border-radius:12px}.integration-form label{margin-top:15px}.integration-form .grid label{margin-top:0}.integration-form input[type=text],.integration-form input[type=password],.integration-form input[type=url],.integration-form select{min-height:42px;border-radius:6px}
27
+ .integration-advanced{margin-top:22px;border-top:1px solid var(--line);padding-top:16px}.integration-advanced summary{cursor:pointer;font-weight:550;min-height:32px}.integration-actions{margin-top:22px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}.integration-actions .btn{min-height:44px;padding:9px 18px;border-radius:7px}.integration-actions p{margin:0;color:var(--mut);font-size:12px}
28
+ [data-integration][hidden],.integration-group[hidden],.integration-empty[hidden]{display:none}
29
+ @media(max-width:600px){.integration-heading h1{font-size:25px}.integration-heading p{font-size:14px}.integration-choice{display:grid;grid-template-columns:48px minmax(0,1fr) 17px;padding:17px 14px;gap:7px 12px}.integration-mark{grid-row:1/3}.integration-copy{grid-column:2;grid-row:1}.integration-method{grid-column:2;grid-row:2;font-size:12px;white-space:normal}.integration-arrow{grid-column:3;grid-row:1/3}.integration-custom .integration-choice{grid-template-columns:40px minmax(0,1fr) 17px}.integration-form>.card{padding:17px}.integration-intro{align-items:flex-start}.integration-intro h1{font-size:23px}.integration-steps{gap:14px}.integration-actions .btn{width:100%}}
30
+ </style>
@@ -0,0 +1,36 @@
1
+ {% macro field(name, label, values, fallback='', prefix='', type='text', required=false, help='', maxlength=160) %}
2
+ <div><label for="{{ prefix }}{{ name }}">{{ label }}</label><input id="{{ prefix }}{{ name }}" name="{{ name }}" type="{{ type }}" value="{{ values.get(name, fallback) or '' }}" maxlength="{{ maxlength }}" {{ 'required' if required }} {% if help %}aria-describedby="{{ prefix }}{{ name }}-help"{% endif %}>{% if help %}<div class="help" id="{{ prefix }}{{ name }}-help">{{ help }}</div>{% endif %}</div>
3
+ {% endmacro %}
4
+ {% macro interface_form(asset, item, values, assets, media) %}
5
+ {% set prefix=(item.id if item else 'new-interface') ~ '-' %}
6
+ <form method="post" action="/ui/inventory/interfaces" class="inventory-form">
7
+ <input type="hidden" name="id" value="{{ item.id if item else '' }}"><input type="hidden" name="asset_id" value="{{ asset.id }}">
8
+ {{ field('name','Interface name',values,item.name if item else '',prefix,required=true,help='For example: eth0, wlan0, or vmbr0.') }}
9
+ <div><label for="{{ prefix }}medium">Connection type</label><select id="{{ prefix }}medium" name="medium">{% for key,label in media.items() %}<option value="{{ key }}" {{ 'selected' if values.get('medium',item.medium if item else 'unknown')==key }}>{{ label }}</option>{% endfor %}</select></div>
10
+ {{ field('mac','MAC address (optional)',values,item.mac if item else '',prefix) }}
11
+ <div><label for="{{ prefix }}attached">Connected to (optional)</label><select id="{{ prefix }}attached" name="attached_to_id"><option value="">Not recorded</option>{% for target in assets if target.id!=asset.id %}<option value="{{ target.id }}" {{ 'selected' if values.get('attached_to_id',item.attached_to_id if item else '')==target.id }}>{{ target.name }}</option>{% endfor %}</select><div class="help">Switch, access point, or virtual bridge host.</div></div>
12
+ {{ field('port','Port / SSID / bridge (optional)',values,item.port if item else '',prefix) }}
13
+ <div class="full form-actions"><button class="btn">{{ 'Save interface' if item else 'Add interface' }}</button></div>
14
+ </form>
15
+ {% endmacro %}
16
+ {% macro allocation_form(subnet,item,values,interfaces,asset_map,states) %}
17
+ {% set prefix=(item.id if item else 'new-allocation') ~ '-' %}
18
+ <form method="post" action="/ui/inventory/allocations" class="inventory-form">
19
+ <input type="hidden" name="id" value="{{ item.id if item else '' }}"><input type="hidden" name="subnet_id" value="{{ subnet.id }}">
20
+ {{ field('address','IP address',values,item.address if item else '',prefix,required=true) }}
21
+ <div><label for="{{ prefix }}state">Allocation</label><select id="{{ prefix }}state" name="state">{% for key,label in states.items() %}<option value="{{ key }}" {{ 'selected' if values.get('state',item.state if item else 'reserved')==key }}>{{ label }}</option>{% endfor %}</select></div>
22
+ <div><label for="{{ prefix }}interface">Device / interface</label><select id="{{ prefix }}interface" name="interface_id"><option value="">No interface (reservation or plan)</option>{% for iface in interfaces %}<option value="{{ iface.id }}" {{ 'selected' if values.get('interface_id',item.interface_id if item else '')==iface.id }}>{{ asset_map[iface.asset_id].name }} / {{ iface.name }}</option>{% endfor %}</select><div class="help">Required for assigned addresses. Add interfaces on the device’s inventory page.</div></div>
23
+ {{ field('label','Owner / purpose',values,item.label if item else '',prefix,help='Required when there is no linked interface.') }}
24
+ <div class="full"><label for="{{ prefix }}notes">Notes</label><textarea id="{{ prefix }}notes" name="notes" maxlength="4000">{{ values.get('notes',item.notes if item else '') }}</textarea></div>
25
+ <div class="full form-actions"><button class="btn">{{ 'Save allocation' if item else 'Add allocation' }}</button></div>
26
+ </form>
27
+ {% endmacro %}
28
+ {% macro remove(kind,id,next,label='Remove record') %}
29
+ <form method="post" action="/ui/inventory/{{ kind }}/{{ id }}/delete" onsubmit="return confirm('Remove this inventory record? This does not change the device or network configuration.')"><input type="hidden" name="next" value="{{ next }}"><button class="btn sm bad" type="submit">{{ label }}</button></form>
30
+ {% endmacro %}
31
+
32
+ {% macro device_kind(id, kinds, selected, label='Device type') %}
33
+ <div class="device-kind" data-device-kind><label for="{{ id }}">{{ label }}</label>
34
+ <select id="{{ id }}" name="kind">{% for key, text in kinds.items() %}<option value="{{ key }}" {{ 'selected' if key==selected }}>{{ text }}</option>{% endfor %}</select>
35
+ </div>
36
+ {% endmacro %}
@@ -0,0 +1,36 @@
1
+ .infra-nav{display:flex;gap:16px;border-bottom:1px solid var(--line);margin:0 0 20px;overflow:auto}
2
+ .infra-nav a{white-space:nowrap;padding:8px 0;color:var(--mut);border-bottom:2px solid transparent}
3
+ .infra-nav a[aria-current=page]{color:var(--fg);border-color:var(--acc)}
4
+ .inventory-filters{display:flex;gap:10px;align-items:end;flex-wrap:wrap;margin:16px 0}
5
+ .inventory-filters>div{flex:1 1 140px}.inventory-filters>div:first-child{flex:2 1 220px}
6
+ .inventory-filters label{margin-top:0}
7
+ .inventory-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 16px;max-width:900px}
8
+ .inventory-form .full{grid-column:1/-1}.inventory-form .form-actions{margin-top:16px}
9
+ .inventory-table .mono{font-size:12px}.inventory-table td{vertical-align:middle}.inventory-table .group-label{background:var(--panel-raised);padding:8px}
10
+ .inventory-table small{display:block;color:var(--mut);font-size:12px}
11
+ .inventory-table a{overflow-wrap:anywhere}.inventory-section{margin-top:24px}
12
+ .inventory-section>summary{cursor:pointer;font-weight:600;padding:10px 0}
13
+ .inventory-heading{display:flex;gap:12px;align-items:center;justify-content:space-between;margin-bottom:8px}
14
+ .inventory-heading h1,.inventory-heading h2{margin:0}.inventory-heading+.page-intro{margin-top:0}
15
+ .inventory-facts{display:flex;flex-wrap:wrap;gap:8px 24px;color:var(--mut);margin:12px 0}
16
+ .inventory-facts b{color:var(--fg);font-weight:600;font-variant-numeric:tabular-nums}
17
+ .inventory-note{border-left:2px solid var(--line);padding:6px 12px;margin:12px 0;color:var(--mut);font-size:12px}
18
+ .inventory-delete{margin-top:20px;padding-top:12px;border-top:1px solid var(--line)}
19
+ .inventory-editor{padding:8px 0;border-top:1px solid var(--line)}.inventory-editor>summary{cursor:pointer;min-height:36px;padding:8px 0}
20
+ .inventory-tree{border:1px solid var(--line);border-radius:var(--r);background:var(--panel)}
21
+ .tree-row{display:grid;grid-template-columns:minmax(0,1fr) 130px 160px;gap:12px;align-items:center;padding:9px 12px;border-bottom:1px solid var(--line)}
22
+ .tree-row:last-child{border-bottom:0}.tree-name{display:flex;align-items:center;gap:8px;padding-left:calc(min(var(--depth), 8)*18px);min-width:0}
23
+ .tree-name a{overflow-wrap:anywhere}.tree-toggle{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;flex-shrink:0;background:none;border:1px solid var(--line);border-radius:3px;color:var(--mut);cursor:pointer}
24
+ .tree-spacer{width:28px;flex-shrink:0}.tree-meta{font-size:12px;color:var(--mut)}
25
+ .allocation-range{font-family:ui-monospace,monospace;font-size:12px;overflow-wrap:anywhere}
26
+ @media(max-width:900px){
27
+ .infra-nav{gap:14px;margin-bottom:16px}.infra-nav a{min-height:40px;font-size:12px}
28
+ .inventory-form{grid-template-columns:minmax(0,1fr)}.inventory-form .full{grid-column:1}
29
+ .inventory-filters{gap:8px}.inventory-filters>div:first-child{flex-basis:100%}
30
+ .inventory-heading{flex-wrap:wrap}.inventory-table .group-row{display:block;padding:8px 0!important}.inventory-table .group-label{display:block;border:0}
31
+ .tree-row{grid-template-columns:minmax(0,1fr) auto;gap:4px 8px}.tree-row>.tree-meta:last-child{grid-column:1/-1;padding-left:36px}
32
+ .tree-name{padding-left:calc(min(var(--depth), 4)*10px)}.tree-toggle{height:40px;width:32px}.tree-spacer{width:32px}
33
+ }
34
+
35
+ .resource-organize{margin-bottom:12px}.resource-organize label{margin:0}.resource-organize select{width:auto;max-width:180px;font-size:13px;padding:4px 8px}.resource-host{flex-basis:100%;font-size:12px}.resource-group-heading{padding:8px 14px;font-size:12px;font-weight:600;background:var(--panel-raised);border-bottom:1px solid var(--line)}
36
+ @media(max-width:900px){.resource-organize select{font-size:16px;max-width:150px}.resource-organize{gap:6px}.resource-group-heading{padding:8px 12px}}
@@ -0,0 +1,10 @@
1
+ {# Everything waiting on the human. The websocket replaces pieces of this by id, and replaces the
2
+ whole thing after a reconnect, so every id here has to stay stable. #}
3
+ <h2 id="nudges-heading"{% if not nudges %} hidden{% endif %}>Needs you
4
+ <span class="pill pending" data-count="nudges">{{ nudges|length }}</span></h2>
5
+ <div id="nudge-list">{% for n in nudges %}{% include "_nudge_card.html" %}{% endfor %}</div>
6
+
7
+ <h2>Pending requests <span class="pill" data-count="pending">{{ pending|length }}</span>
8
+ <span class="live-state" id="live-state" hidden></span></h2>
9
+ <div id="pending-list">{% for req in pending %}{% include "_request_card.html" %}{% endfor %}</div>
10
+ <div class="card mut" id="pending-empty"{% if pending %} hidden{% endif %}>Nothing waiting.</div>
@@ -0,0 +1,5 @@
1
+ <div class="row mail-tabs" aria-label="Mail navigation">
2
+ <a class="btn {{ 'p' if request.url.path == '/ui/mail' }}" href="/ui/mail">Inbox</a>
3
+ <a class="btn {{ 'p' if request.url.path == '/ui/mail/rules' }}" href="/ui/mail/rules">Email rules</a>
4
+ <a class="btn {{ 'p' if request.url.path == '/ui/mail/settings' }}" href="/ui/mail/settings">Connection</a>
5
+ </div>
@@ -0,0 +1,10 @@
1
+ <style>
2
+ .mail-tabs{margin-bottom:20px}.mail-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:14px}.mail-heading h1{margin:0}.mail-heading p{margin:4px 0 0}
3
+ .mail-shell{display:grid;grid-template-columns:145px minmax(240px,340px) minmax(0,1fr);border:1px solid var(--line);background:var(--panel);min-height:580px;border-radius:var(--r);overflow:hidden}
4
+ .mail-rail{padding:12px 8px;background:var(--panel-raised)}.mail-rail a{display:flex;justify-content:space-between;align-items:center;padding:9px 8px;margin:2px 0;color:var(--mut);border-radius:var(--r);gap:8px}.mail-rail a.active{background:var(--panel);color:var(--fg)}.mail-rail h2{font-size:11px;text-transform:uppercase;letter-spacing:.08em;padding:0 8px;margin:22px 0 8px}.mail-rail .mail-count{font-variant-numeric:tabular-nums}
5
+ .mail-list{border-right:1px solid var(--line);min-width:0}.mail-search{padding:12px;border-bottom:1px solid var(--line);display:flex;gap:6px}.mail-search input{width:100%;margin:0}.mail-message{display:block;padding:16px 14px;border-bottom:1px solid var(--line);color:var(--fg);border-left:3px solid transparent}.mail-message:hover{background:var(--panel-raised);text-decoration:none}.mail-message.selected{border-left-color:var(--acc);background:var(--panel-raised)}.mail-sender{display:block;font-weight:600;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.mail-subject{margin:4px 0 8px;display:block;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.mail-meta{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:11px;color:var(--mut)}.mail-meta time{margin-left:auto}.mail-page{padding:12px;justify-content:space-between}
6
+ .mail-reader{padding:24px;min-width:0}.mail-reader h2{font-size:20px;line-height:1.35;margin:0 0 16px}.mail-reader dl{display:grid;grid-template-columns:55px minmax(0,1fr);gap:6px 12px;margin:16px 0}.mail-reader dt{color:var(--mut)}.mail-reader dd{margin:0}.mail-body{white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.75 system-ui,sans-serif;background:transparent;padding:0;margin-top:24px;max-height:65vh;overflow:auto}.mail-result{padding:12px;border:1px solid var(--line);background:var(--panel-raised);margin:20px 0}.mail-result p{margin:8px 0}.mail-attachments{border-top:1px solid var(--line);padding-top:16px;margin-top:20px}.mail-attachment{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px;padding:9px 0}.mail-empty{padding:60px 24px;text-align:center;color:var(--mut)}.mail-empty strong{display:block;font-size:18px;color:var(--fg);margin-bottom:8px}.mail-config{max-width:720px}.mail-config .grid{grid-template-columns:1fr 1fr}.mail-config label{display:block}.mail-config input:not([type=checkbox]),.mail-config select{width:100%}.mail-rule{padding:16px;margin-bottom:10px}.mail-rule h2{margin:0}.mail-rule .row{justify-content:space-between}.mail-rule p{color:var(--mut);margin:8px 0}.mail-status-done{color:var(--ok);border-color:var(--ok)}.mail-status-failed{color:var(--bad);border-color:var(--bad)}.mail-status-pending,.mail-status-running,.mail-status-queued{color:var(--warn);border-color:var(--warn)}
7
+ @media(max-width:1050px){.mail-shell{grid-template-columns:120px minmax(220px,290px) minmax(0,1fr)}.mail-reader{padding:18px}}
8
+ @media(max-width:740px){.mail-shell{display:block;min-height:400px}.mail-rail{display:flex;overflow-x:auto;gap:6px;padding:8px}.mail-rail h2{display:none}.mail-rail a{white-space:nowrap;flex-shrink:0;padding:7px 9px}.mail-list{border-right:0}.mail-reader{border-top:1px solid var(--line);padding:18px}.mail-shell.has-selection .mail-list{display:none}.mail-reader-back{display:inline-flex!important;margin-bottom:16px}.mail-config .grid{grid-template-columns:1fr}.mail-heading{align-items:flex-start}}
9
+ .mail-reader-back{display:none}
10
+ </style>
@@ -0,0 +1,5 @@
1
+ <script>
2
+ {% include "_topology_layout.js" %}
3
+ {% include "_topology_render.js" %}
4
+ {% include "_topology_controller.js" %}
5
+ </script>
@@ -0,0 +1,12 @@
1
+ {# An agent asking for the human in the conversation. Nothing to decide: the answer happens in the
2
+ conversation, and this card only records that it is still owed. #}
3
+ <article class="tile nudge-tile" id="nudge-{{ n.id }}" data-nudge="{{ n.id }}">
4
+ <div class="tile-head"><span aria-hidden="true">💬</span><b>{{ n.agent.name }} needs you</b>
5
+ <span class="tile-meta mut small"><time data-since="{{ n.created_at.isoformat() }}" datetime="{{ n.created_at.isoformat() }}">{{ n.created_at.strftime('%H:%M:%S') }}</time></span></div>
6
+ <div class="nudge-message">“{{ n.message }}”</div>
7
+ <div class="tile-quick">
8
+ <form method="post" action="/ui/nudges/{{ n.id }}/dismiss"><input type="hidden" name="next" value="/ui">
9
+ <button class="btn sm">Dismiss</button></form>
10
+ <span class="small mut">{{ n.context or 'no location given' }}{% if n.session_id %} · session {{ n.session_id[:24] }}{% endif %}</span>
11
+ </div>
12
+ </article>
@@ -0,0 +1,6 @@
1
+ {# patterns of a request, grouped by bundle when the request used bundles: expects req #}
2
+ {% set groups = req.detail.bundles if req.detail and req.detail.bundles else {} %}
3
+ {% set grouped = [] %}{% for name, pats in groups.items() %}{% for p in pats %}{% set _ = grouped.append(p) %}{% endfor %}{% endfor %}
4
+ {% for name, pats in groups.items() %}<details class="bundle"><summary><b>bundle {{ name }}</b> <span class="mut small">({{ pats | length }} patterns)</span></summary>
5
+ <div>{% for p in pats %}<code>{{ p }}</code><br>{% endfor %}</div></details>{% endfor %}
6
+ {% for p in req.patterns %}{% if p not in grouped %}<code>{{ p }}</code><br>{% endif %}{% endfor %}
@@ -0,0 +1,13 @@
1
+ <p>{{ preview.counts.changed }} changed · {{ preview.counts.unchanged }} unchanged · {{ preview.counts.inconclusive }} inconclusive</p>
2
+ <p class="small mut">{{ preview.since.strftime('%Y-%m-%d %H:%M') }} to {{ preview.at.strftime('%Y-%m-%d %H:%M') }} UTC.
3
+ {% if preview.limited %}Sample capped at the newest {{ preview.limit }} call records; older records are omitted.{% else %}{{ preview.rows|length }} call records in this window.{% endif %}
4
+ Approval and execution records can describe the same call; these are not unique operation counts.</p>
5
+ <p class="small">Compares each recorded call independently against current rules, active grants, resource settings and agent status. One-time grants are not spent or simulated across rows. This does not reconstruct historical policy, run tools, or check upstream availability, LLM budgets, approval cooldowns or semantic decisions. Incomplete evidence is inconclusive. No matches does not mean a rule is safe.</p>
6
+ <table class="data-table" role="table"><thead><tr><th>Audit</th><th>Agent / tool</th><th>Current</th><th>With rule</th></tr></thead><tbody>
7
+ {% for row in preview.rows %}<tr>
8
+ <td data-label="Audit"><a href="/ui/audit?q=%23{{ row.id }}">#{{ row.id }}</a><div class="small mut">{{ row.ts.strftime('%m-%d %H:%M') }} UTC</div></td>
9
+ <td data-label="Agent / tool">{{ row.agent }}<div class="small">{{ row.tool }}</div>{% if row.reason %}<div class="small mut">{{ row.reason }}</div>{% endif %}</td>
10
+ <td data-label="Current">{{ preview.labels[row.before] }}</td>
11
+ <td data-label="With rule">{% if row.changed %}<b>{{ preview.labels[row.after] }}</b>{% else %}{{ preview.labels[row.after] }}{% endif %}</td>
12
+ </tr>{% endfor %}</tbody></table>
13
+ {% if not preview.rows %}<p>No recent brokered call records for this agent scope.</p>{% endif %}
@@ -0,0 +1,46 @@
1
+ {# One pending request as a tile: who, what, how urgent, and the two or three answers most likely
2
+ to be right. Everything a decision could turn on is one tap away, never more. Rendered on page
3
+ load and again over the websocket, so it must not touch `request` or `flash`. #}
4
+ {% set level = hazard_level(req.detail) %}{% set flagged = hazard_blocks(level) %}
5
+ {% set ops = operations(req.detail) %}
6
+ <article class="tile req-tile{{ ' flagged' if flagged }}" id="req-{{ req.id }}" data-request="{{ req.id }}">
7
+ <details class="tile-open">
8
+ <summary>
9
+ <div class="tile-head"><span class="tile-dot" aria-hidden="true"></span><b>{{ req.agent.name }}</b>
10
+ {% if level %}<span class="pill {{ level }}">{{ 'destructive' if flagged else 'flagged' }} · {{ level }}</span>{% endif %}
11
+ {% if req.semantic and req.semantic.status == 'ok' %}<span class="pill {{ 'ok' if req.semantic.verdict == 'safe' else 'high' if req.semantic.verdict == 'unsafe' else 'medium' if req.semantic.verdict == 'unclear' else '' }}">Jev · {{ req.semantic.verdict }}</span>{% elif req.semantic %}<span class="pill">Jev · no verdict</span>{% endif %}
12
+ <span class="tile-meta mut small"><span class="tile-times"><time data-since="{{ req.created_at.isoformat() }}" datetime="{{ req.created_at.isoformat() }}">{{ req.created_at.strftime('%H:%M:%S') }}</time>{% if req.expires_at %} · <span data-until="{{ req.expires_at.isoformat() }}">expires {{ req.expires_at.strftime('%H:%M') }}</span>{% endif %}</span>
13
+ <span class="tile-chevron" aria-hidden="true">▾</span></span></div>
14
+ {% if ops %}{% set lines = operation_line(ops[0][1]).split('\n') %}
15
+ {# Never shortened: the tile carries approve buttons, so what it shows has to be all of it. A
16
+ pathological command scrolls inside the tile rather than pushing those buttons off the screen. #}
17
+ <div class="tile-line mono">{{ lines[0] }}</div>
18
+ {% for extra in lines[1:] %}<div class="tile-line mono tile-cont">{{ extra.strip() }}</div>{% endfor %}
19
+ {% if ops|length > 1 %}<div class="small mut">and {{ ops|length - 1 }} more operation{{ '' if ops|length == 2 else 's' }} in this request</div>{% endif %}
20
+ {% else %}<div class="tile-line mono">{% for p in req.patterns[:2] %}{{ p }}{{ ' ' }}{% endfor %}{% if req.patterns|length > 2 %}<span class="mut">+{{ req.patterns|length - 2 }} more</span>{% endif %}</div>{% endif %}
21
+ {% if req.reason %}<div class="tile-reason small">“{{ req.reason }}”</div>{% endif %}
22
+ </summary>
23
+ <div class="tile-body">
24
+ {% include "_hazards.html" %}
25
+ {% if req.semantic %}<div class="tile-field"><div class="tile-label">Jev</div>{% include "_semantic.html" %}</div>{% endif %}
26
+ <div class="tile-field"><div class="tile-label">Permission scopes</div>{% include "_patterns.html" %}</div>
27
+ {% if ops %}<div class="tile-field"><div class="tile-label">Operation{{ '' if ops|length == 1 else 's' }}</div>
28
+ {% for pats, op in ops %}{% with constraints=op %}{% include "_constraints.html" %}{% endwith %}{% endfor %}</div>{% endif %}
29
+ <div class="tile-field small mut">session {{ (req.session_id or 'none')[:24] }} · <a href="/ui/requests/{{ req.id }}">{{ req.id }}</a></div>
30
+ {% with next='/ui', in_detail=true %}{% include "_decide.html" %}{% endwith %}
31
+ </div>
32
+ </details>
33
+ <form method="post" action="/ui/requests/{{ req.id }}/decide" class="tile-quick">
34
+ <input type="hidden" name="next" value="/ui">
35
+ {% if flagged %}<input type="hidden" name="extent" value="exact">
36
+ <button class="btn ok sm" name="scope" value="once">Allow once (exact)</button>
37
+ <button class="btn bad sm" name="scope" value="deny">Deny</button>
38
+ <span class="small mut">Destructive · open to read the flags</span>
39
+ {% else %}
40
+ <button class="btn ok sm" name="scope" value="once">Allow once</button>
41
+ <button class="btn sm" name="scope" value="ttl">1 hour</button>
42
+ <button class="btn bad sm" name="scope" value="deny">Deny</button>
43
+ <span class="small mut">Open for other durations, broader scope, or a deny reason</span>
44
+ {% endif %}
45
+ </form>
46
+ </article>
@@ -0,0 +1,10 @@
1
+ {# Jev's verdict on a request: the one-line reading, then every answer so the human can see why.
2
+ Advisory in shadow mode; under trust an eligible verdict has already approved the request once. #}
3
+ {% set v = req.semantic %}{% if v %}
4
+ <div class="semantic {{ v.verdict }}"><b>{{ jev_line(v) }}</b>
5
+ {% if v.status == 'ok' %}<ul class="small">
6
+ <li>category <b>{{ v.summary.category }}</b> ({{ '%.2f' % v.summary.category_confidence }}) · reversibility {{ v.summary.reversibility }} ({{ '%.2f' % v.summary.reversibility_confidence }}){% if v.summary.task_fit is not none %} · reason fits {{ '%.2f' % v.summary.task_fit }}{% else %} · no reason given, truthfulness not checked{% endif %}</li>
7
+ <li>{% for k, p in v.summary.effects.items() %}<span class="pill {{ ('high' if k not in ('modifies_state', 'persistent_change') and not (k == 'deletes_data' and v.operation == 'put') else 'medium') if p >= 0.5 else 'medium' if p > 0.15 else 'ok' }}">{{ jev_effect_words[k] }} {{ '%.2f' % p }}</span> {% endfor %}</li>
8
+ <li class="mut">{% if v.auto.eligible %}{{ 'would auto-approve under trust' if v.mode != 'trust' else 'auto-approved' }}{% else %}not auto-approvable: {{ v.auto.blocked_by | join(', ') }}{% endif %} · {{ v.model }} · {{ v.latency_ms }} ms{% if v.usage %} · {{ v.usage.input_tokens }} tokens in{% endif %}{% if v.omitted %} · omitted: {{ v.omitted | join(', ') }}{% endif %}</li>
9
+ </ul>{% else %}<div class="small mut">{{ v.status }}{% if v.reason %}: {{ v.reason }}{% endif %} · the decision is yours as usual</div>{% endif %}
10
+ </div>{% endif %}
@@ -0,0 +1,217 @@
1
+ /* Page state, camera, input and snapshot refresh. Discovery remains server-owned. */
2
+ (()=>{
3
+ 'use strict';
4
+ const $=id=>document.getElementById(id),T=KeywrightTopology,V=KeywrightTopologyView;
5
+ let graph=JSON.parse($('net-data').textContent),loadedAt=Date.now(),busy=false,lastAttempt=0;
6
+ if(!graph.sources.length){$('net-freshness').textContent='No controller connected';return;}
7
+ const surface=$('network-svg'),scene=$('net-scene'),renderer=new V.Renderer(scene);
8
+ const ui={expanded:new Set(),pages:new Map(),selected:'',branch:'',status:''};
9
+ let mobile=matchMedia('(max-width: 700px)').matches,model,geometry,layoutKey='',camera={x:0,y:0,s:1},size={w:1,h:1};
10
+ let returnFocus=null,searchTimer=0,animation=0;
11
+ const date=v=>v?new Date(v).toLocaleString():'Not yet collected';
12
+ function el(tag,text,cls){const n=document.createElement(tag);if(text!==undefined)n.textContent=text;if(cls)n.className=cls;return n;}
13
+ function button(text,action,cls='btn'){const b=el('button',text,cls);b.type='button';b.onclick=action;return b;}
14
+ function error(message){$('net-error').textContent=message;$('net-error').hidden=!message;}
15
+ function setCamera(next){camera=next;scene.setAttribute('transform',`translate(${camera.x} ${camera.y}) scale(${camera.s})`);$('net-scale').textContent=Math.round(camera.s*100)+'%';}
16
+ function moveCamera(next,animate=false){
17
+ cancelAnimationFrame(animation);
18
+ if(!animate||matchMedia('(prefers-reduced-motion: reduce)').matches){setCamera(next);return;}
19
+ const start={...camera},at=performance.now();
20
+ function frame(now){const t=Math.min(1,(now-at)/180),ease=1-(1-t)**3;setCamera({x:start.x+(next.x-start.x)*ease,y:start.y+(next.y-start.y)*ease,s:start.s+(next.s-start.s)*ease});if(t<1)animation=requestAnimationFrame(frame);}
21
+ animation=requestAnimationFrame(frame);
22
+ }
23
+ function focusBounds(b,animate=true){
24
+ // Automatic camera operations never shrink labels below their designed size.
25
+ const s=Math.max(1,Math.min(1.15,(size.w-24)/b.width,(size.h-32)/b.height));
26
+ const x=size.w/2-(b.x+b.width/2)*s;
27
+ const y=b.height*s>size.h-32?16-b.y*s:size.h/2-(b.y+b.height/2)*s;
28
+ moveCamera({x,y,s},animate);
29
+ $('net-view-note').textContent=b.width*s>size.w||b.height*s>size.h?'Pan to explore · labels stay at readable size':'Drag to pan · pinch or use + / − to zoom';
30
+ }
31
+ function zoom(factor,anchor={x:size.w/2,y:size.h/2}){
32
+ const s=Math.max(.45,Math.min(4,camera.s*factor)),ratio=s/camera.s;
33
+ moveCamera({s,x:anchor.x-(anchor.x-camera.x)*ratio,y:anchor.y-(anchor.y-camera.y)*ratio});
34
+ }
35
+ function rebuild(force=false){
36
+ model=T.normalize(graph,$('net-site').value);
37
+ for(const id of ui.expanded)if(!model.groups.has(id))ui.expanded.delete(id);
38
+ if(ui.selected&&!model.nodes.has(ui.selected)&&!model.groups.has(ui.selected))ui.selected='';
39
+ if(ui.branch&&!model.groups.has(ui.branch)){ui.branch='';closeDetails(false);}
40
+ const key=JSON.stringify([model.signature,mobile,[...ui.expanded].sort(),[...ui.pages].sort()]);
41
+ if(force||key!==layoutKey){geometry=T.layout(model,{mobile,...ui});layoutKey=key;}
42
+ renderer.draw(model,geometry,ui);branchBar();counts();deviceList();searchResults(false);
43
+ $('net-map-empty').hidden=!!model.nodes.size;
44
+ if(!$('net-details').hidden&&ui.selected)details(ui.selected,false);
45
+ }
46
+ function defaults(){
47
+ ui.expanded.clear();ui.pages.clear();ui.branch='';ui.selected='';closeDetails(false);rebuild(true);
48
+ // Expand only clusters that fit comfortably; dense networks keep an honest overview.
49
+ if(!mobile){for(const [id,g] of model.groups){if(!g.clients.length)continue;ui.expanded.add(id);const trial=T.layout(model,{mobile,...ui});if(trial.bounds.width>size.w-32||trial.bounds.height>size.h-32)ui.expanded.delete(id);}rebuild();}
50
+ focusBounds(geometry.bounds,false);
51
+ }
52
+ function overview(){ui.expanded.clear();ui.branch='';ui.selected='';closeDetails(false);rebuild();focusBounds(geometry.bounds);}
53
+ function branchBar(){
54
+ const group=model.groups.get(ui.branch),bar=$('net-branch');bar.hidden=!group;
55
+ if(!group){$('net-path').textContent='Infrastructure overview · select a switch or AP to explore';return;}
56
+ const block=geometry.blocks.get(ui.branch),total=block.total;
57
+ $('net-branch-name').textContent=group.node.name;
58
+ $('net-page').textContent=total?(ui.expanded.has(ui.branch)?`${block.page*block.pageSize+1}–${Math.min(total,(block.page+1)*block.pageSize)} of ${total} clients`:`${total} clients collapsed`):'No directly connected clients';
59
+ $('net-prev').disabled=!ui.expanded.has(ui.branch)||block.page===0;
60
+ $('net-next').disabled=!ui.expanded.has(ui.branch)||(block.page+1)*block.pageSize>=total;
61
+ $('net-collapse').disabled=!total;$('net-collapse').textContent=ui.expanded.has(ui.branch)?'Collapse clients':'Expand clients';
62
+ $('net-show-details').disabled=group.synthetic;
63
+ const path=[],seen=new Set();let id=ui.selected||ui.branch;
64
+ while(id&&!seen.has(id)){seen.add(id);path.unshift(model.nodes.get(id)?.name||model.groups.get(id)?.node.name||'Unknown');id=model.groupOf.get(id)||model.parent.get(id);}
65
+ $('net-path').textContent=path.join(' › ');
66
+ }
67
+ function select(id,showDetails=false){
68
+ if(!model.nodes.has(id)&&!model.groups.has(id))return;
69
+ ui.selected=id;const group=model.groups.has(id)?id:model.groupOf.get(id);ui.branch=group||'';
70
+ if(group){
71
+ ui.expanded.add(group);
72
+ if(group!==id)ui.pages.set(group,Math.floor(model.groups.get(group).clients.indexOf(id)/(mobile?8:10)));
73
+ }
74
+ rebuild();
75
+ const rect=geometry.positions.get(id);
76
+ focusBounds(group===id?geometry.clusterBounds.get(id):rect||geometry.bounds);
77
+ if(group!==id||showDetails)details(id,true);else closeDetails(false);
78
+ }
79
+ function closeDetails(restore=true){$('net-details').hidden=true;if(restore&&returnFocus?.isConnected)returnFocus.focus({preventScroll:true});}
80
+ function revealAboveSheet(id){
81
+ if(!matchMedia('(max-width: 900px)').matches||$('net-details').hidden)return;
82
+ const p=geometry.positions.get(id);if(!p)return;
83
+ const canvas=surface.getBoundingClientRect(),sheet=$('net-details').getBoundingClientRect();
84
+ const top=Math.max(canvas.top,document.querySelector('.site-header')?.getBoundingClientRect().bottom||0);
85
+ const bottom=Math.min(canvas.bottom,sheet.top-12);
86
+ if(bottom-top>80)moveCamera({...camera,y:(top+bottom)/2-canvas.top-(p.y+p.height/2)*camera.s});
87
+ }
88
+ function details(id,focus){
89
+ const n=model.nodes.get(id);if(!n)return;
90
+ const box=$('net-details');box.hidden=false;$('net-detail-title').textContent=n.name;
91
+ const body=$('net-detail-body');body.replaceChildren(el('p',n.virtual?'Recorded connection · not monitored':V.states[n.state]||'Unknown','pill'));
92
+ const dl=el('dl');
93
+ const fields=[['Connected to',n.attachment_label||'Not reported'],['Connection source',n.attachment_source||'Not reported'],['Connection',n.hub&&n.medium==='wifi'?'Mesh Wi-Fi':({wifi:'Wi-Fi',wired:'Wired'})[n.medium]||'Unspecified'],['Port / SSID',n.port||'Not reported'],['IP address',n.addresses.join(', ')||'Not reported'],['MAC',n.mac||'Not reported'],['Site',n.site||n.site_id||'Not reported'],['Device',n.model||n.role],['Last observed',date(n.last_observed)],['First observed',date(n.first_observed)],['Source state',n.source_status||'Unknown']];
94
+ for(const [key,value] of fields)dl.append(el('dt',key),el('dd',value));body.append(dl);
95
+ if(n.uplink_note&&!n.attachment)body.append(el('p',n.uplink_note,'help'));
96
+ if(n.missing)body.append(el('p','Absent from the latest successful refresh. This is its last recorded connection.','help'));
97
+ if(n.unreviewed)body.append(el('p','Unreviewed in inventory. DHCP reservation status is unknown.','help'));
98
+ for(const [url,label] of [[n.review_url,'Review observation'],[n.asset_url,'Open inventory record']])if(url?.startsWith('/ui/')){const a=el('a',label,'btn');a.href=url;body.append(a);}
99
+ if(focus){returnFocus=renderer.nodeElements.get(id)||document.activeElement;$('net-detail-close').focus({preventScroll:true});requestAnimationFrame(()=>revealAboveSheet(id));}
100
+ }
101
+ function matches(n,q){return [n.name,n.mac,...n.addresses,n.port,n.site,n.model].join(' ').toLowerCase().includes(q);}
102
+ function statusMatch(n){return !ui.status||ui.status==='unreviewed'&&n.unreviewed||ui.status==='inactive'&&['inactive','missing'].includes(n.state)||ui.status==='unknown'&&['unknown','stale'].includes(n.state)||n.state===ui.status;}
103
+ function counts(){
104
+ const ns=[...model.nodes.values()].filter(n=>!n.virtual),count={};ns.forEach(n=>count[n.state]=(count[n.state]||0)+1);
105
+ const values=[['',ns.length,'All'],['online',count.online||0,'Online'],['inactive',(count.inactive||0)+(count.missing||0),'Inactive / missing'],['old',count.old||0,'24h+'],['unknown',(count.unknown||0)+(count.stale||0),'Unknown / stale'],['unreviewed',ns.filter(n=>n.unreviewed).length,'Unreviewed']];
106
+ const active=document.activeElement?.dataset?.status;
107
+ $('net-counts').replaceChildren(...values.map(([state,value,label])=>{const b=button(`${value} ${label}`,()=>{ui.status=state;renderer.emphasize(model,ui);counts();deviceList();},'net-status-button '+state);b.setAttribute('aria-pressed',String(ui.status===state));b.dataset.status=state;return b;}));
108
+ if(active!==undefined)[...$('net-counts').children].find(n=>n.dataset.status===active)?.focus({preventScroll:true});
109
+ }
110
+ function deviceList(){
111
+ const q=$('net-search').value.trim().toLowerCase(),nodes=[...model.nodes.values()].filter(n=>matches(n,q)&&statusMatch(n)).sort((a,b)=>a.name.localeCompare(b.name));
112
+ $('net-list-summary').textContent=`Device list · ${nodes.length} matching`;
113
+ const body=$('net-list');body.replaceChildren();
114
+ for(const n of nodes){const row=el('tr'),name=el('td');name.dataset.label='Device';name.append(button(n.name,()=>{select(n.id,true);surface.scrollIntoView({block:'center'});},'net-list-button'));row.append(name);
115
+ for(const [key,value] of [['State',(V.states[n.state]||'Unknown')+(n.unreviewed?' · Unreviewed':'')],['Connected to',n.attachment_label||'Not reported'],['IP address',n.addresses.join(', ')||'—']]){const td=el('td',value);td.dataset.label=key;row.append(td);}body.append(row);}
116
+ $('net-empty-list').hidden=!!nodes.length;
117
+ }
118
+ function searchResults(auto){
119
+ const q=$('net-search').value.trim().toLowerCase(),box=$('net-search-results');box.replaceChildren();box.hidden=!q;
120
+ if(!q)return;
121
+ const matchesList=[...model.nodes.values()].filter(n=>matches(n,q)).sort((a,b)=>a.name.localeCompare(b.name));
122
+ box.append(el('p',`${matchesList.length} matching devices${matchesList.length>8?' · refine your search or use the device list':''}`,'help'));
123
+ for(const n of matchesList.slice(0,8))box.append(button(n.name,()=>{select(n.id,true);box.hidden=true;surface.scrollIntoView({block:'center'});},'net-search-result'));
124
+ if(auto&&matchesList.length===1){select(matchesList[0].id);box.hidden=true;surface.scrollIntoView({block:'center'});}
125
+ }
126
+ function sites(){
127
+ const current=$('net-site').value,unique=new Map();graph.nodes.forEach(n=>unique.set(n.site_id,n.site||n.site_id));
128
+ $('net-site').replaceChildren(new Option('All sites',''));[...unique].sort((a,b)=>a[1].localeCompare(b[1])).forEach(([id,name])=>$('net-site').add(new Option(name,id)));
129
+ if(unique.has(current))$('net-site').value=current;
130
+ }
131
+ function freshness(){
132
+ const elapsed=Date.now()-loadedAt,age=graph.last_success?Math.max(0,new Date(graph.generated_at)-new Date(graph.last_success)+elapsed):Infinity;
133
+ if(graph.fresh&&(age>graph.fresh_seconds*1000||elapsed>graph.fresh_seconds*1000)){graph.fresh=false;graph.nodes.forEach(n=>n.state='stale');rebuild();}
134
+ $('net-freshness').textContent=(graph.fresh?'Current snapshot':'Snapshot not current')+' · Refreshed '+date(graph.last_success)+(graph.configuration_changed?' · Controller configuration changed':'');
135
+ }
136
+ function accept(data){const empty=!graph.nodes.length;graph=data;loadedAt=Date.now();sites();rebuild();if(empty)defaults();freshness();}
137
+ async function readCached(){
138
+ const response=await fetch('/ui/inventory/map/data?source='+encodeURIComponent(graph.source),{headers:{Accept:'application/json'}});
139
+ if(!response.ok||response.redirected)throw new Error('Unable to load observations. Sign in again or reload this page.');accept(await response.json());
140
+ }
141
+ async function refresh(){
142
+ if(busy||!graph.source)return;busy=true;lastAttempt=Date.now();$('net-refresh').disabled=true;$('net-refresh').textContent='Refreshing…';error('');
143
+ try{
144
+ const response=await fetch('/ui/inventory/map/refresh',{method:'POST',body:new URLSearchParams({resource_id:graph.source}),headers:{Accept:'application/json'}});
145
+ if(response.redirected||!response.headers.get('content-type')?.includes('application/json'))throw new Error('Your session may have expired. Sign in again or reload this page.');
146
+ const data=await response.json();if(data.nodes)accept(data);
147
+ if(!response.ok){if(response.status===409){await readCached();error(data.error);return;}throw new Error(data.error||'Refresh failed. Previous observations have been retained.');}
148
+ }catch(e){error(e.message);if(graph.fresh){graph.fresh=false;graph.nodes.forEach(n=>n.state='stale');rebuild();}freshness();}
149
+ finally{busy=false;$('net-refresh').disabled=false;$('net-refresh').textContent='Refresh now';}
150
+ }
151
+ // Pointer gestures share one camera. Pinch is anchored to the fingers' midpoint.
152
+ const pointers=new Map();let gesture=null,suppressClick=false;
153
+ function point(e){const r=surface.getBoundingClientRect();return {x:e.clientX-r.left,y:e.clientY-r.top};}
154
+ function beginGesture(){
155
+ const ps=[...pointers.values()];if(!ps.length){gesture=null;return;}
156
+ gesture={camera:{...camera},points:ps.map(p=>({...p})),moved:false};
157
+ }
158
+ surface.addEventListener('pointerdown',e=>{
159
+ if(e.button!==0)return;cancelAnimationFrame(animation);pointers.set(e.pointerId,point(e));surface.setPointerCapture(e.pointerId);beginGesture();suppressClick=false;
160
+ });
161
+ surface.addEventListener('pointermove',e=>{
162
+ if(!pointers.has(e.pointerId)||!gesture)return;pointers.set(e.pointerId,point(e));
163
+ const ps=[...pointers.values()],start=gesture.points,c=gesture.camera;
164
+ if(ps.length>=2&&start.length>=2){
165
+ const midpoint=a=>({x:(a[0].x+a[1].x)/2,y:(a[0].y+a[1].y)/2}),a=midpoint(start),b=midpoint(ps);
166
+ const distance=a=>Math.hypot(a[0].x-a[1].x,a[0].y-a[1].y);
167
+ const s=Math.max(.45,Math.min(4,c.s*distance(ps)/Math.max(1,distance(start))));
168
+ setCamera({s,x:b.x-(a.x-c.x)*s/c.s,y:b.y-(a.y-c.y)*s/c.s});gesture.moved=true;suppressClick=true;
169
+ }else{
170
+ const dx=ps[0].x-start[0].x,dy=ps[0].y-start[0].y;
171
+ if(Math.hypot(dx,dy)>4||gesture.moved){gesture.moved=true;suppressClick=true;setCamera({s:c.s,x:c.x+dx,y:c.y+dy});}
172
+ }
173
+ });
174
+ function endPointer(e){
175
+ if(!pointers.has(e.pointerId))return;
176
+ // Capture retargets pointerup: use hit testing to distinguish a node tap from panning.
177
+ const tap=e.type==='pointerup'&&!suppressClick&&pointers.size===1;
178
+ pointers.delete(e.pointerId);if(surface.hasPointerCapture(e.pointerId))surface.releasePointerCapture(e.pointerId);
179
+ if(tap){const n=document.elementFromPoint(e.clientX,e.clientY)?.closest('[data-action="select"]');if(n)select(n.dataset.id);}
180
+ beginGesture();
181
+ }
182
+ surface.addEventListener('pointerup',endPointer);surface.addEventListener('pointercancel',endPointer);
183
+ surface.addEventListener('lostpointercapture',e=>{pointers.delete(e.pointerId);beginGesture();});
184
+ surface.addEventListener('click',e=>{if(e.detail===0){const n=e.target.closest('[data-action="select"]');if(n)select(n.dataset.id);}});
185
+ surface.addEventListener('keydown',e=>{
186
+ const node=e.target.closest('[data-action="select"]');
187
+ if(node&&(e.key==='Enter'||e.key===' ')){e.preventDefault();select(node.dataset.id);return;}
188
+ const delta={ArrowLeft:[40,0],ArrowRight:[-40,0],ArrowUp:[0,40],ArrowDown:[0,-40]}[e.key];
189
+ if(delta){e.preventDefault();moveCamera({...camera,x:camera.x+delta[0],y:camera.y+delta[1]});}
190
+ else if(e.key==='+'||e.key==='='){e.preventDefault();zoom(1.2);}else if(e.key==='-'){e.preventDefault();zoom(1/1.2);}
191
+ });
192
+ surface.addEventListener('wheel',e=>{if(e.ctrlKey||e.metaKey){e.preventDefault();zoom(Math.exp(-e.deltaY*.008),point(e));}},{passive:false});
193
+ $('net-zoom-in').onclick=()=>zoom(1.2);$('net-zoom-out').onclick=()=>zoom(1/1.2);
194
+ $('net-fit').onclick=()=>focusBounds(ui.branch?geometry.clusterBounds.get(ui.branch):geometry.bounds);
195
+ $('net-overview').onclick=overview;$('net-reset').onclick=()=>{ui.status='';$('net-search').value='';defaults();};
196
+ $('net-collapse').onclick=()=>{ui.selected=ui.branch;closeDetails(false);if(ui.expanded.has(ui.branch))ui.expanded.delete(ui.branch);else ui.expanded.add(ui.branch);rebuild();focusBounds(geometry.clusterBounds.get(ui.branch));};
197
+ function page(delta){const b=geometry.blocks.get(ui.branch);ui.pages.set(ui.branch,b.page+delta);ui.selected=ui.branch;closeDetails(false);rebuild();focusBounds(geometry.clusterBounds.get(ui.branch));}
198
+ $('net-prev').onclick=()=>page(-1);$('net-next').onclick=()=>page(1);
199
+ $('net-show-details').onclick=()=>{ui.selected=ui.branch;details(ui.branch,true);renderer.emphasize(model,ui);};
200
+ $('net-detail-close').onclick=()=>closeDetails();document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeDetails();$('net-search-results').hidden=true;}});
201
+ $('net-search').addEventListener('input',()=>{clearTimeout(searchTimer);deviceList();searchResults(false);searchTimer=setTimeout(()=>searchResults(true),350);});
202
+ $('net-search').addEventListener('keydown',e=>{if(e.key==='Enter'){clearTimeout(searchTimer);const q=e.target.value.trim().toLowerCase(),n=[...model.nodes.values()].find(n=>matches(n,q));if(q&&n){select(n.id,true);$('net-search-results').hidden=true;surface.scrollIntoView({block:'center'});}}});
203
+ $('net-site').onchange=()=>{ui.status='';defaults();};
204
+ $('net-source').onchange=()=>{location.href='/ui/inventory/map?source='+encodeURIComponent($('net-source').value);};
205
+ $('net-refresh').onclick=refresh;
206
+ function tick(){freshness();if(!document.hidden&&$('net-auto').checked&&!busy&&Date.now()-lastAttempt>=graph.refresh_seconds*1000)refresh();}
207
+ $('net-auto').onchange=()=>{if($('net-auto').checked)tick();};
208
+ function resize(){
209
+ const rect=surface.getBoundingClientRect(),next={w:rect.width,h:rect.height};if(next.w===size.w&&next.h===size.h)return;
210
+ const first=size.w===1,wasMobile=mobile;mobile=matchMedia('(max-width: 700px)').matches;size=next;
211
+ surface.setAttribute('viewBox',`0 0 ${size.w} ${size.h}`);
212
+ if(first||wasMobile!==mobile)defaults();else focusBounds(ui.branch?geometry.clusterBounds.get(ui.branch):geometry.bounds,false);
213
+ }
214
+ sites();resize();new ResizeObserver(resize).observe(surface);
215
+ lastAttempt=graph.fresh?Date.now():0;freshness();if(graph.error)error(graph.error);
216
+ setInterval(tick,1000);document.addEventListener('visibilitychange',()=>{if(!document.hidden)tick();});
217
+ })();