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.
- keywright_server/__init__.py +3 -0
- keywright_server/__main__.py +71 -0
- keywright_server/access.py +131 -0
- keywright_server/access_view.py +144 -0
- keywright_server/actions.py +30 -0
- keywright_server/approvals.py +433 -0
- keywright_server/audit.py +58 -0
- keywright_server/bundles.py +87 -0
- keywright_server/config.py +102 -0
- keywright_server/db.py +119 -0
- keywright_server/discovery.py +277 -0
- keywright_server/events.py +78 -0
- keywright_server/friction.py +242 -0
- keywright_server/integrations.py +64 -0
- keywright_server/inventory.py +415 -0
- keywright_server/inventory_mcp.py +288 -0
- keywright_server/jev.py +80 -0
- keywright_server/knowledge.py +403 -0
- keywright_server/knowledge_chunking.py +118 -0
- keywright_server/knowledge_index.py +233 -0
- keywright_server/knowledge_mcp.py +82 -0
- keywright_server/llm.py +338 -0
- keywright_server/llm_proxy.py +271 -0
- keywright_server/llm_usage.py +99 -0
- keywright_server/mail.py +312 -0
- keywright_server/mcp_oauth.py +422 -0
- keywright_server/mcp_proxy.py +128 -0
- keywright_server/mcp_server.py +1025 -0
- keywright_server/models.py +442 -0
- keywright_server/network_map.py +128 -0
- keywright_server/notify/__init__.py +8 -0
- keywright_server/notify/base.py +156 -0
- keywright_server/notify/homeassistant.py +221 -0
- keywright_server/notify/log.py +30 -0
- keywright_server/notify/manager.py +131 -0
- keywright_server/notify/webhook.py +79 -0
- keywright_server/nudges.py +121 -0
- keywright_server/omada.py +179 -0
- keywright_server/policy.py +265 -0
- keywright_server/policy_preview.py +151 -0
- keywright_server/reconciliation.py +188 -0
- keywright_server/result_inspection.py +293 -0
- keywright_server/semantic.py +451 -0
- keywright_server/semantic_questions.json +120 -0
- keywright_server/settings_store.py +44 -0
- keywright_server/ssh.py +290 -0
- keywright_server/tool_access.py +260 -0
- keywright_server/usage.py +121 -0
- keywright_server/web/__init__.py +0 -0
- keywright_server/web/admin.py +1126 -0
- keywright_server/web/app.py +154 -0
- keywright_server/web/auth.py +144 -0
- keywright_server/web/discovery_ui.py +112 -0
- keywright_server/web/friction_ui.py +92 -0
- keywright_server/web/inventory_ui.py +192 -0
- keywright_server/web/knowledge_ui.py +177 -0
- keywright_server/web/mail_ui.py +190 -0
- keywright_server/web/mcp_oauth_ui.py +87 -0
- keywright_server/web/network_map_ui.py +37 -0
- keywright_server/web/oidc.py +231 -0
- keywright_server/web/security.py +77 -0
- keywright_server/web/templates/_activity_row.html +25 -0
- keywright_server/web/templates/_agent_setup.html +50 -0
- keywright_server/web/templates/_collections_style.html +88 -0
- keywright_server/web/templates/_constraints.html +6 -0
- keywright_server/web/templates/_decide.html +39 -0
- keywright_server/web/templates/_device_kind_picker.html +66 -0
- keywright_server/web/templates/_hazards.html +6 -0
- keywright_server/web/templates/_icons.html +19 -0
- keywright_server/web/templates/_infrastructure_nav.html +8 -0
- keywright_server/web/templates/_integration_style.html +30 -0
- keywright_server/web/templates/_inventory_forms.html +36 -0
- keywright_server/web/templates/_inventory_style.html +36 -0
- keywright_server/web/templates/_live.html +10 -0
- keywright_server/web/templates/_mail_nav.html +5 -0
- keywright_server/web/templates/_mail_style.html +10 -0
- keywright_server/web/templates/_network_map_script.html +5 -0
- keywright_server/web/templates/_nudge_card.html +12 -0
- keywright_server/web/templates/_patterns.html +6 -0
- keywright_server/web/templates/_policy_preview_results.html +13 -0
- keywright_server/web/templates/_request_card.html +46 -0
- keywright_server/web/templates/_semantic.html +10 -0
- keywright_server/web/templates/_topology_controller.js +217 -0
- keywright_server/web/templates/_topology_layout.js +174 -0
- keywright_server/web/templates/_topology_render.js +117 -0
- keywright_server/web/templates/access.html +119 -0
- keywright_server/web/templates/agents.html +49 -0
- keywright_server/web/templates/audit.html +6 -0
- keywright_server/web/templates/base.html +198 -0
- keywright_server/web/templates/dashboard.html +226 -0
- keywright_server/web/templates/decision.html +26 -0
- keywright_server/web/templates/friction.html +36 -0
- keywright_server/web/templates/friction_review.html +21 -0
- keywright_server/web/templates/grants.html +61 -0
- keywright_server/web/templates/integration_picker.html +47 -0
- keywright_server/web/templates/inventory.html +21 -0
- keywright_server/web/templates/inventory_asset.html +42 -0
- keywright_server/web/templates/inventory_discovery.html +32 -0
- keywright_server/web/templates/inventory_discovery_review.html +34 -0
- keywright_server/web/templates/inventory_map.html +45 -0
- keywright_server/web/templates/inventory_networks.html +12 -0
- keywright_server/web/templates/inventory_subnet.html +41 -0
- keywright_server/web/templates/inventory_topology.html +22 -0
- keywright_server/web/templates/knowledge.html +25 -0
- keywright_server/web/templates/knowledge_document.html +7 -0
- keywright_server/web/templates/knowledge_proposal.html +7 -0
- keywright_server/web/templates/login.html +9 -0
- keywright_server/web/templates/mail.html +45 -0
- keywright_server/web/templates/mail_rules.html +24 -0
- keywright_server/web/templates/mail_settings.html +19 -0
- keywright_server/web/templates/mcp_oauth_continue.html +14 -0
- keywright_server/web/templates/policies.html +30 -0
- keywright_server/web/templates/request_detail.html +27 -0
- keywright_server/web/templates/requests.html +9 -0
- keywright_server/web/templates/resource_form.html +198 -0
- keywright_server/web/templates/resources.html +76 -0
- keywright_server/web/templates/settings.html +53 -0
- keywright_server/web/templates/setup.html +8 -0
- keywright_server/web/templates/usage.html +84 -0
- keywright_server/web/usage_ui.py +41 -0
- keywright_server-0.0.1.dist-info/METADATA +152 -0
- keywright_server-0.0.1.dist-info/RECORD +127 -0
- keywright_server-0.0.1.dist-info/WHEEL +4 -0
- keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
- keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
- keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
- keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{% extends "base.html" %}{% from "_icons.html" import icon %}
|
|
2
|
+
{% block title %}Add a resource · Keywright{% endblock %}
|
|
3
|
+
{% block body %}
|
|
4
|
+
{% include "_integration_style.html" %}
|
|
5
|
+
<div class="integration-page">
|
|
6
|
+
<a class="integration-back" href="/ui/resources">{{ icon('chevron') }} Resources</a>
|
|
7
|
+
<div class="integration-heading"><div><h1>Add a resource</h1><p>Choose an integration. We'll guide you through the connection.</p></div></div>
|
|
8
|
+
<form class="integration-search" method="get" action="/ui/resources/new" role="search">
|
|
9
|
+
{{ icon('search') }}<input type="search" name="q" id="integration-search" value="{{ query }}" placeholder="Search integrations…" aria-label="Search integrations" autocomplete="off">
|
|
10
|
+
<noscript><button class="btn">Search</button></noscript>
|
|
11
|
+
</form>
|
|
12
|
+
{% for title, entries, custom in [('Integrations', presets, false), ('Custom connections', generics, true)] %}
|
|
13
|
+
<section class="integration-group {{ 'integration-custom' if custom else '' }}" aria-label="{{ title }}" {% if not entries | selectattr('matches') | list %}hidden{% endif %}>
|
|
14
|
+
<h2>{{ title }}</h2>
|
|
15
|
+
<div class="integration-list">
|
|
16
|
+
{% for entry in entries %}{% set item = entry.item %}
|
|
17
|
+
<a class="integration-choice" data-integration data-search="{{ item.search_text }}" href="/ui/resources/new?{{ 'kind=' ~ item.kind if custom else 'integration=' ~ item.id }}" {% if not entry.matches %}hidden{% endif %}>
|
|
18
|
+
<span class="integration-mark {{ item.icon }}">{% if item.icon == 'notion' %}N{% else %}{{ icon(item.icon) }}{% endif %}</span>
|
|
19
|
+
<div class="integration-copy"><strong>{{ item.title }}</strong><p>{{ item.description }}</p></div>
|
|
20
|
+
<span class="integration-method">{{ item.badge }}</span><span class="integration-arrow">{{ icon('chevron') }}</span>
|
|
21
|
+
</a>
|
|
22
|
+
{% endfor %}
|
|
23
|
+
</div>
|
|
24
|
+
</section>
|
|
25
|
+
{% endfor %}
|
|
26
|
+
<div class="integration-empty" id="integration-empty" {% if matches %}hidden{% endif %}><h2>No matching integrations</h2><p>Try a different name, or use a custom connection.</p><a class="btn" href="/ui/resources/new">Show all connections</a></div>
|
|
27
|
+
<p class="integration-count" id="integration-count" role="status" aria-live="polite">{{ matches }} connection{{ '' if matches == 1 else 's' }} available</p>
|
|
28
|
+
</div>
|
|
29
|
+
{% endblock %}
|
|
30
|
+
{% block scripts %}
|
|
31
|
+
<script>
|
|
32
|
+
(function () {
|
|
33
|
+
var search = document.getElementById('integration-search');
|
|
34
|
+
var items = Array.from(document.querySelectorAll('[data-integration]'));
|
|
35
|
+
function filter() {
|
|
36
|
+
var terms = search.value.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
37
|
+
var count = 0;
|
|
38
|
+
items.forEach(function (item) { item.hidden = !terms.every(function (term) { return item.dataset.search.includes(term); }); if (!item.hidden) count++; });
|
|
39
|
+
document.querySelectorAll('.integration-group').forEach(function (group) { group.hidden = !Array.from(group.querySelectorAll('[data-integration]')).some(function (item) { return !item.hidden; }); });
|
|
40
|
+
document.getElementById('integration-empty').hidden = count !== 0;
|
|
41
|
+
document.getElementById('integration-count').textContent = count + ' connection' + (count === 1 ? '' : 's') + ' available';
|
|
42
|
+
}
|
|
43
|
+
search.addEventListener('input', filter);
|
|
44
|
+
search.form.addEventListener('submit', function (event) { event.preventDefault(); filter(); });
|
|
45
|
+
})();
|
|
46
|
+
</script>
|
|
47
|
+
{% endblock %}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Infrastructure inventory · Keywright{% endblock %}{% block body %}
|
|
2
|
+
{% include "_infrastructure_nav.html" %}
|
|
3
|
+
<div class="inventory-heading"><h1>Infrastructure inventory</h1><a class="btn p" href="/ui/inventory/assets/new">Add device or workload</a></div>
|
|
4
|
+
<p class="page-intro">Hosts, devices, and services behind your resources. Group by network, parent host, or connection type.</p>
|
|
5
|
+
<form class="inventory-filters" method="get">
|
|
6
|
+
<div><label for="inventory-q">Search inventory</label><input id="inventory-q" name="q" type="text" value="{{ q }}" placeholder="Name, IP, site, tag, or resource"></div>
|
|
7
|
+
<div><label for="inventory-sort">Sort by</label><select id="inventory-sort" name="sort">{% for key,label in [('name','Name'),('ip','IP address'),('kind','Device type'),('site','Site')] %}<option value="{{ key }}" {{ 'selected' if sort==key }}>{{ label }}</option>{% endfor %}</select></div>
|
|
8
|
+
<div><label for="inventory-group">Group by</label><select id="inventory-group" name="group">{% for key,label in [('none','None'),('host','Parent host'),('network','Network'),('medium','Connection type'),('site','Site'),('kind','Device type')] %}<option value="{{ key }}" {{ 'selected' if group==key }}>{{ label }}</option>{% endfor %}</select></div>
|
|
9
|
+
<button class="btn" type="submit">Apply</button>{% if q or group!='none' or sort!='name' %}<a class="btn" href="/ui/inventory">Reset</a>{% endif %}
|
|
10
|
+
</form>
|
|
11
|
+
{% if rows %}<div class="card"><table class="data-table inventory-table" role="table"><thead><tr><th scope="col">Device / workload</th><th scope="col">Connection</th><th scope="col">Addresses</th><th scope="col">Hosted on</th><th scope="col">Resources</th></tr></thead><tbody>
|
|
12
|
+
{% for row in rows %}{% if group!='none' and loop.changed(row.group) %}<tr class="group-row"><th class="group-label" colspan="5" scope="rowgroup">{{ row.group }}</th></tr>{% endif %}
|
|
13
|
+
<tr><td data-label="Device / workload" class="wide"><a href="/ui/inventory/assets/{{ row.asset.id }}"><b>{{ row.asset.name }}</b></a><small>{{ kinds[row.asset.kind] }}{% if row.asset.site %} · {{ row.asset.site }}{% endif %}</small>{% if row.asset.tags %}<small>{{ row.asset.tags }}</small>{% endif %}</td>
|
|
14
|
+
<td data-label="Connection">{{ row.media|join(', ') or 'Unknown' }}</td>
|
|
15
|
+
<td data-label="Addresses">{% for allocation in row.allocations %}<div><a class="mono" href="/ui/inventory/networks/{{ allocation.subnet_id }}">{{ allocation.address }}</a><small>{{ states[allocation.state] }} · {{ subnet_map[allocation.subnet_id].name }}</small></div>{% else %}<span class="mut">Not recorded</span>{% endfor %}</td>
|
|
16
|
+
<td data-label="Hosted on">{% if row.parent %}<a href="/ui/inventory/assets/{{ row.parent.id }}">{{ row.parent.name }}</a>{% else %}<span class="mut">—</span>{% endif %}</td>
|
|
17
|
+
<td data-label="Resources">{% for resource in row.resources %}<div><a href="/ui/resources/{{ resource.id }}">{{ resource.name }}</a></div>{% else %}<span class="mut">None linked</span>{% endfor %}</td></tr>{% endfor %}
|
|
18
|
+
</tbody></table></div><p class="mut small">{{ rows|length }} of {{ assets|length }} infrastructure records</p>
|
|
19
|
+
{% else %}<div class="card collection-empty"><h2>{{ 'No matching records' if assets else 'Build your infrastructure inventory' }}</h2><p class="mut">{{ 'Try a different search.' if assets else 'Add physical hosts first, then their VMs and containers. Link existing resources to the host or workload they belong to.' }}</p><a class="btn" href="{{ '/ui/inventory' if assets else '/ui/inventory/assets/new' }}">{{ 'Clear search' if assets else 'Add your first host' }}</a></div>{% endif %}
|
|
20
|
+
<div class="inventory-note">Manually maintained inventory. Reachability is not monitored yet; missing observations do not mean a device is offline or its IP is available.</div>
|
|
21
|
+
{% endblock %}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{% extends "base.html" %}{% from "_inventory_forms.html" import field, interface_form, remove, device_kind %}
|
|
2
|
+
{% block title %}{{ asset.name if asset else 'Add infrastructure' }} · Keywright{% endblock %}{% block body %}
|
|
3
|
+
{% include "_infrastructure_nav.html" %}
|
|
4
|
+
<div class="inventory-heading"><h1>{{ asset.name if asset else 'Add device or workload' }}</h1><a class="small" href="/ui/inventory">Back to inventory</a></div>
|
|
5
|
+
<p class="page-intro">{{ 'Manage hosting, interfaces, addresses, and linked resources.' if asset else 'Create the host or workload first, then add its interfaces and IP allocations.' }}</p>
|
|
6
|
+
{% if asset %}<div class="inventory-facts"><span>Maintenance <b>Reviewed inventory</b></span><span>Reachability <b>Unknown · not monitored</b></span><span>Updated <b>{{ asset.updated_at.strftime('%Y-%m-%d %H:%M') }} UTC</b></span></div>
|
|
7
|
+
{% if discovery_sources.get(asset.id) %}<div class="card"><h2>Discovery sources</h2>{% for source in discovery_sources[asset.id] %}<p><a href="/ui/inventory/discovery/{{ source.observation.id }}/review">{{ source.resource.name }} · {{ source.observation.data.name }}</a><span class="small mut"> · observed {{ source.observation.last_seen.strftime('%Y-%m-%d %H:%M') }} UTC · reviewed {{ source.link.reviewed_at.strftime('%Y-%m-%d %H:%M') }} UTC</span>{% if source.link.reviewed_data != source.observation.data %}<span class="pill">Needs review</span>{% endif %}</p>{% endfor %}</div>{% endif %}{% endif %}
|
|
8
|
+
<div class="card"><form method="post" action="/ui/inventory/assets" class="inventory-form">
|
|
9
|
+
<input type="hidden" name="id" value="{{ asset.id if asset else '' }}">
|
|
10
|
+
{% if values.get('resource_id') %}<input type="hidden" name="resource_id" value="{{ values.resource_id }}"><p class="full small mut">The selected resource will be linked when this record is created.</p>{% endif %}
|
|
11
|
+
{{ field('name','Name',values,asset.name if asset else '',required=true) }}
|
|
12
|
+
{{ device_kind('asset-kind',kinds,values.get('kind',asset.kind if asset else 'physical'),'Device / workload type') }}
|
|
13
|
+
<div><label for="asset-parent">Hosted on</label><select id="asset-parent" name="parent_id"><option value="">No parent host</option>{% for target in assets if not asset or target.id!=asset.id %}<option value="{{ target.id }}" {{ 'selected' if values.get('parent_id',asset.parent_id if asset else '')==target.id }}>{{ target.name }}</option>{% endfor %}</select><div class="help">The physical host, VM, or container running this workload.</div></div>
|
|
14
|
+
{{ field('site','Site / location',values,asset.site if asset else '') }}
|
|
15
|
+
<div class="full">{{ field('tags','Tags (comma separated)',values,asset.tags if asset else '',maxlength=500) }}</div>
|
|
16
|
+
<div class="full"><label for="asset-notes">Notes</label><textarea id="asset-notes" name="notes" maxlength="4000">{{ values.get('notes',asset.notes if asset else '') }}</textarea></div>
|
|
17
|
+
<div class="full form-actions"><button class="btn p">{{ 'Save record' if asset else 'Create record' }}</button></div>
|
|
18
|
+
</form></div>
|
|
19
|
+
{% if asset %}
|
|
20
|
+
{% set back='/ui/inventory/assets/' ~ asset.id %}
|
|
21
|
+
{% if children %}<h2>Hosted workloads</h2><div class="card row">{% for child in children %}<a href="/ui/inventory/assets/{{ child.id }}">{{ child.name }}</a>{% endfor %}<a class="small" href="/ui/inventory/topology">View topology</a></div>{% endif %}
|
|
22
|
+
<section class="inventory-section"><div class="inventory-heading"><h2>Network interfaces</h2></div>
|
|
23
|
+
<div class="card">
|
|
24
|
+
{% for iface in asset_interfaces %}<details class="inventory-editor" {{ 'open' if interface_values and interface_values.get('id')==iface.id }}><summary><b>{{ iface.name }}</b> <span class="mut">· {{ media[iface.medium] }}{% if iface.attached_to_id %} · {{ asset_map[iface.attached_to_id].name }}{% endif %}{% if iface.port %} · {{ iface.port }}{% endif %}</span></summary>
|
|
25
|
+
{{ interface_form(asset,iface,interface_values if interface_values and interface_values.get('id')==iface.id else {},assets,media) }}
|
|
26
|
+
<div class="inventory-delete">{{ remove('interface',iface.id,back,'Remove interface') }}</div></details>
|
|
27
|
+
{% else %}<p class="mut">No interfaces recorded. Add one to assign this device an IP address.</p>{% endfor %}
|
|
28
|
+
<details class="inventory-editor" {{ 'open' if not asset_interfaces or (interface_values and not interface_values.get('id')) }}><summary>Add interface</summary>{{ interface_form(asset,none,interface_values if interface_values and not interface_values.get('id') else {},assets,media) }}</details>
|
|
29
|
+
</div></section>
|
|
30
|
+
<section class="inventory-section"><div class="inventory-heading"><h2>IP allocations</h2><a class="btn sm" href="/ui/inventory/networks">Manage subnets & allocations</a></div>
|
|
31
|
+
<div class="card">{% if asset_allocations %}<table class="data-table" role="table"><thead><tr><th>Address</th><th>Network</th><th>Interface</th><th>Allocation</th></tr></thead><tbody>
|
|
32
|
+
{% for allocation in asset_allocations %}<tr><td data-label="Address" class="mono"><a href="/ui/inventory/networks/{{ allocation.subnet_id }}">{{ allocation.address }}</a></td><td data-label="Network">{{ subnet_map[allocation.subnet_id].name }}</td><td data-label="Interface">{{ interface_map[allocation.interface_id].name }}</td><td data-label="Allocation">{{ states[allocation.state] }}</td></tr>{% endfor %}
|
|
33
|
+
</tbody></table>{% else %}<p class="mut">No addresses recorded. Open a subnet to reserve or assign an IP to an interface.</p>{% endif %}</div></section>
|
|
34
|
+
<section class="inventory-section"><h2>Linked resources</h2><div class="card">
|
|
35
|
+
{% for resource in asset_resources %}<div class="row access-heading"><a href="/ui/resources/{{ resource.id }}">{{ resource.name }}</a><span class="pill">{{ resource.kind|upper }}</span>{{ remove('link',resource.id,back,'Unlink') }}</div>{% else %}<p class="mut">No resources linked. Inventory records do not grant agent access.</p>{% endfor %}
|
|
36
|
+
{% if unlinked_resources %}<form method="post" action="/ui/inventory/links" class="row"><input type="hidden" name="asset_id" value="{{ asset.id }}"><div style="flex:1"><label for="link-resource">Existing resource</label><select id="link-resource" name="resource_id" required>{% for resource in unlinked_resources %}<option value="{{ resource.id }}">{{ resource.name }} ({{ resource.kind|upper }})</option>{% endfor %}</select></div><button class="btn" style="align-self:end">Link resource</button></form>{% endif %}
|
|
37
|
+
</div></section>
|
|
38
|
+
<div class="inventory-delete">{{ remove('asset',asset.id,back,'Remove from inventory') }}<p class="help">Remove or reassign interfaces, child workloads, and resource links first. This does not delete the actual device.</p></div>
|
|
39
|
+
{% endif %}
|
|
40
|
+
{% endblock %}
|
|
41
|
+
|
|
42
|
+
{% block scripts %}{% include "_device_kind_picker.html" %}{% endblock %}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}Discovery · Keywright{% endblock %}{% block body %}
|
|
3
|
+
{% include "_infrastructure_nav.html" %}
|
|
4
|
+
<h1>Discovery</h1>
|
|
5
|
+
<p class="page-intro">Read devices and workloads from Omada and Proxmox, then compare them with your inventory.</p>
|
|
6
|
+
<div class="inventory-note">Refreshes only collect observations. Review a device to import it or link an existing record, then choose which fields to apply. Suggested matches need review; an observed IP may be a temporary DHCP lease.</div>
|
|
7
|
+
<div class="grid">
|
|
8
|
+
<div class="card"><h2>Omada controller</h2><form method="post" action="/ui/inventory/discovery/refresh">
|
|
9
|
+
<input type="hidden" name="provider" value="omada"><label for="omada-source">Controller resource</label>
|
|
10
|
+
<select id="omada-source" name="resource_id" required><option value="">Choose a controller</option>{% for resource in resources if resource.kind=='mcp' and resource.config.get('adapter')=='omada' %}<option value="{{ resource.id }}">{{ resource.name }}</option>{% endfor %}</select>
|
|
11
|
+
<p class="help">Reads all accessible sites, network devices, and clients. Refresh may take up to three minutes.</p><button class="btn">Refresh Omada</button></form></div>
|
|
12
|
+
<div class="card"><h2>Proxmox cluster</h2><form method="post" action="/ui/inventory/discovery/refresh">
|
|
13
|
+
<input type="hidden" name="provider" value="proxmox"><label for="proxmox-source">Proxmox SSH resource</label>
|
|
14
|
+
<select id="proxmox-source" name="resource_id" required><option value="">Choose a Proxmox host</option>{% for resource in resources if resource.kind=='ssh' %}<option value="{{ resource.id }}" {{ 'disabled' if not resource.config.get('host_key') }}>{{ resource.name }}{{ ' · test connection first' if not resource.config.get('host_key') }}</option>{% endfor %}</select>
|
|
15
|
+
<p class="help">Choose a cluster node with permission to read its hosts, VMs, and containers. Guest IPs are not included yet.</p><button class="btn">Refresh Proxmox</button></form></div>
|
|
16
|
+
</div>
|
|
17
|
+
{% if latest %}<h2>Latest refreshes</h2><div class="card"><table class="data-table inventory-table" role="table"><thead><tr><th>Source</th><th>Result</th><th>Started (UTC)</th><th>Observations</th></tr></thead><tbody>
|
|
18
|
+
{% for run in latest %}<tr><td data-label="Source">{{ resource_map[run.resource_id].name }}<small>{{ providers[run.provider] }}</small></td><td data-label="Result">{{ 'Interrupted' if run.status=='running' and run.started_at < interrupted_before else run.status|capitalize }}{% if run.error %}<small>{{ run.error }}</small>{% endif %}</td><td data-label="Started (UTC)">{{ run.started_at.strftime('%Y-%m-%d %H:%M:%S') }}</td><td data-label="Observations">{{ run.count if run.status=='succeeded' else 'Previous observations retained' }}</td></tr>{% endfor %}
|
|
19
|
+
</tbody></table></div>{% endif %}
|
|
20
|
+
<form method="get" class="inventory-filters"><div><label for="discovery-q">Search observations</label><input type="text" id="discovery-q" name="q" value="{{ q }}" placeholder="Name, MAC, IP, or site"></div><div><label for="discovery-source">Source</label><select id="discovery-source" name="source"><option value="">All sources</option>{% for resource in resources %}<option value="{{ resource.id }}" {{ 'selected' if source==resource.id }}>{{ resource.name }}</option>{% endfor %}</select></div><div><label for="discovery-state">Review status</label><select id="discovery-state" name="state"><option value="">All observations</option>{% for key,label in [('new','New'),('changed','Needs review'),('reviewed','Reviewed')] %}<option value="{{ key }}" {{ 'selected' if state==key }}>{{ label }}</option>{% endfor %}</select></div><button class="btn">Apply</button></form>
|
|
21
|
+
{% if rows %}<div class="card"><table class="data-table inventory-table" role="table"><thead><tr><th>Observed device / workload</th><th>Connection / hosting</th><th>Observed addresses</th><th>Possible inventory matches</th><th>Source / freshness</th></tr></thead><tbody>
|
|
22
|
+
{% for row in rows %}{% set record=row.record %}{% set item=record.data %}<tr>
|
|
23
|
+
<td data-label="Device / workload" class="wide"><b>{{ item.name }}</b><small>{{ kinds[item.kind] }}{% if item.site %} · {{ item.site }}{% endif %}</small><small>{{ 'Source reports: ' ~ item.status }}</small>
|
|
24
|
+
{% if row.changes %}<details><summary class="small">Changed: {{ row.changes|map(attribute='label')|join(', ') }}</summary><p class="small mut">Last change {{ record.changed_at.strftime('%Y-%m-%d %H:%M') }} UTC</p>{% for change in row.changes %}<div class="small"><b>{{ change.label }}</b>: {{ change.before }} → {{ change.after }}</div>{% endfor %}</details>{% endif %}</td>
|
|
25
|
+
<td data-label="Connection / hosting">{{ media[item.medium] }}{% if row.parent %}<small>Hosted on {{ row.parent }}</small>{% endif %}{% if row.attachment %}<small>Connected to {{ row.attachment }}</small>{% endif %}{% if item.port %}<small>{{ item.port }}</small>{% endif %}{% if item.mac %}<small class="mono">{{ item.mac }}</small>{% endif %}</td>
|
|
26
|
+
<td data-label="Observed addresses">{% for address in item.addresses %}<div class="mono">{{ address }}</div>{% else %}<span class="mut">Not reported</span>{% endfor %}</td>
|
|
27
|
+
<td data-label="Possible inventory matches">{% if row.link %}<a href="/ui/inventory/assets/{{ row.link.asset_id }}">{{ asset_map[row.link.asset_id].name }}</a><small>{{ row.review_state }}</small>{% else %}{% for match in row.matches %}<div><a href="/ui/inventory/discovery/{{ record.id }}/review?target_id={{ match.asset.id }}">{{ match.asset.name }}</a><small>{{ match.reasons }}</small></div>{% else %}<span class="mut">New · no match found</span>{% endfor %}{% if row.matches|length > 1 %}<small>Ambiguous · review before linking</small>{% endif %}{% endif %}<div><a class="btn sm" href="/ui/inventory/discovery/{{ record.id }}/review">Review</a></div></td>
|
|
28
|
+
<td data-label="Source / freshness">{{ resource_map[record.resource_id].name }}<small>{{ providers[record.provider] }} · {{ record.last_seen.strftime('%Y-%m-%d %H:%M') }} UTC</small>{% if row.missing %}<small class="warn">Not seen in latest successful refresh</small>{% elif row.stale %}<small class="warn">Observation older than 24 hours</small>{% endif %}</td>
|
|
29
|
+
</tr>{% endfor %}</tbody></table></div><p class="small mut">{{ rows|length }} of {{ total_count }} observations · page {{ page }} of {{ total_pages }}. Missing observations do not establish that a device is offline or an address is free.</p>
|
|
30
|
+
{% if total_pages > 1 %}<div class="row">{% if page > 1 %}<a class="btn" href="?{{ previous_query }}">Previous</a>{% endif %}{% if page < total_pages %}<a class="btn" href="?{{ next_query }}">Next</a>{% endif %}</div>{% endif %}
|
|
31
|
+
{% else %}<div class="card collection-empty"><h2>No observations to show</h2><p class="mut">Refresh a source above, or adjust your filters. Existing inventory stays unchanged.</p></div>{% endif %}
|
|
32
|
+
{% endblock %}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{% extends "base.html" %}{% from "_inventory_forms.html" import device_kind %}{% block title %}Review {{ observation.data.name }} · Keywright{% endblock %}
|
|
2
|
+
{% block body %}{% include "_infrastructure_nav.html" %}{% set item=observation.data %}
|
|
3
|
+
<div class="inventory-heading"><h1>Review {{ item.name }}</h1><a href="/ui/inventory/discovery">Back to discovery</a></div>
|
|
4
|
+
<p class="page-intro">{{ providers[observation.provider] }} · {{ resource_map[observation.resource_id].name }} · observed {{ observation.last_seen.strftime('%Y-%m-%d %H:%M') }} UTC</p>
|
|
5
|
+
{% if not fresh %}<div class="inventory-note">This observation is old, missing, or its latest refresh did not succeed. Refresh the source before applying changes. Existing records and allocations are retained.</div>{% endif %}
|
|
6
|
+
<div class="card"><h2>Inventory record</h2>
|
|
7
|
+
{% if discovery_link %}<p>Linked to <a href="/ui/inventory/assets/{{ target.id }}">{{ target.name }}</a>. Last reviewed {{ discovery_link.reviewed_at.strftime('%Y-%m-%d %H:%M') }} UTC.</p>
|
|
8
|
+
{% else %}<form method="get" class="row"><div style="flex:1"><label for="review-target">Import or link</label><select id="review-target" name="target_id"><option value="">Create a new inventory record</option>{% if assets %}<optgroup label="Inventory records">{% for asset in assets %}<option value="{{ asset.id }}" {{ 'selected' if target_selection==asset.id }}>{{ asset.name }}</option>{% endfor %}</optgroup>{% endif %}{% if resources %}<optgroup label="Existing resources">{% for resource in resources %}<option value="resource:{{ resource.id }}" {{ 'selected' if selected_resource and selected_resource.id==resource.id }}>{{ resource.name }} ({{ resource.kind|upper }} resource)</option>{% endfor %}</optgroup>{% endif %}</select></div><button class="btn" style="align-self:end">Choose record</button></form>
|
|
9
|
+
<p class="help">Choose an existing resource to link it to this device. A new inventory record is created if that resource has none.</p>
|
|
10
|
+
{% if candidates %}<p class="small">Possible matches: {% for candidate in candidates %}<a href="?target_id={{ candidate.asset.id }}">{{ candidate.asset.name }}</a> ({{ candidate.reasons }}){% if not loop.last %}; {% endif %}{% endfor %}. Select a record only after confirming its identity.</p>{% endif %}{% endif %}
|
|
11
|
+
{% if selected_resource %}<p class="inventory-note">{{ selected_resource.name }} will be linked to {{ target.name if target else 'the new inventory record' }} when you save.</p>{% endif %}
|
|
12
|
+
{% if target %}<form method="get" class="row"><input type="hidden" name="target_id" value="{{ target_selection }}"><div style="flex:1"><label for="review-interface">Interface to use</label><select id="review-interface" name="interface_id"><option value="new">Create an interface if selected below</option>{% for iface in interfaces if iface.asset_id==target.id %}<option value="{{ iface.id }}" {{ 'selected' if selected_interface and selected_interface.id==iface.id }}>{{ iface.name }} · {{ iface.mac or media[iface.medium] }}</option>{% endfor %}</select></div><button class="btn" style="align-self:end">Choose interface</button></form>{% endif %}
|
|
13
|
+
</div>
|
|
14
|
+
<form method="post" action="/ui/inventory/discovery/{{ observation.id }}/review">
|
|
15
|
+
<input type="hidden" name="review_token" value="{{ review_token }}"><input type="hidden" name="target_id" value="{{ target_selection }}"><input type="hidden" name="interface_id" value="{{ selected_interface_id }}">
|
|
16
|
+
<div class="card"><h2>{{ 'Choose changes to apply' if target else 'New record' }}</h2>
|
|
17
|
+
{% if target %}<p class="small mut">Unchecked fields keep their current values. Saving also marks these observations as reviewed.</p><table class="data-table inventory-table" role="table"><thead><tr><th>Apply</th><th>Current record</th><th>Observed value</th></tr></thead><tbody>
|
|
18
|
+
{% for key,label in [('name','Name'),('kind','Device type'),('site','Site')] %}<tr><td data-label="Apply"><label><input type="checkbox" name="apply_{{ key }}" value="1"> {{ label }}</label></td><td data-label="Current">{{ kinds[target.kind] if key=='kind' else target[key] or 'Not recorded' }}</td><td data-label="Observed">{{ kinds[item.kind] if key=='kind' else item[key] or 'Not reported (clears this field)' }}</td></tr>{% endfor %}</tbody></table>
|
|
19
|
+
{% else %}<div class="inventory-form"><div><label for="new-name">Name</label><input type="text" id="new-name" name="name" value="{{ item.name }}" maxlength="160" required></div>{{ device_kind('new-kind',kinds,item.kind) }}<div><label for="new-site">Site</label><input type="text" id="new-site" name="site" value="{{ item.site }}" maxlength="160"></div></div>{% endif %}
|
|
20
|
+
{% if item.parent %}<h2>Hosting</h2><p class="small">Current host: {{ asset_map[target.parent_id].name if target and target.parent_id else 'Not recorded' }}</p>{% set parent=related.parent %}{% if parent.asset %}<label><input type="checkbox" name="apply_parent" value="1"> Set hosted on {{ parent.asset.name }}</label>{% else %}<p>Import or link the observed host first: {% if parent.observation %}<a href="/ui/inventory/discovery/{{ parent.observation.id }}/review">{{ parent.observation.data.name }}</a>{% else %}{{ item.parent }}{% endif %}. Then return to this review.</p>{% endif %}{% endif %}
|
|
21
|
+
</div>
|
|
22
|
+
{% if item.mac or selected_interface %}<div class="card"><h2>Network interface</h2>
|
|
23
|
+
{% if selected_interface %}<p class="small">Current: {{ selected_interface.name }} · {{ selected_interface.mac or 'No MAC' }} · {{ media[selected_interface.medium] }} · {{ selected_interface.port or 'No port / SSID' }}</p>{% else %}<label for="interface-name">New interface name</label><input type="text" id="interface-name" name="interface_name" value="{{ 'wlan0' if item.medium=='wifi' else 'eth0' }}" maxlength="160">{% endif %}
|
|
24
|
+
{% if item.mac %}<label><input type="checkbox" name="apply_interface" value="1"> {{ 'Update this interface' if selected_interface else 'Create this interface' }} from observation</label><p class="small">Observed: {{ item.mac }} · {{ media[item.medium] }} · {{ item.port or 'No port / SSID reported' }}. Missing connection details preserve existing values.</p>{% endif %}
|
|
25
|
+
{% if item.attachment %}{% set attachment=related.attachment %}<p class="small">Current connection: {{ asset_map[selected_interface.attached_to_id].name if selected_interface and selected_interface.attached_to_id else 'Not recorded' }}</p>{% if attachment.asset %}<label><input type="checkbox" name="apply_attachment" value="1"> Connect interface to {{ attachment.asset.name }}</label>{% else %}<p>Import or link the connected device first: {% if attachment.observation %}<a href="/ui/inventory/discovery/{{ attachment.observation.id }}/review">{{ attachment.observation.data.name }}</a>{% else %}{{ item.attachment }}{% endif %}.</p>{% endif %}{% endif %}
|
|
26
|
+
</div>{% endif %}
|
|
27
|
+
{% if address_rows %}<div class="card"><h2>Observed addresses</h2><p class="small mut">Choose a subnet only for addresses you have confirmed belong to this interface. DHCP observations alone are not allocation records. Existing addresses are never released by this review.</p>
|
|
28
|
+
{% for row in address_rows %}<label for="address-{{ row.index }}">{{ row.address }}</label><select id="address-{{ row.index }}" name="address_{{ row.index }}"><option value="">Keep as observation only</option>{% for choice in row.choices %}<option value="{{ choice.subnet.id }}" {{ 'disabled' if choice.conflict }}>{{ choice.subnet.name }} · {{ choice.subnet.scope }} · {{ choice.subnet.cidr }}{{ ' · already allocated / gateway' if choice.conflict else ' · already recorded on this interface' if choice.existing else ' · inside DHCP pool' if choice.pool else '' }}</option>{% endfor %}</select>{% if not row.choices %}<p class="help">No matching subnet. <a href="/ui/inventory/networks/new">Record a subnet</a> before allocating this address.</p>{% endif %}{% endfor %}
|
|
29
|
+
<label><input type="checkbox" name="confirm_addresses" value="1"> I confirmed the selected addresses are assigned to this interface, including any DHCP reservations.</label></div>{% endif %}
|
|
30
|
+
<div class="row"><button class="btn p" name="action" value="save" {{ 'disabled' if not fresh }}>{{ 'Save review' if target else 'Import record' }}</button><a class="btn" href="/ui/inventory/discovery">Cancel</a></div>
|
|
31
|
+
{% if discovery_link %}<div class="inventory-delete"><button class="btn bad" name="action" value="unlink" formnovalidate>Unlink observation</button><p class="help">Removes only the discovery link. Inventory, interfaces, and IP allocations remain recorded.</p></div>{% endif %}
|
|
32
|
+
</form>{% endblock %}
|
|
33
|
+
|
|
34
|
+
{% block scripts %}{% include "_device_kind_picker.html" %}{% endblock %}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Network map · Keywright{% endblock %}
|
|
2
|
+
{% block body %}
|
|
3
|
+
{% include "_infrastructure_nav.html" %}
|
|
4
|
+
<style>
|
|
5
|
+
.net-heading{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:12px}.net-heading h1{margin:0}#net-freshness{margin:6px 0 0;color:var(--mut);font-size:12px}.net-heading .btn{flex-shrink:0}
|
|
6
|
+
.net-options{margin:10px 0}.net-options summary{cursor:pointer;color:var(--mut);padding:8px 0}.net-controls{display:flex;gap:12px;align-items:end;flex-wrap:wrap;padding:8px 0}.net-controls>div{flex:1 1 170px}.net-controls label{margin-top:0}.net-controls .inline{align-self:center;margin:0}
|
|
7
|
+
.net-search-wrap{position:relative;max-width:560px;margin:12px 0}.net-search-wrap label{margin-top:0}.net-search-wrap input{width:100%}.net-search-results{position:absolute;top:100%;left:0;right:0;z-index:4;padding:8px;background:var(--panel);border:1px solid var(--line);border-radius:8px;box-shadow:0 8px 24px #0004;max-height:300px;overflow:auto}.net-search-results p{margin:0 6px 6px}.net-search-result{display:block;text-align:left;width:100%;min-height:44px;border:0;background:none;color:var(--fg);padding:8px;font:inherit;overflow-wrap:anywhere}.net-search-result:hover,.net-search-result:focus{background:var(--line)}
|
|
8
|
+
.net-status{display:flex;gap:6px;flex-wrap:wrap;margin:12px 0}.net-status-button{display:inline-flex;align-items:center;gap:6px;min-height:36px;padding:6px 9px;border:1px solid var(--line);border-radius:18px;color:var(--mut);background:var(--panel);font:inherit;font-size:12px;cursor:pointer}.net-status-button:before{content:'';width:6px;height:6px;border-radius:50%;background:var(--mut)}.net-status-button.online:before{background:var(--ok)}.net-status-button.inactive:before{background:var(--warn)}.net-status-button.old:before{background:var(--bad)}.net-status-button.unreviewed:before{background:#b59aee}.net-status-button[aria-pressed=true]{border-color:var(--acc);color:var(--fg)}
|
|
9
|
+
.net-workspace{position:relative;border:1px solid var(--line);border-radius:12px;overflow:hidden;background:var(--panel)}.net-tools{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:10px;border-bottom:1px solid var(--line)}.net-tools .btn,.net-branch .btn{min-height:44px;font-size:13px}.net-tools .zoom{width:44px;font-size:20px;padding:5px}.net-tools .net-scale{font-size:11px;color:var(--mut);margin-left:auto}.net-path{padding:8px 12px;margin:0;color:var(--mut);font-size:12px;border-bottom:1px solid var(--line);overflow-wrap:anywhere;min-height:34px}.net-stage{position:relative;background:radial-gradient(ellipse at 50% 30%,rgba(48,91,146,.12),transparent 75%)}
|
|
10
|
+
#network-svg{display:block;width:100%;height:640px;touch-action:none;cursor:grab;user-select:none}#network-svg:active{cursor:grabbing}.net-branch{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:10px 12px;border-top:1px solid var(--line);background:var(--panel)}.net-branch[hidden]{display:none}.net-branch-title{flex:1 1 170px;min-width:0}.net-branch-title strong{display:block;overflow-wrap:anywhere}.net-branch-title span{display:block;color:var(--mut);font-size:12px}.net-pagination{display:flex;gap:5px}.net-pagination .btn{min-width:44px}.net-hint{padding:8px 12px;color:var(--mut);font-size:11px;border-top:1px solid var(--line);margin:0}.net-map-empty{position:absolute;left:20px;right:20px;top:40%;text-align:center;color:var(--mut);pointer-events:none}
|
|
11
|
+
.topo-node{cursor:pointer;outline:none;color:var(--mut)}.topo-node.online{color:var(--ok)}.topo-node.inactive,.topo-node.missing{color:var(--warn)}.topo-node.old{color:var(--bad)}.node-surface{fill:var(--panel);stroke:var(--line);stroke-width:1.2}.hub .node-surface{stroke:#526b85;fill:var(--panel);filter:drop-shadow(0 2px 6px #173c5d20)}.state-rail{fill:currentColor}.topo-icon{fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.topo-node .node-name{fill:var(--fg);font:500 13px system-ui,sans-serif}.topo-node .node-meta{fill:var(--mut);font:11px system-ui,sans-serif}.review-dot{fill:#b59aee;stroke:var(--panel);stroke-width:2}.expanded-dot{fill:var(--acc)}.collapsed-dot,.unknown-uplink{fill:var(--mut)}.unplaced .node-surface{stroke-dasharray:4 4}.topo-node.selected .node-surface,.topo-node:focus .node-surface{stroke:var(--acc);stroke-width:2.5}.topo-node:hover .node-surface{stroke:var(--acc)}.topo-node.on-path .node-surface{stroke:var(--acc)}.topo-node.dimmed{opacity:.28}.topo-node.dimmed:focus,.topo-node.dimmed:hover{opacity:1}
|
|
12
|
+
.topo-edge{fill:none;stroke:#526b85;stroke-width:1;opacity:.5}.topo-edge.backbone{stroke:#6f8eac;stroke-width:2.5;opacity:.85}.topo-edge.wifi{stroke-dasharray:5 5}.topo-edge.unknown{stroke:var(--mut);stroke-dasharray:2 5;opacity:.4}.topo-edge.historical{opacity:.35}.topo-edge.on-path{stroke:var(--acc);opacity:1;stroke-width:2.5}.topo-edge.dimmed{opacity:.12}
|
|
13
|
+
.net-details{position:absolute;top:12px;right:12px;z-index:3;width:310px;max-width:calc(100% - 24px);max-height:600px;overflow:auto;padding:16px;border:1px solid var(--line);border-radius:12px;background:var(--panel);box-shadow:0 8px 40px #0005;overflow-wrap:anywhere}.net-detail-head{display:flex;align-items:start;gap:8px}.net-detail-head h2{font-size:17px;margin:8px 0;flex:1;min-width:0}.net-detail-head .btn{min-width:44px;min-height:44px}.net-details dl{margin:12px 0;display:grid;grid-template-columns:100px minmax(0,1fr);gap:10px}.net-details dt{font-size:11px;color:var(--mut)}.net-details dd{margin:0;font-size:13px}.net-details .btn{margin:4px 4px 0 0}.net-details .pill{font-size:12px}
|
|
14
|
+
.net-legend{display:flex;gap:10px 18px;flex-wrap:wrap;margin:12px 0;font-size:12px;color:var(--mut)}.net-legend span{display:inline-flex;align-items:center;gap:6px}.net-line{display:inline-block;width:22px;border-top:2px solid #6f8eac}.net-line.wifi{border-top-style:dashed}.net-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#b59aee}.net-table-wrap{margin-top:18px}.net-table-wrap summary{cursor:pointer;min-height:44px;padding:10px 0}.net-list-button{border:0;padding:6px 0;background:none;font:inherit;color:var(--acc);cursor:pointer;text-align:left;min-height:40px;overflow-wrap:anywhere}
|
|
15
|
+
@media(prefers-color-scheme:dark){.net-stage{background:radial-gradient(ellipse at 50% 30%,#12253d,#0c1421 85%)}.node-surface,.hub .node-surface{fill:#172232}.hub .node-surface{filter:drop-shadow(0 2px 7px #285c8b25)}}
|
|
16
|
+
@media(max-width:900px){.net-details{position:fixed;left:10px;right:10px;top:auto;bottom:calc(68px + env(safe-area-inset-bottom));width:auto;max-width:none;max-height:44vh;max-height:44svh;z-index:9;border-radius:16px 16px 8px 8px}.net-detail-head{position:sticky;top:-16px;background:var(--panel);padding-top:8px;z-index:1}}
|
|
17
|
+
@media(max-width:700px){ #network-svg{height:520px}.net-heading{align-items:start}.net-heading .btn{padding:8px;font-size:12px}.net-status-button{min-height:44px}.net-tools{gap:4px;padding:8px}.net-tools .btn{padding:6px 8px}.net-tools .net-scale{display:none}.net-tools #net-reset{margin-left:auto}.net-branch{gap:6px}.net-branch-title{flex-basis:calc(100% - 100px)}.net-search-wrap{max-width:none}.net-controls{gap:8px}.net-details dl{grid-template-columns:100px minmax(0,1fr)}}
|
|
18
|
+
@media(max-width:350px){.net-tools #net-reset{margin-left:0}.net-heading{flex-wrap:wrap}}
|
|
19
|
+
</style>
|
|
20
|
+
<div class="net-heading"><div><h1>Network map</h1><p id="net-freshness" aria-live="polite">Loading saved observations…</p></div><button class="btn p" id="net-refresh" type="button" {% if not graph.source %}disabled{% endif %}>Refresh now</button></div>
|
|
21
|
+
<details class="net-options"><summary>Controller & refresh settings</summary><div class="net-controls">
|
|
22
|
+
<div><label for="net-source">Controller</label><select id="net-source">{% for source in graph.sources %}<option value="{{ source.id }}" {{ 'selected' if source.id == graph.source }}>{{ source.name }}</option>{% endfor %}</select></div>
|
|
23
|
+
<div><label for="net-site">Site</label><select id="net-site"><option value="">All sites</option></select></div>
|
|
24
|
+
<label class="inline"><input type="checkbox" id="net-auto" checked {% if not graph.source %}disabled{% endif %}>Refresh every minute while open</label>
|
|
25
|
+
</div></details>
|
|
26
|
+
<div id="net-error" class="err" role="status" hidden></div>
|
|
27
|
+
{% if not graph.sources %}<div class="card collection-empty"><h2>Connect an Omada controller</h2><p class="mut">Add an Omada resource to discover your network and its device connections.</p><a class="btn" href="/ui/resources">Open Resources</a></div>{% else %}
|
|
28
|
+
<div class="net-search-wrap"><label for="net-search">Find a device</label><input type="search" id="net-search" placeholder="Name, IP, MAC or SSID" autocomplete="off" aria-controls="net-search-results"><div id="net-search-results" class="net-search-results" aria-label="Matching devices" hidden></div></div>
|
|
29
|
+
<div id="net-counts" class="net-status" aria-label="Highlight devices by status"></div>
|
|
30
|
+
<div class="net-workspace">
|
|
31
|
+
<div class="net-tools" aria-label="Map controls"><button type="button" class="btn zoom" id="net-zoom-in" aria-label="Zoom in">+</button><button type="button" class="btn zoom" id="net-zoom-out" aria-label="Zoom out">−</button><button type="button" class="btn" id="net-fit" title="Fit the current view without shrinking labels">Fit view</button><button type="button" class="btn" id="net-overview">Overview</button><button type="button" class="btn" id="net-reset">Reset</button><span class="net-scale" id="net-scale"></span></div>
|
|
32
|
+
<p id="net-path" class="net-path" aria-live="polite"></p>
|
|
33
|
+
<div class="net-stage"><svg id="network-svg" tabindex="0" role="group" aria-label="Network connections. Select a switch or AP to explore clients. Arrow keys pan; plus and minus zoom. The device list below provides an alternative."><g id="net-scene"></g></svg><p id="net-map-empty" class="net-map-empty" hidden>No observations yet. Refresh to discover devices.</p></div>
|
|
34
|
+
<div id="net-branch" class="net-branch" hidden><div class="net-branch-title"><strong id="net-branch-name"></strong><span id="net-page" aria-live="polite"></span></div><div class="net-pagination"><button type="button" class="btn" id="net-prev" aria-label="Previous clients">←</button><button type="button" class="btn" id="net-next" aria-label="Next clients">→</button></div><button type="button" class="btn" id="net-collapse">Collapse clients</button><button type="button" class="btn" id="net-show-details">Device details</button></div>
|
|
35
|
+
<p id="net-view-note" class="net-hint">Drag to pan · pinch or use + / − to zoom</p>
|
|
36
|
+
<aside id="net-details" class="net-details" hidden aria-labelledby="net-detail-title"><div class="net-detail-head"><h2 id="net-detail-title">Device details</h2><button type="button" class="btn" id="net-detail-close" aria-label="Close device details">×</button></div><div id="net-detail-body"></div></aside>
|
|
37
|
+
</div>
|
|
38
|
+
<div class="net-legend"><span><i class="net-line"></i>Wired</span><span><i class="net-line wifi"></i>Wi-Fi / mesh</span><span><i class="net-dot"></i>Unreviewed overlay</span></div>
|
|
39
|
+
<p class="help">Thicker links connect infrastructure. Unknown connections stay separate. Select a status above to highlight it; positions stay fixed. Busy branches show clients in pages. “Last observed” is the collection time, not a ping. Connections do not measure traffic.</p>
|
|
40
|
+
<details class="net-table-wrap"><summary id="net-list-summary">Device list</summary><div class="card" style="margin-top:10px"><table class="data-table"><thead><tr><th>Device</th><th>State</th><th>Connected to</th><th>IP address</th></tr></thead><tbody id="net-list"></tbody></table><p id="net-empty-list" class="mut" hidden>No devices match these filters.</p></div></details>
|
|
41
|
+
{% endif %}
|
|
42
|
+
<noscript><div class="card">Enable JavaScript for the interactive map and automatic refresh. <a href="/ui/inventory/discovery">Browse saved observations in Discovery.</a></div></noscript>
|
|
43
|
+
<script id="net-data" type="application/json">{{ graph|tojson }}</script>
|
|
44
|
+
{% endblock %}
|
|
45
|
+
{% block scripts %}{% include "_network_map_script.html" %}{% endblock %}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}IP allocations · Keywright{% endblock %}{% block body %}
|
|
2
|
+
{% include "_infrastructure_nav.html" %}
|
|
3
|
+
<div class="inventory-heading"><h1>IP allocations</h1><a class="btn p" href="/ui/inventory/networks/new">Add subnet</a></div>
|
|
4
|
+
<p class="page-intro">Track assigned, reserved, planned, and excluded addresses in each network.</p>
|
|
5
|
+
{% if subnets %}<div class="card"><table class="data-table inventory-table" role="table"><thead><tr><th>Subnet</th><th>Network</th><th>VLAN / site</th><th>Recorded</th><th>DHCP pool</th><th>Unrecorded outside pool</th></tr></thead><tbody>
|
|
6
|
+
{% for subnet in subnets %}{% set counts=summaries[subnet.id] %}<tr>
|
|
7
|
+
<td data-label="Subnet" class="wide"><a href="/ui/inventory/networks/{{ subnet.id }}"><b>{{ subnet.name }}</b></a><small>Address space: {{ subnet.scope }}</small></td><td data-label="Network" class="mono">{{ subnet.cidr }}</td><td data-label="VLAN / site">{{ subnet.vlan if subnet.vlan else 'No VLAN' }}<small>{{ subnet.site or 'No site' }}</small></td>
|
|
8
|
+
<td data-label="Recorded">{{ counts.recorded }}<small>{{ 'Gateway recorded' if subnet.gateway else 'Gateway not recorded' }}</small></td><td data-label="DHCP pool">{{ counts.pool_size if subnet.dhcp_start else 'Not recorded' }}</td><td data-label="Unrecorded outside pool">{{ counts.unrecorded }}<small>Availability unverified</small></td>
|
|
9
|
+
</tr>{% endfor %}</tbody></table></div>
|
|
10
|
+
{% else %}<div class="card collection-empty"><h2>No subnets recorded</h2><p class="mut">Add your network and its DHCP pool, then record static addresses and reservations.</p><a class="btn" href="/ui/inventory/networks/new">Add your first subnet</a></div>{% endif %}
|
|
11
|
+
<div class="inventory-note">Manual records only. Unrecorded addresses may still be in use. These counts do not include live DHCP leases or device discovery.</div>
|
|
12
|
+
{% endblock %}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{% extends "base.html" %}{% from "_inventory_forms.html" import field, allocation_form, remove %}
|
|
2
|
+
{% block title %}{{ subnet.name if subnet else 'Add subnet' }} · Keywright{% endblock %}{% block body %}
|
|
3
|
+
{% include "_infrastructure_nav.html" %}
|
|
4
|
+
<div class="inventory-heading"><h1>{{ subnet.name if subnet else 'Add subnet' }}</h1><a class="small" href="/ui/inventory/networks">All subnets</a></div>
|
|
5
|
+
<p class="page-intro">Record the network’s allocation plan. Changes here do not configure your router or DHCP server.</p>
|
|
6
|
+
<details class="card" {{ 'open' if not subnet or values }}><summary style="cursor:pointer">{{ 'Network settings · ' ~ subnet.cidr if subnet else 'Network settings' }}</summary>
|
|
7
|
+
<form method="post" action="/ui/inventory/networks" class="inventory-form">
|
|
8
|
+
<input type="hidden" name="id" value="{{ subnet.id if subnet else '' }}">
|
|
9
|
+
{{ field('name','Network name',values,subnet.name if subnet else '',required=true) }}
|
|
10
|
+
{{ field('cidr','Subnet (CIDR)',values,subnet.cidr if subnet else '',required=true,help='IPv4 or IPv6, for example 192.168.1.0/24.') }}
|
|
11
|
+
{{ field('scope','Address space',values,subnet.scope if subnet else 'default',help='Use a separate name for isolated networks that reuse the same IP range.') }}
|
|
12
|
+
{{ field('site','Site / location',values,subnet.site if subnet else '') }}
|
|
13
|
+
{{ field('vlan','VLAN (optional)',values,subnet.vlan if subnet else '',type='number') }}
|
|
14
|
+
{{ field('gateway','Gateway (optional)',values,subnet.gateway if subnet else '') }}
|
|
15
|
+
{{ field('dhcp_start','DHCP pool start (optional)',values,subnet.dhcp_start if subnet else '') }}
|
|
16
|
+
{{ field('dhcp_end','DHCP pool end (optional)',values,subnet.dhcp_end if subnet else '') }}
|
|
17
|
+
<div class="full"><label for="subnet-notes">Notes</label><textarea id="subnet-notes" name="notes" maxlength="4000">{{ values.get('notes',subnet.notes if subnet else '') }}</textarea></div>
|
|
18
|
+
<div class="full form-actions"><button class="btn p">{{ 'Save subnet' if subnet else 'Create subnet' }}</button></div>
|
|
19
|
+
</form></details>
|
|
20
|
+
{% if subnet %}{% set back='/ui/inventory/networks/' ~ subnet.id %}
|
|
21
|
+
<div class="inventory-facts"><span>Usable addresses <b>{{ summary.capacity }}</b></span><span>Allocations <b>{{ summary.recorded }}</b></span><span>DHCP pool <b>{{ summary.pool_size if subnet.dhcp_start else 'Not recorded' }}</b></span><span>Unrecorded outside pool <b>{{ summary.unrecorded }}</b></span></div>
|
|
22
|
+
<div class="inventory-note">Unrecorded does not mean available. Confirm DHCP reservations and active leases before assigning an address. Offline devices retain their allocations.</div>
|
|
23
|
+
<h2>Recorded allocations</h2>
|
|
24
|
+
<div class="card">
|
|
25
|
+
{% if subnet.gateway %}<p class="small"><b>Gateway</b> <code>{{ subnet.gateway }}</code> · excluded from unrecorded ranges</p>{% endif %}
|
|
26
|
+
{% if subnet.dhcp_start %}<p class="small"><b>DHCP pool</b> <code>{{ subnet.dhcp_start }} – {{ subnet.dhcp_end }}</code> · live leases not imported</p>{% endif %}
|
|
27
|
+
{% if summary.rows %}<table class="data-table inventory-table" role="table"><thead><tr><th>Address</th><th>Allocation</th><th>Device / interface</th><th>Owner / purpose</th><th>Updated</th></tr></thead><tbody>
|
|
28
|
+
{% for allocation in summary.rows %}<tr><td data-label="Address" class="mono">{{ allocation.address }}</td><td data-label="Allocation">{{ states[allocation.state] }}</td><td data-label="Device / interface">{% if allocation.interface_id %}{% set iface=interface_map[allocation.interface_id] %}<a href="/ui/inventory/assets/{{ iface.asset_id }}">{{ asset_map[iface.asset_id].name }}</a><small>{{ iface.name }}</small>{% else %}<span class="mut">Not linked</span>{% endif %}</td><td data-label="Owner / purpose">{{ allocation.label or '—' }}{% if allocation.notes %}<small>{{ allocation.notes }}</small>{% endif %}</td><td data-label="Updated" class="small mut">{{ allocation.updated_at.strftime('%Y-%m-%d %H:%M') }} UTC</td></tr>{% endfor %}
|
|
29
|
+
</tbody></table>{% else %}<p class="mut">No individual allocations recorded.</p>{% endif %}
|
|
30
|
+
</div>
|
|
31
|
+
<details class="card" {{ 'open' if not summary.rows or (allocation_values and not allocation_values.get('id')) }}><summary style="cursor:pointer">Add allocation</summary>{{ allocation_form(subnet,none,allocation_values if allocation_values and not allocation_values.get('id') else {},interfaces,asset_map,states) }}</details>
|
|
32
|
+
{% if summary.rows %}<details class="inventory-section" {{ 'open' if allocation_values and allocation_values.get('id') }}><summary>Edit or release an allocation</summary>
|
|
33
|
+
<div class="card">{% for allocation in summary.rows %}<details class="inventory-editor" {{ 'open' if allocation_values and allocation_values.get('id')==allocation.id }}><summary><span class="mono">{{ allocation.address }}</span> · {{ states[allocation.state] }}{% if allocation.label %} · {{ allocation.label }}{% endif %}</summary>
|
|
34
|
+
{{ allocation_form(subnet,allocation,allocation_values if allocation_values and allocation_values.get('id')==allocation.id else {},interfaces,asset_map,states) }}
|
|
35
|
+
<div class="inventory-delete">{{ remove('allocation',allocation.id,back,'Release allocation record') }}<p class="help">Only release this record after the address has been released on the device or DHCP server.</p></div>
|
|
36
|
+
</details>{% endfor %}</div></details>{% endif %}
|
|
37
|
+
<details class="inventory-section"><summary>Unrecorded address ranges ({{ summary.unrecorded }} addresses)</summary><div class="card"><p class="mut small">Excludes recorded allocations, gateway, DHCP pool, and protocol-reserved addresses. Availability is unverified.</p>
|
|
38
|
+
{% for start,end in summary.gaps %}<div class="allocation-range">{{ start }}{% if start!=end %} – {{ end }}{% endif %}</div>{% else %}<p class="mut">No unrecorded ranges outside the DHCP pool.</p>{% endfor %}</div></details>
|
|
39
|
+
<div class="inventory-delete">{{ remove('subnet',subnet.id,back,'Remove subnet') }}<p class="help">Remove its allocations first. This does not change the network configuration.</p></div>
|
|
40
|
+
{% endif %}
|
|
41
|
+
{% endblock %}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Hosting topology · Keywright{% endblock %}{% block body %}
|
|
2
|
+
{% include "_infrastructure_nav.html" %}
|
|
3
|
+
<div class="inventory-heading"><h1>Hosting topology</h1><a class="btn" href="/ui/inventory/assets/new">Add device or workload</a></div>
|
|
4
|
+
<p class="page-intro">Physical hosts, their virtual machines, containers, and services. Select a record to manage its connections and addresses.</p>
|
|
5
|
+
{% if tree %}<div class="row" style="margin-bottom:12px"><button class="btn sm" id="expand-tree" type="button" hidden>Expand all</button><button class="btn sm" id="collapse-tree" type="button" hidden>Collapse all</button></div>
|
|
6
|
+
<div class="inventory-tree" aria-label="Hosting relationships">
|
|
7
|
+
{% for node in tree %}<div class="tree-row" id="tree-{{ node.asset.id }}" data-node="{{ node.asset.id }}" data-ancestors="{{ node.ancestors|join(' ') }}" style="--depth:{{ node.depth }}">
|
|
8
|
+
<div class="tree-name">{% if node.children %}<button type="button" class="tree-toggle" aria-expanded="true" aria-label="Toggle workloads on {{ node.asset.name }}" hidden>−</button>{% else %}<span class="tree-spacer"></span>{% endif %}<a href="/ui/inventory/assets/{{ node.asset.id }}"><b>{{ node.asset.name }}</b></a></div>
|
|
9
|
+
<span class="tree-meta">{{ kinds[node.asset.kind] }}</span><span class="tree-meta">{{ node.asset.site or 'No site recorded' }}</span>
|
|
10
|
+
</div>{% endfor %}</div>
|
|
11
|
+
{% else %}<div class="card collection-empty"><h2>No hosting relationships yet</h2><p class="mut">Add a host, then choose it in the “Hosted on” field of a VM, container, or service.</p><a class="btn" href="/ui/inventory/assets/new">Add a host</a></div>{% endif %}
|
|
12
|
+
<div class="inventory-note">This view shows hosting relationships. Wired, Wi-Fi, and virtual connections are recorded on each device’s interfaces.</div>
|
|
13
|
+
{% endblock %}
|
|
14
|
+
{% block scripts %}<script>
|
|
15
|
+
(function(){
|
|
16
|
+
const rows=Array.from(document.querySelectorAll('[data-node]'));
|
|
17
|
+
const closed=new Set();
|
|
18
|
+
function update(){rows.forEach(row=>{row.hidden=row.dataset.ancestors.split(' ').some(id=>closed.has(id)); const b=row.querySelector('button');if(b){b.setAttribute('aria-expanded',String(!closed.has(row.dataset.node)));b.textContent=closed.has(row.dataset.node)?'+':'−';}});}
|
|
19
|
+
rows.forEach(row=>{const b=row.querySelector('button');if(b){b.hidden=false;b.addEventListener('click',()=>{const id=row.dataset.node;closed.has(id)?closed.delete(id):closed.add(id);update();});}});
|
|
20
|
+
['expand','collapse'].forEach(action=>{const b=document.getElementById(action+'-tree');if(!b)return;b.hidden=false;b.addEventListener('click',()=>{closed.clear();if(action==='collapse')rows.filter(r=>r.querySelector('button')).forEach(r=>closed.add(r.dataset.node));update();});});
|
|
21
|
+
})();
|
|
22
|
+
</script>{% endblock %}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Knowledge · Keywright{% endblock %}{% block body %}
|
|
2
|
+
<h1>Shared knowledge</h1>
|
|
3
|
+
<p class="page-intro">Reviewed documentation, decisions and memory for your agents. Each collection has its own access rules.</p>
|
|
4
|
+
<div class="card"><form method="get" class="grid" style="align-items:end"><div><label for="collection">Collection</label><select name="collection" id="collection"><option value="">Choose a collection</option>{% for c in collections %}<option value="{{ c.id }}" {{ 'selected' if selected and c.id==selected.id }}>{{ c.name }}</option>{% endfor %}</select></div><div><label for="query">Search reviewed knowledge</label><input type="text" id="query" name="q" value="{{ q }}" maxlength="1000" placeholder="Architecture, lessons, runbooks…"></div><div><button class="btn p">Open / search</button></div></form></div>
|
|
5
|
+
{% if selected %}
|
|
6
|
+
<h2>{{ selected.name }}</h2><p>{{ selected.description }}</p>
|
|
7
|
+
<p><a class="btn" href="/ui/access?resource={{ selected.id }}">Manage agent access</a> <span class="mut">Default: {{ selected.default_mode }}. Read and contribution permissions are separate; only admins publish.</span></p>
|
|
8
|
+
<div class="card"><b>Retrieval</b><p>Published Markdown is text-indexed immediately. {% if semantic %}Semantic retrieval is configured. Last semantic rebuild: {{ selected.config.get('knowledge_indexed_at', 'Not yet built') }} ({{ selected.config.get('knowledge_indexed_chunks', 0) }} chunks).{% else %}Local text search is active. Optional semantic retrieval has not been configured.{% endif %}</p>{% if semantic %}<form method="post" action="/ui/knowledge/{{ selected.id }}/reindex"><button class="btn">Rebuild semantic index</button><p class="help">Sends published content to the configured embedding provider. Run after publishing or changing the embedding model.</p></form>{% endif %}</div>
|
|
9
|
+
<details class="card"><summary>Update existing search passages</summary><p>Reorganize published documents into section-aware passages. Content, revisions and review history stay unchanged. {% if semantic %}Rebuild the semantic index afterward to make the new passages available to semantic search.{% endif %}</p><form method="post" action="/ui/knowledge/{{ selected.id }}/rechunk"><button class="btn">Update search passages</button></form></details>
|
|
10
|
+
{% if results %}<h2>Search results · {{ results.mode }}</h2>{% if results.warning %}<p role="status">{{ results.warning }}</p>{% endif %}{% for hit in results.results %}<article class="card"><h3><a href="/ui/knowledge/documents/{{ hit.document_id }}">{{ hit.title }}</a></h3><p class="mut">{{ hit.citation }} · {{ hit.kind }}{% if hit.section_path %} · {{ hit.section_path | join(' › ') }}{% endif %}</p><pre style="white-space:pre-wrap;overflow-wrap:anywhere">{{ hit.text }}</pre></article>{% else %}<p>No published knowledge matched your search.</p>{% endfor %}{% endif %}
|
|
11
|
+
<h2>Awaiting review</h2>{% for rev, doc in proposals %}<div class="card"><a href="/ui/knowledge/proposals/{{ rev.id }}">{{ rev.title }}</a><p>{{ doc.path }} · {{ rev.attribution }} · {{ rev.created_at.strftime('%Y-%m-%d %H:%M UTC') }}</p></div>{% else %}<p class="mut">No proposals awaiting review.</p>{% endfor %}
|
|
12
|
+
<h2>Published documents</h2>{% for doc in documents.documents %}<article class="card"><h3><a href="/ui/knowledge/documents/{{ doc.document_id }}">{{ doc.title }}</a></h3><p>{{ doc.path }} · {{ doc.kind }} · {{ doc.attribution }}</p><p class="mut">Source: {{ doc.source or 'Admin-authored Markdown' }}{% if doc.source_version %} · {{ doc.source_version }}{% endif %}</p></article>{% else %}<p class="mut">No published documents yet. Add Markdown below or let an authorized agent propose memory.</p>{% endfor %}
|
|
13
|
+
{% if documents.next_offset is not none %}<a class="btn" href="/ui/knowledge?collection={{ selected.id }}&offset={{ documents.next_offset }}">Next documents</a>{% endif %}
|
|
14
|
+
<details class="card"><summary>Add Markdown or import from Git</summary><form method="post" action="/ui/knowledge/{{ selected.id }}/publish">
|
|
15
|
+
<label for="path">Path within this collection</label><input type="text" id="path" name="path" required maxlength="240" placeholder="decisions/storage.md">
|
|
16
|
+
<label for="title">Title</label><input type="text" id="title" name="title" required maxlength="200">
|
|
17
|
+
<label for="kind">Knowledge type</label><select id="kind" name="kind">{% for kind in kinds %}<option>{{ kind }}</option>{% endfor %}</select>
|
|
18
|
+
<label for="source">Source / provenance</label><input type="text" id="source" name="source" maxlength="2000" placeholder="Runbook, project issue, or session reference">
|
|
19
|
+
<label for="retention">Operational retention (days; other types do not expire)</label><input id="retention" name="retention_days" type="number" value="30" min="1" max="365">
|
|
20
|
+
<label for="body">Markdown</label><textarea id="body" name="body" rows="12" maxlength="256000"></textarea>
|
|
21
|
+
<label><input type="checkbox" name="import_git" value="1"> Import this path from the configured Git checkout's latest commit</label><p class="help">Git imports use committed UTF-8 Markdown and record the exact commit. Uncommitted files are not imported.</p>
|
|
22
|
+
<button class="btn p">Publish reviewed document</button></form></details>
|
|
23
|
+
{% endif %}
|
|
24
|
+
<details class="card" {{ 'open' if not collections }}><summary>Create a collection</summary><form method="post" action="/ui/knowledge/collections"><label for="name">Collection name</label><input type="text" id="name" name="name" required maxlength="64" pattern="[A-Za-z0-9][A-Za-z0-9_-]*" placeholder="KeywrightKnowledge"><label for="description">Description</label><input type="text" id="description" name="description" maxlength="2000"><p class="help">New collections require explicit access rules. Grant read access and permission to propose memory separately.</p><button class="btn p">Create collection</button></form></details>
|
|
25
|
+
{% endblock %}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}{{ doc.title }} · Knowledge{% endblock %}{% block body %}
|
|
2
|
+
<p><a href="/ui/knowledge?collection={{ collection.id }}">{{ collection.name }}</a> / {{ doc.path }}</p><h1>{{ doc.title }}</h1>
|
|
3
|
+
<div class="card"><p>{{ doc.kind }} · {{ doc.attribution }} · reviewed {{ doc.reviewed_at }}</p><p>Source: {{ doc.source or 'Admin-authored Markdown' }} · {{ doc.source_version }}</p><p class="mono" style="overflow-wrap:anywhere">Revision: {{ doc.revision }}<br>SHA-256: {{ doc.sha256 }}</p>{% if doc.expires_at %}<p>Expires: {{ doc.expires_at }}</p>{% endif %}<a class="btn" href="?download=true">Download Markdown</a></div>
|
|
4
|
+
<pre class="card" style="white-space:pre-wrap;overflow-wrap:anywhere">{{ doc.markdown }}</pre>
|
|
5
|
+
<details class="card"><summary>Publish a new revision</summary><form method="post" action="/ui/knowledge/{{ collection.id }}/publish"><input type="hidden" name="path" value="{{ doc.path }}"><input type="hidden" name="expected_revision" value="{{ doc.revision }}"><label>Title<input type="text" name="title" value="{{ doc.title }}" maxlength="200" required></label><label>Type<select name="kind">{% for kind in kinds %}<option {{ 'selected' if doc.kind==kind }}>{{ kind }}</option>{% endfor %}</select></label><label>Source<input type="text" name="source" value="{{ doc.source }}" maxlength="2000"></label><label>Markdown<textarea name="body" rows="18" required maxlength="256000">{{ doc.markdown }}</textarea></label><label>Operational retention (days)<input name="retention_days" type="number" min="1" max="365" value="30"></label><label><input type="checkbox" name="import_git" value="1"> Reimport this path from the configured Git checkout</label><button class="btn p">Publish new revision</button></form></details>
|
|
6
|
+
<form method="post" action="/ui/knowledge/{{ collection.id }}/archive"><input type="hidden" name="document_id" value="{{ doc.document_id }}"><input type="hidden" name="expected_revision" value="{{ doc.revision }}"><button class="btn">Archive document</button><p class="help">Removes the document from agent retrieval. Revision history and original Markdown remain in the knowledge backup.</p></form>
|
|
7
|
+
{% endblock %}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Review memory · Keywright{% endblock %}{% block body %}
|
|
2
|
+
<p><a href="/ui/knowledge?collection={{ collection.id }}">{{ collection.name }}</a> / {{ doc.path }}</p><h1>Review: {{ revision.title }}</h1>
|
|
3
|
+
<div class="card"><p>Status: {{ revision.status }} · {{ revision.kind }} · {{ revision.attribution }}</p><p>Rationale: {{ revision.rationale }}</p><p>Claimed source: {{ revision.source or 'Not supplied' }} · {{ revision.source_version }}</p><p>Based on: {{ revision.base_revision or 'New document' }}</p><p>Review the content and provenance before making it available to other agents. Approval never grants authority to instructions inside the document.</p></div>
|
|
4
|
+
<h2>Changes from current publication</h2><pre class="card" style="white-space:pre-wrap;overflow-wrap:anywhere">{{ diff or 'No text changes.' }}</pre>
|
|
5
|
+
<details class="card"><summary>Full proposed Markdown</summary><pre style="white-space:pre-wrap;overflow-wrap:anywhere">{{ body }}</pre></details>
|
|
6
|
+
{% if revision.status=='pending' %}<form method="post"><button class="btn p" name="action" value="approve">Approve and publish</button> <button class="btn" name="action" value="reject">Reject proposal</button></form>{% endif %}
|
|
7
|
+
{% endblock %}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{% extends "base.html" %}{% set hide_nav = True %}{% block title %}Keywright login{% endblock %}{% block body %}
|
|
2
|
+
<div class="card auth-card"><h1 class="brand">{{ icon('shield') }}Keywright</h1><p class="mut small">Sign in to the administration console.</p>
|
|
3
|
+
{% if logged_out and oidc_enabled %}<p class="small">Signed out of Keywright. Your Authentik session may still be active.</p>{% endif %}
|
|
4
|
+
{% if oidc_enabled %}<p><a class="btn p" href="/ui/oidc/login?next={{ next|urlencode }}">Sign in with Authentik</a></p>{% endif %}
|
|
5
|
+
{% if password_enabled %}<form method="post" action="/ui/login"><input type="hidden" name="next" value="{{ next }}">
|
|
6
|
+
<input type="text" name="username" value="admin" autocomplete="username" hidden>
|
|
7
|
+
<label for="admin-password">Admin password</label><input id="admin-password" type="password" name="password" autocomplete="current-password" autofocus required>
|
|
8
|
+
<div style="margin-top:12px"><button class="btn p">Log in</button></div></form>{% endif %}</div>
|
|
9
|
+
{% endblock %}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{% extends 'base.html' %}
|
|
2
|
+
{% block title %}Mail · Keywright{% endblock %}
|
|
3
|
+
{% block body %}
|
|
4
|
+
{% include '_mail_style.html' %}
|
|
5
|
+
<div class="mail-heading"><div><h1>Mail</h1><p class="mut">{{ cfg.account }} · {{ cfg.mailbox }}{% if cfg.last_sync %} · Last sync {{ cfg.last_sync[:16]|replace('T',' ') }} UTC{% endif %}</p></div><form method="post" action="/ui/mail/sync"><button class="btn">Refresh inbox</button></form></div>
|
|
6
|
+
{% include '_mail_nav.html' %}
|
|
7
|
+
{% if not cfg.resource %}<div class="card">Connect your mailbox to browse messages and run rules. <a href="/ui/mail/settings">Set up connection</a></div>{% endif %}
|
|
8
|
+
{% if cfg.sync_error %}<div class="err" role="alert">{{ cfg.sync_error }} <a href="/ui/requests">View access requests</a></div>{% endif %}
|
|
9
|
+
{% if not cfg.enabled %}<p class="mut">Automatic processing is paused. You can refresh the inbox and run rules on individual messages.</p>{% endif %}
|
|
10
|
+
<div class="mail-shell {{ 'has-selection' if message }}">
|
|
11
|
+
<aside class="mail-rail" aria-label="Message filters">
|
|
12
|
+
<a href="/ui/mail" class="{{ 'active' if not status and view != 'history' }}">Inbox <span class="mail-count">{{ counts.values()|sum }}</span></a>
|
|
13
|
+
<a href="/ui/mail?view=history" class="{{ 'active' if view == 'history' }}">All indexed mail</a>
|
|
14
|
+
<h2>Processing</h2>
|
|
15
|
+
{% for key in ['unprocessed','pending','done','failed'] %}<a href="/ui/mail?status={{ key }}" class="{{ 'active' if status == key }}">{{ statuses[key] }} <span class="mail-count">{{ counts.get(key,0) }}</span></a>{% endfor %}
|
|
16
|
+
</aside>
|
|
17
|
+
<section class="mail-list" aria-label="Messages">
|
|
18
|
+
<form class="mail-search" method="get"><input type="hidden" name="status" value="{{ status }}"><input type="hidden" name="view" value="{{ view }}"><input name="q" value="{{ q }}" aria-label="Search sender or subject" placeholder="Search mail"><button class="btn sm">Search</button></form>
|
|
19
|
+
{% for row in messages %}
|
|
20
|
+
<a class="mail-message {{ 'selected' if selected == row.id }}" href="/ui/mail?selected={{ row.id }}&status={{ status }}&q={{ q|urlencode }}&view={{ view }}&page={{ page }}">
|
|
21
|
+
<span class="mail-sender">{{ row.sender }}</span><span class="mail-subject">{{ row.subject or '(No subject)' }}</span>
|
|
22
|
+
<span class="mail-meta"><span class="pill mail-status-{{ row.status }}">{{ statuses.get(row.status,row.status) }}</span>{% if row.attachments %}<span>{{ row.attachments|length }} attachment{{ 's' if row.attachments|length != 1 }}</span>{% endif %}<time>{{ row.date[:10] }}</time></span>
|
|
23
|
+
</a>
|
|
24
|
+
{% else %}<div class="mail-empty"><strong>No messages here</strong>{{ 'Try another search or filter.' if q or status else 'Refresh to load your inbox.' }}</div>{% endfor %}
|
|
25
|
+
<div class="row mail-page"><span class="mut">{{ total }} messages · {{ page }}/{{ pages }}</span><span>{% if page > 1 %}<a href="?page={{ page-1 }}&status={{ status }}&q={{ q|urlencode }}&view={{ view }}">Previous</a>{% endif %} {% if page < pages %}<a href="?page={{ page+1 }}&status={{ status }}&q={{ q|urlencode }}&view={{ view }}">Next</a>{% endif %}</span></div>
|
|
26
|
+
</section>
|
|
27
|
+
<section class="mail-reader" aria-label="Message details">
|
|
28
|
+
{% if message %}
|
|
29
|
+
<a class="btn mail-reader-back" href="/ui/mail?status={{ status }}&q={{ q|urlencode }}&view={{ view }}&page={{ page }}">Back to messages</a>
|
|
30
|
+
<h2>{{ message.subject or '(No subject)' }}</h2><span class="pill mail-status-{{ message.status }}">{{ statuses.get(message.status,message.status) }}</span>
|
|
31
|
+
{% if not message.present %}<span class="pill">No longer in {{ cfg.mailbox }}</span>{% endif %}
|
|
32
|
+
<dl><dt>From</dt><dd>{{ message.sender }}</dd><dt>To</dt><dd>{{ message.recipients|join(', ') }}</dd><dt>Date</dt><dd>{{ message.date }}</dd></dl>
|
|
33
|
+
<div class="mail-result">
|
|
34
|
+
{% if message.plan %}<strong>{{ message.plan.name }}</strong>{% else %}<strong>Processing status</strong>{% endif %}
|
|
35
|
+
<p>{{ message.note or 'No rule has been run on this message.' }}</p>
|
|
36
|
+
{% for filename, receipt in message.receipts.items() %}{% if receipt is mapping %}<p><span class="pill ok">Delivered</span> {{ filename }} · Budget document #{{ receipt.id }}</p>{% endif %}{% endfor %}
|
|
37
|
+
{% if message.request_id %}<p><a href="/ui/requests/{{ message.request_id }}">View approval request</a></p>{% endif %}
|
|
38
|
+
{% if message.status != 'done' %}<form method="post" action="/ui/mail/{{ message.id }}/run"><button class="btn {{ 'p' if message.status == 'failed' }}">{{ 'Retry' if message.status == 'failed' else 'Check approval' if message.status == 'pending' else 'Run rules' }}</button></form>{% endif %}
|
|
39
|
+
</div>
|
|
40
|
+
{% if message.attachments %}<div class="mail-attachments"><strong>Attachments</strong>{% for name in message.attachments %}<div class="mail-attachment"><span>{{ name }}</span>{% if name.lower().endswith('.pdf') %}<a class="btn sm" href="/ui/mail/{{ message.id }}/attachment?name={{ name|urlencode }}">Download PDF</a>{% endif %}</div>{% endfor %}</div>{% endif %}
|
|
41
|
+
<pre class="mail-body">{{ message.body if message.body is not none else 'Message body is unavailable. Check access permissions and refresh.' }}</pre>
|
|
42
|
+
{% else %}<div class="mail-empty"><strong>Your inbox, with a delivery trail</strong>Select a message to read it, view attachments, and check what its rules have done.</div>{% endif %}
|
|
43
|
+
</section>
|
|
44
|
+
</div>
|
|
45
|
+
{% endblock %}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{% extends 'base.html' %}{% block title %}Email rules · Keywright{% endblock %}
|
|
2
|
+
{% block body %}{% include '_mail_style.html' %}
|
|
3
|
+
<h1>Email rules</h1><p class="page-intro">Match incoming messages and choose what happens next. The first enabled matching rule runs, in priority order.</p>
|
|
4
|
+
{% include '_mail_nav.html' %}
|
|
5
|
+
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr))">
|
|
6
|
+
<section aria-label="Saved email rules">
|
|
7
|
+
{% for entry in rules %}<div class="card mail-rule"><div class="row"><h2>{{ entry.name }}</h2><span class="pill {{ 'ok' if entry.enabled }}">{{ 'Enabled' if entry.enabled else 'Paused' }}</span></div>
|
|
8
|
+
<p>{{ 'From '+entry.sender if entry.sender else 'Any sender' }}{% if entry.subject %} · Subject contains “{{ entry.subject }}”{% endif %}</p>
|
|
9
|
+
<p>{{ {'pdf':'Send PDF attachments to Budget','mark_read':'Mark as read','move':'Move to folder'}.get(entry.action) }}{% if entry.action == 'move' %}: {{ entry.target }}{% endif %}</p>
|
|
10
|
+
<div class="row"><span class="mut">Priority {{ entry.position }}</span><a href="?edit={{ entry.id }}" class="btn sm">Edit</a><form method="post" action="/ui/mail/rules/{{ entry.id }}/toggle"><button class="btn sm">{{ 'Pause' if entry.enabled else 'Enable' }}</button></form></div></div>
|
|
11
|
+
{% else %}<div class="card mail-empty"><strong>Give incoming mail a job</strong>Add your electric-bill rule to deliver its PDFs to Budget.</div>{% endfor %}
|
|
12
|
+
</section>
|
|
13
|
+
<form class="card mail-config" method="post" action="/ui/mail/rules/save"><h2>{{ 'Edit rule' if rule else 'New rule' }}</h2>
|
|
14
|
+
<input type="hidden" name="id" value="{{ rule.id if rule else '' }}">
|
|
15
|
+
<label>Rule name<input name="name" required maxlength="120" placeholder="Electric bill" value="{{ rule.name if rule else '' }}"></label>
|
|
16
|
+
<label>From address<input name="sender" maxlength="320" placeholder="billing@utility.example" value="{{ rule.sender if rule else '' }}"></label><p class="help">Use an address or a pattern such as *@utility.example.</p>
|
|
17
|
+
<label>Subject contains<input name="subject" maxlength="200" placeholder="Your electric bill" value="{{ rule.subject if rule else '' }}"></label><p class="help">When both filters are set, both must match.</p>
|
|
18
|
+
<label>Action<select name="action">{% for value,label in [('pdf','Send PDFs to Budget'),('mark_read','Mark as read'),('move','Move to folder')] %}<option value="{{ value }}" {{ 'selected' if rule and rule.action == value }}>{{ label }}</option>{% endfor %}</select></label>
|
|
19
|
+
<label>Destination folder <span class="mut">(for Move to folder)</span><input name="target" maxlength="200" value="{{ rule.target if rule else '' }}" placeholder="Notifications"></label>
|
|
20
|
+
<label>Priority<input type="number" name="position" min="0" max="9999" value="{{ rule.position if rule else 100 }}"></label><p class="help">Lower numbers run first.</p>
|
|
21
|
+
<label class="inline"><input type="checkbox" name="enabled" {{ 'checked' if not rule or rule.enabled }}> Enable this rule</label>
|
|
22
|
+
<p class="help">PDF delivery marks the message read after Budget confirms receipt. Existing messages require Run rules; editing a rule does not resend completed deliveries.</p>
|
|
23
|
+
<div class="row"><button class="btn p">Save rule</button>{% if rule %}<a class="btn" href="/ui/mail/rules">New rule</a>{% endif %}</div>
|
|
24
|
+
</form></div>{% endblock %}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{% extends 'base.html' %}{% block title %}Mail connection · Keywright{% endblock %}
|
|
2
|
+
{% block body %}{% include '_mail_style.html' %}<h1>Mail connection</h1><p class="page-intro">Connect the agents mailbox and its PDF delivery destination.</p>{% include '_mail_nav.html' %}
|
|
3
|
+
<form class="card mail-config" method="post">
|
|
4
|
+
<h2>Mailbox</h2><div class="grid">
|
|
5
|
+
<label>Email resource<select name="resource" required><option value="">Choose resource</option>{% for res in resources %}<option value="{{ res.id }}" {{ 'selected' if cfg.resource == res.id }}>{{ res.name }}</option>{% endfor %}</select></label>
|
|
6
|
+
<label>Agent for mailbox access<select name="agent" required><option value="">Choose agent</option>{% for agent in agents %}<option value="{{ agent.id }}" {{ 'selected' if cfg.agent == agent.id }}>{{ agent.name }}</option>{% endfor %}</select></label>
|
|
7
|
+
<label>Account name<input name="account" value="{{ cfg.account }}" required maxlength="200"></label>
|
|
8
|
+
<label>Mailbox folder<input name="mailbox" value="{{ cfg.mailbox }}" required maxlength="200"></label>
|
|
9
|
+
</div><p class="help">Mailbox operations use this agent’s existing Keywright permissions. Reading a message here does not mark it read.</p>
|
|
10
|
+
<h2>Budget delivery</h2>
|
|
11
|
+
<label>Budget server URL<input type="url" name="budget_url" placeholder="http://budget-server:8099" value="{{ cfg.budget_url or '' }}"></label>
|
|
12
|
+
<label>Document delivery token<input type="password" name="budget_token" autocomplete="new-password" placeholder="{{ 'Saved — leave blank to keep' if cfg.budget_token else 'ba_docs_…' }}"></label>
|
|
13
|
+
<p class="help">Create this token in Budget → Settings → Document inbox. PDFs are stored as received. Each attachment can be up to 5 MiB.</p>
|
|
14
|
+
<h2>Automatic processing</h2>
|
|
15
|
+
<label class="inline"><input type="checkbox" name="enabled" {{ 'checked' if cfg.enabled }}> Refresh and run email rules every minute</label>
|
|
16
|
+
<p class="help">The first sync only loads your inbox. New arrivals after that can run automatically. Existing messages can be processed with Run rules.</p>
|
|
17
|
+
<p class="help">These email rules are separate from the legacy YAML router. Pause overlapping legacy rules before enabling automatic processing.</p>
|
|
18
|
+
<button class="btn p">Save connection</button>
|
|
19
|
+
</form>{% endblock %}
|