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,14 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}Sign in · Keywright{% endblock %}
|
|
3
|
+
{% block body %}
|
|
4
|
+
<div class="card">
|
|
5
|
+
<h1>Opening sign-in</h1>
|
|
6
|
+
<p>Continue to connect {{ resource_name }}.</p>
|
|
7
|
+
<p><a id="oauth-continue" class="btn p" href="{{ authorization_url }}" rel="noreferrer">Continue to sign in</a></p>
|
|
8
|
+
<p class="help">If sign-in does not open automatically, use the button above.</p>
|
|
9
|
+
<p><a href="/ui/resources/{{ resource_id }}">Back to resource</a></p>
|
|
10
|
+
</div>
|
|
11
|
+
{% endblock %}
|
|
12
|
+
{% block scripts %}
|
|
13
|
+
<script>window.location.replace(document.getElementById('oauth-continue').href);</script>
|
|
14
|
+
{% endblock %}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Rules{% endblock %}{% block body %}
|
|
2
|
+
<h1>Standing rules</h1>
|
|
3
|
+
<p class="page-intro"><a href="/ui/friction">Review approval friction and exact-operation suggestions</a>.</p>
|
|
4
|
+
{% if error %}<div class="card" role="alert">{{ error }}</div>{% endif %}
|
|
5
|
+
<div class="card">
|
|
6
|
+
<form method="post" action="/ui/policies">
|
|
7
|
+
<div class="grid">
|
|
8
|
+
<div><label>Pattern</label><input type="text" name="pattern" value="{{ draft.get('pattern', '') }}" placeholder="ssh/nvr/exec/journalctl · mcp/ha/tools/get_state · ssh/dev-box/exec/**" required>
|
|
9
|
+
<div class="help"><code>*</code> one segment, <code>**</code> everything below. A literal pattern also covers everything under it. MCP calls can be qualified by argument: <code>mcp/ha/tools/call_service/service=automation.turn_off</code>, and a value may end in <code>*</code> (<code>entity_id=light.*</code>). Keys come from the resource's argument keys.</div></div>
|
|
10
|
+
<div><label>Effect</label><select name="effect"><option value="allow">allow</option><option value="deny" {{ 'selected' if draft.get('effect') == 'deny' }}>deny (always wins)</option></select></div>
|
|
11
|
+
<div><label>Agent</label><select name="agent_id"><option value="">every agent</option>{% for a in agents %}<option value="{{ a.id }}" {{ 'selected' if draft.get('agent_id') == a.id }}>{{ a.name }}</option>{% endfor %}</select></div>
|
|
12
|
+
<div><label>Note</label><input type="text" name="note" value="{{ draft.get('note', '') }}"></div>
|
|
13
|
+
</div>
|
|
14
|
+
<div class="row" style="margin-top:10px"><button class="btn" name="action" value="preview">Preview recent activity</button><button class="btn p" name="action" value="save">Add rule</button></div>
|
|
15
|
+
</form></div>
|
|
16
|
+
{% if preview %}
|
|
17
|
+
<section class="card" aria-labelledby="preview-title">
|
|
18
|
+
<h2 id="preview-title">Rule preview · not saved</h2>
|
|
19
|
+
<p><b>{{ draft.effect }} <code>{{ draft.pattern }}</code></b> · {{ agent_map[draft.agent_id].name if draft.agent_id in agent_map else 'every agent' }}</p>
|
|
20
|
+
{% include "_policy_preview_results.html" %}
|
|
21
|
+
<p class="small">Edit the draft above and preview again, or choose <b>Add rule</b> to save. Access settings may change after this preview.</p>
|
|
22
|
+
</section>
|
|
23
|
+
{% endif %}
|
|
24
|
+
<div class="card"><table class="data-table" role="table"><thead role="rowgroup"><tr role="row"><th scope="col" role="columnheader">Effect</th><th scope="col" role="columnheader">Pattern</th><th scope="col" role="columnheader">Agent</th><th scope="col" role="columnheader">Note</th><th scope="col" role="columnheader">Added</th><th scope="col" role="columnheader">Actions</th></tr></thead><tbody role="rowgroup">
|
|
25
|
+
{% for p in rows %}<tr role="row"><td role="cell" data-label="Effect"><div class="cell-value"><span class="pill {{ p.effect }}">{{ p.effect }}</span></div></td><td role="cell" data-label="Pattern" class="wide"><div class="cell-value"><code>{{ p.pattern }}</code></div></td>
|
|
26
|
+
<td role="cell" data-label="Agent" class="wide"><div class="cell-value">{{ agent_map[p.agent_id].name if p.agent_id in agent_map else '<i class="mut">all</i>'|safe }}</div></td><td role="cell" data-label="Note" class="wide small"><div class="cell-value">{{ p.note }}<div>{{ constraint_label(p.constraints) }}</div>{% if p.constraints %}<details><summary>Operation constraints</summary>{% with constraints=p.constraints %}{% include "_constraints.html" %}{% endwith %}</details>{% endif %}{% if p.request_id %}<a href="/ui/requests/{{ p.request_id }}">Original decision</a>{% endif %}</div></td>
|
|
27
|
+
<td role="cell" data-label="Added" class="small mut"><div class="cell-value">{{ p.created_at.strftime('%m-%d') }} · {{ p.created_by }}</div></td>
|
|
28
|
+
<td role="cell" data-label="Actions" class="wide actions"><div class="cell-value"><form method="post" action="/ui/policies/{{ p.id }}/delete" class="inline"><button class="btn sm">Delete</button></form></div></td></tr>{% endfor %}
|
|
29
|
+
</tbody></table>{% if not rows %}<p class="mut">No standing rules yet. Add a rule above.</p>{% endif %}</div>
|
|
30
|
+
{% endblock %}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Request {{ req.id }}{% endblock %}{% block body %}
|
|
2
|
+
<h1>Request <span class="mono">{{ req.id }}</span> <span class="pill {{ req.status }}">{{ req.status }}</span></h1>
|
|
3
|
+
<div class="card">
|
|
4
|
+
<table class="detail-table" role="table"><tbody role="rowgroup">
|
|
5
|
+
<tr role="row"><th scope="row" role="rowheader">Agent</th><td role="cell">{{ req.agent.name }}</td></tr>
|
|
6
|
+
<tr role="row"><th scope="row" role="rowheader">Session</th><td role="cell" class="mono">{{ req.session_id or '— (no session id: “This session” is not available for this request)' }}</td></tr>
|
|
7
|
+
<tr role="row"><th scope="row" role="rowheader">Patterns</th><td role="cell">{% include "_patterns.html" %}</td></tr>
|
|
8
|
+
<tr role="row"><th scope="row" role="rowheader">Reason</th><td role="cell">{{ req.reason or '—' }}</td></tr>
|
|
9
|
+
{% if hazards_of(req.detail) %}<tr role="row"><th scope="row" role="rowheader">Flags</th><td role="cell">{% include "_hazards.html" %}</td></tr>{% endif %}
|
|
10
|
+
{% if req.semantic %}<tr role="row"><th scope="row" role="rowheader">Jev</th><td role="cell">{% include "_semantic.html" %}</td></tr>{% endif %}
|
|
11
|
+
{% set ops = operations(req.detail) %}{% if ops %}<tr role="row"><th scope="row" role="rowheader">Operation{% if ops|length > 1 %}s ({{ ops|length }}){% endif %}</th><td role="cell">{% for pats, op in ops %}<div class="access-source">{% with constraints=op %}{% include "_constraints.html" %}{% endwith %}{% if pats %}<div class="small mut">{% for p in pats %}<code>{{ p }}</code> {% endfor %}</div>{% endif %}</div>{% endfor %}</td></tr>
|
|
12
|
+
{% elif req.detail %}<tr role="row"><th scope="row" role="rowheader">Detail</th><td role="cell"><pre>{{ req.detail | redact | tojson(indent=1) }}</pre></td></tr>{% endif %}
|
|
13
|
+
<tr role="row"><th scope="row" role="rowheader">Created</th><td role="cell">{{ req.created_at }}</td></tr>
|
|
14
|
+
{% if req.decided_at %}<tr role="row"><th scope="row" role="rowheader">Decided</th><td role="cell">{{ req.decided_at }} · {{ decision_label(req) }} via {{ req.decided_via }}{% if req.deny_reason %} · “{{ req.deny_reason }}”{% endif %}</td></tr>{% endif %}
|
|
15
|
+
{% if req.decision_options %}<tr role="row"><th scope="row" role="rowheader">Access scope</th><td role="cell">{{ 'Exact operation' if req.decision_options.extent == 'exact' else 'Listed permission patterns' }}{% if req.decision_options.ttl_s %} · {{ req.decision_options.ttl_s // 60 }} minutes{% endif %}</td></tr>{% endif %}
|
|
16
|
+
</tbody></table>
|
|
17
|
+
{% if req.status == 'pending' %}<div style="margin-top:12px">{% with next='/ui/requests/' ~ req.id, in_detail=True %}{% include "_decide.html" %}{% endwith %}</div>{% endif %}
|
|
18
|
+
</div>
|
|
19
|
+
{% if grants %}<div class="row toolbar"><form method="post" action="/ui/requests/{{ req.id }}/revoke"><input type="hidden" name="next" value="/ui/requests/{{ req.id }}"><button class="btn">Revoke this approval</button></form><span class="small mut">Other permission sources may still allow access. Running operations continue.</span></div>{% endif %}
|
|
20
|
+
{% if rules %}<div class="card">This decision created {{ rules|length }} standing rules. <a href="/ui/policies">Manage those rules</a> to change permanent access.</div>{% endif %}
|
|
21
|
+
{% if timeline %}<h2>Approval activity</h2><div class="card">
|
|
22
|
+
{% for e in timeline %}<div class="access-target"><div><b>{{ e.tool }}</b> · {{ e.decision }} <a href="/ui/audit?q=%23{{ e.id }}">audit #{{ e.id }}</a></div><div class="small mut">{{ e.ts.strftime('%Y-%m-%d %H:%M:%S') }} UTC{% if e.duration_ms is not none %} · {{ e.duration_ms }} ms{% endif %}{% if e.exit_code is not none %} · exit {{ e.exit_code }}{% endif %}</div>
|
|
23
|
+
{% if e.detail.command %}<pre>$ {{ e.detail.command }}</pre>{% elif e.detail.path %}<div><code>{{ e.detail.path }}</code></div>{% elif e.detail.tool %}<div>{{ e.detail.server }} · {{ e.detail.tool }}</div>{% endif %}</div>{% endfor %}
|
|
24
|
+
</div>{% endif %}
|
|
25
|
+
{% if grants %}<h2>Grants from this request</h2><div class="card"><table class="data-table" role="table"><thead role="rowgroup"><tr role="row"><th scope="col" role="columnheader">Pattern</th><th scope="col" role="columnheader">Scope</th><th scope="col" role="columnheader">Expires</th><th scope="col" role="columnheader">Uses</th><th scope="col" role="columnheader">Revoked</th></tr></thead><tbody role="rowgroup">
|
|
26
|
+
{% for g in grants %}<tr role="row"><td role="cell" data-label="Pattern" class="wide"><div class="cell-value"><code>{{ g.pattern }}</code></div></td><td role="cell" data-label="Scope"><div class="cell-value">{{ g.scope }}</div></td><td role="cell" data-label="Expires"><div class="cell-value">{{ g.expires_at or '—' }}</div></td><td role="cell" data-label="Uses"><div class="cell-value">{{ g.uses_remaining if g.uses_remaining is not none else '∞' }}</div></td><td role="cell" data-label="Revoked"><div class="cell-value">{{ g.revoked_at or '' }}</div></td></tr>{% endfor %}</tbody></table></div>{% endif %}
|
|
27
|
+
{% endblock %}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Requests{% endblock %}{% block body %}
|
|
2
|
+
<h1>Requests</h1>
|
|
3
|
+
<p class="page-intro"><a href="/ui/friction">Approval friction</a> · recurring requests, decision wait times and call failures.</p>
|
|
4
|
+
<div class="card"><table class="data-table" role="table"><thead role="rowgroup"><tr role="row"><th scope="col" role="columnheader">When</th><th scope="col" role="columnheader">Agent</th><th scope="col" role="columnheader">Patterns</th><th scope="col" role="columnheader">Status</th><th scope="col" role="columnheader">Via</th></tr></thead><tbody role="rowgroup">
|
|
5
|
+
{% for r in rows %}<tr role="row"><td role="cell" data-label="When" class="small mut"><div class="cell-value"><a href="/ui/requests/{{ r.id }}">{{ r.created_at.strftime('%m-%d %H:%M:%S') }}</a></div></td><td role="cell" data-label="Agent" class="wide"><div class="cell-value">{{ r.agent.name }}</div></td>
|
|
6
|
+
<td role="cell" data-label="Patterns" class="wide"><div class="cell-value">{% for p in r.patterns %}<code>{{ p }}</code> {% endfor %}<div class="small mut">{{ r.reason }}</div></div></td>
|
|
7
|
+
<td role="cell" data-label="Status"><div class="cell-value">{% if hazard_level(r.detail) %}<span class="pill {{ hazard_level(r.detail) }}">{{ hazard_level(r.detail) }}</span> {% endif %}<span class="pill {{ r.status }}">{{ r.status }}</span>{% if r.decision_scope %} <span class="small mut">{{ r.decision_scope }}</span>{% endif %}</div></td>
|
|
8
|
+
<td role="cell" data-label="Via" class="small mut"><div class="cell-value">{{ r.decided_via or '' }}</div></td></tr>{% endfor %}</tbody></table>{% if not rows %}<p class="mut">No requests yet. Agent access requests will appear here.</p>{% endif %}</div>
|
|
9
|
+
{% endblock %}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
{% extends "base.html" %}{% from "_icons.html" import icon %}{% block title %}{{ 'Edit' if res else 'Add' }} {{ preset.title if preset else kind }}{% endblock %}{% block body %}
|
|
2
|
+
{% include "_integration_style.html" %}
|
|
3
|
+
<div class="integration-page integration-form">
|
|
4
|
+
{% if not res %}<a class="integration-back" href="/ui/resources/new">{{ icon('chevron') }} All integrations</a>{% endif %}
|
|
5
|
+
{% if res %}
|
|
6
|
+
{% include "_infrastructure_nav.html" %}
|
|
7
|
+
{% endif %}
|
|
8
|
+
{% if res %}<p class="small mut">Inventory: {% if inventory_asset %}<a href="/ui/inventory/assets/{{ inventory_asset.id }}">{{ inventory_asset.name }}</a>{% else %}<a href="/ui/inventory/assets/new?resource={{ res.id }}">Add this resource to inventory</a> · or link it from an existing inventory record{% endif %}</p>{% endif %}
|
|
9
|
+
|
|
10
|
+
{% if preset %}
|
|
11
|
+
<div class="integration-intro"><span class="integration-mark {{ preset.icon }}">{% if preset.icon == 'notion' %}N{% else %}{{ icon(preset.icon) }}{% endif %}</span><div><h1>{{ preset.title }}</h1><p>{{ preset.instructions if not res else preset.description }}</p></div></div>
|
|
12
|
+
{% if preset.auth_type == 'oauth' %}<ol class="integration-steps" aria-label="Connection setup"><li {% if not res %}aria-current="step"{% endif %}><span>1</span> Connection details</li><li {% if res %}aria-current="step"{% endif %}><span>2</span> {{ 'Connected' if oauth_status == 'connected' else 'Sign in' }}</li></ol>{% endif %}
|
|
13
|
+
{% else %}<h1>{{ 'Edit' if res else 'Add' }} {{ {'ssh': 'SSH host', 'mcp': 'MCP server', 'llm': 'LLM gateway'}[kind] }}</h1>{% endif %}
|
|
14
|
+
<div class="card"><form method="post" action="/ui/resources">
|
|
15
|
+
<input type="hidden" name="rid" value="{{ res.id if res else '' }}"><input type="hidden" name="kind" value="{{ kind }}">
|
|
16
|
+
<input type="hidden" name="integration" value="{{ preset.id if preset else '' }}">
|
|
17
|
+
<input type="hidden" name="adapter" value="{{ adapter | default('') }}">
|
|
18
|
+
<div class="grid">
|
|
19
|
+
<div><label>Connection name</label><input type="text" name="name" value="{{ res.name if res else preset.default_name if preset else '' }}" required {% if res %}readonly{% endif %}></div>
|
|
20
|
+
<div><label>Agent access</label><select name="default_mode">{% for m, label in [('approval', 'Ask for approval'), ('open', 'Allow by default'), ('locked', 'Require explicit rules')] %}<option value="{{ m }}" {{ 'selected' if res and res.default_mode == m }}>{{ label }}</option>{% endfor %}</select>
|
|
21
|
+
<div class="help">Applies when no access rule or active grant covers an operation.</div></div>
|
|
22
|
+
</div>
|
|
23
|
+
<label>Description <span class="mut">(optional)</span></label><input type="text" name="description" value="{{ res.description if res else '' }}">
|
|
24
|
+
{% if kind == 'ssh' %}
|
|
25
|
+
<div class="grid host-fields">
|
|
26
|
+
<div><label>Host</label><input type="text" name="host" value="{{ res.config.host if res else '' }}" required></div>
|
|
27
|
+
<div><label>Port</label><input type="number" name="port" value="{{ res.config.port if res else 22 }}"></div>
|
|
28
|
+
<div><label>User</label><input type="text" name="user" value="{{ res.config.user if res else 'root' }}"></div>
|
|
29
|
+
</div>
|
|
30
|
+
{% if res %}
|
|
31
|
+
<label>Private key path on the Keywright host</label><input type="text" name="key_path" value="{{ res.config.key_path if res else '' }}" placeholder="{{ keys_dir }}/<name>">
|
|
32
|
+
<div class="help">{% if key and key.exists %}Key present{% if key.pub %}: <code>{{ key.pub }}</code>{% endif %}{% else %}No key yet. Save, then use <b>Generate key</b> below (recommended), or paste one here.{% endif %}</div>
|
|
33
|
+
<details><summary class="small mut" style="cursor:pointer;margin-top:8px">Paste an existing private key instead</summary>
|
|
34
|
+
<textarea name="private_key" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea></details>
|
|
35
|
+
{% else %}
|
|
36
|
+
<div class="help" style="margin-top:8px">Save first; then generate a key on this Keywright and enrol it on the host from the same page.</div>
|
|
37
|
+
{% endif %}
|
|
38
|
+
{% if res and res.config.host_key %}<label>Pinned host key</label><code>{{ res.config.host_key }}</code>
|
|
39
|
+
<div class="row" style="margin-top:6px"><label class="inline"><input type="checkbox" name="forget_host_key" value="1"> forget host key (re-pin on next connect)</label></div>{% else %}<div class="help" style="margin-top:8px">Host key not pinned yet: the first successful connection or probe pins it.</div>{% endif %}
|
|
40
|
+
{% elif kind == 'llm' %}
|
|
41
|
+
{% set limits = (res.config.limits if res else {}) or {} %}{% set budget = (res.config.agent_budget if res else {}) or {} %}
|
|
42
|
+
<label>LiteLLM URL <span class="mut">(reachable from this Keywright only; never published to agents)</span></label>
|
|
43
|
+
<input type="url" name="url" value="{{ res.config.url if res else '' }}" placeholder="http://litellm:4000" required>
|
|
44
|
+
<label>Master key <span class="mut">(mints and deletes virtual keys; stored here, shown as ***)</span></label>
|
|
45
|
+
<input type="password" name="master_key" value="{{ '***' if res and res.config.master_key else '' }}" autocomplete="off" placeholder="sk-…" {% if not res %}required{% endif %}>
|
|
46
|
+
<label style="margin-top:8px">Models agents may ask for <span class="mut">(comma or newline separated; <b>Test connection</b> refreshes this from the gateway)</span></label>
|
|
47
|
+
<textarea name="models_text" rows="3" placeholder="claude-sonnet-5, gpt-5.6">{{ (res.config.models or []) | join(', ') if res else '' }}</textarea>
|
|
48
|
+
<div class="help">Each model is a path: <code>llm/{{ res.name if res else 'litellm' }}/model/<model></code> (slashes in a name become <code>_</code>). A rule on <code>llm/{{ res.name if res else 'litellm' }}/model/*</code> covers them all. Keys are minted by <b>approvals</b>; an <i>open</i> default or a standing allow rule mints nothing until the LLM proxy ships, so keep this on <i>approval</i> for now.</div>
|
|
49
|
+
<h2 style="margin-top:14px">Per-lease ceiling</h2>
|
|
50
|
+
<p class="small mut">Every approval becomes one LiteLLM virtual key with exactly these limits and the lease's lifetime. If the agent goes feral, this is all the money there is.</p>
|
|
51
|
+
<div class="grid">
|
|
52
|
+
<div><label>Max spend (USD)</label><input type="text" name="max_budget" value="{{ limits.get('max_budget', 2.0) if res else 2 }}" required inputmode="decimal"></div>
|
|
53
|
+
<div><label>Requests / minute</label><input type="text" name="rpm_limit" value="{{ limits.get('rpm_limit', 30) if res else 30 }}" inputmode="numeric"></div>
|
|
54
|
+
<div><label>Tokens / minute <span class="mut">(blank = none)</span></label><input type="text" name="tpm_limit" value="{{ limits.get('tpm_limit') or '' if res else '' }}" inputmode="numeric"></div>
|
|
55
|
+
<div><label>Concurrent requests</label><input type="text" name="max_parallel_requests" value="{{ limits.get('max_parallel_requests', 4) if res else 4 }}" inputmode="numeric"></div>
|
|
56
|
+
</div>
|
|
57
|
+
<h2 style="margin-top:14px">Per-agent ceiling</h2>
|
|
58
|
+
<p class="small mut">Each agent is a LiteLLM internal user; all of its keys on this gateway count against this window as well.</p>
|
|
59
|
+
<div class="grid">
|
|
60
|
+
<div><label>Max spend (USD)</label><input type="text" name="agent_max_budget" value="{{ budget.get('max_budget', 10.0) if res else 10 }}" required inputmode="decimal"></div>
|
|
61
|
+
<div><label>Per</label><select name="agent_budget_duration">{% for d, l in [('1h', 'hour'), ('1d', 'day'), ('7d', 'week'), ('30d', '30 days')] %}<option value="{{ d }}" {{ 'selected' if (budget.get('budget_duration', '1d') if res else '1d') == d }}>{{ l }}</option>{% endfor %}</select></div>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="help">Set a global ceiling too, in <code>litellm.yaml</code> (<code>litellm_settings.max_budget</code>), and keep the vendors' own spend limits on. Keywright is a wall, not the only wall.</div>
|
|
64
|
+
{% else %}
|
|
65
|
+
{% if adapter == 'keywright' %}
|
|
66
|
+
<p class="help">Built-in tools for inventory, hosting and network topology, discovery observations, subnets and IP allocations. Agents use the same approval gate and access rules as other resources. No URL or credentials are needed.</p>
|
|
67
|
+
{% elif adapter == 'omada' %}
|
|
68
|
+
<p class="help">In Omada Global View, open <b>Settings → Platform Integration → Open API</b> and create an app in <b>Client</b> mode with access to the sites you want. This connector reads sites, devices and clients.</p>
|
|
69
|
+
<label>Interface Access Address</label><input type="url" name="url" value="{{ res.config.url if res else '' }}" placeholder="https://omada.example:8043" required>
|
|
70
|
+
<div class="help">Copy the address from Open API settings, including its port. Do not include an API path.</div>
|
|
71
|
+
<label>Omada ID</label><input type="text" name="omada_id" value="{{ res.config.omada_id if res else '' }}" required>
|
|
72
|
+
<label>Client ID</label><input type="text" name="client_id" value="{{ res.config.client_id if res else '' }}" required>
|
|
73
|
+
<label>Client Secret</label><input type="password" name="client_secret" value="" autocomplete="new-password" placeholder="{{ 'Leave blank to keep the stored secret' if res else '' }}" {% if not res %}required{% endif %}>
|
|
74
|
+
<div class="help">Stored on Keywright. Agents never receive this credential.</div>
|
|
75
|
+
<label>TLS certificate verification</label><select name="verify_tls"><option value="1" {{ 'selected' if not res or res.config.get('verify_tls', True) }}>Verify certificate (recommended)</option><option value="0" {{ 'selected' if res and not res.config.get('verify_tls', True) }}>Skip verification for a self-signed controller</option></select>
|
|
76
|
+
<label>Custom CA bundle path <span class="mut">(optional, on the Keywright server)</span></label><input type="text" name="ca_bundle" value="{{ res.config.get('ca_bundle', '') if res else '' }}">
|
|
77
|
+
{% else %}
|
|
78
|
+
{% if preset and preset.url %}
|
|
79
|
+
<input type="hidden" name="url" value="{{ preset.url }}"><input type="hidden" name="auth_type" value="{{ preset.auth_type }}">
|
|
80
|
+
<p class="help">You'll authorize access on {{ preset.title }}. Keywright keeps the connection credentials; your agents use the access rules you choose here.</p>
|
|
81
|
+
<details class="integration-advanced"><summary>Advanced connection settings</summary>
|
|
82
|
+
{% else %}
|
|
83
|
+
<label>Streamable HTTP URL</label><input type="url" name="url" value="{{ res.config.url if res else '' }}" required>
|
|
84
|
+
{% endif %}
|
|
85
|
+
{% set oauth = res.config.get('oauth', {}) if res else {} %}
|
|
86
|
+
{% if not (preset and preset.url) %}
|
|
87
|
+
<label>Authentication</label><select name="auth_type">
|
|
88
|
+
<option value="headers" {{ 'selected' if not res or res.config.get('auth_type') != 'oauth' }}>Headers / API token</option>
|
|
89
|
+
<option value="oauth" {{ 'selected' if res and res.config.get('auth_type') == 'oauth' }}>OAuth — connect in your browser</option>
|
|
90
|
+
</select>
|
|
91
|
+
<div class="help">Choose the authentication method required by your server. For OAuth, save and then connect in your browser.</div>
|
|
92
|
+
{% endif %}
|
|
93
|
+
<details style="margin-top:12px"><summary>Advanced OAuth settings</summary>
|
|
94
|
+
<label>Client ID (optional)</label><input name="oauth_client_id" value="{{ oauth.get('client_id', '') }}" autocomplete="off">
|
|
95
|
+
<div class="help">Leave blank for automatic client registration. For a pre-registered client, use the callback <code>{{ oauth_callback }}</code> on your configured public HTTPS URL.</div>
|
|
96
|
+
<label>Client authentication</label><select name="oauth_auth_method">
|
|
97
|
+
{% for method in ['none', 'client_secret_basic', 'client_secret_post'] %}<option value="{{ method }}" {{ 'selected' if oauth.get('token_endpoint_auth_method', 'none') == method }}>{{ method }}</option>{% endfor %}
|
|
98
|
+
</select>
|
|
99
|
+
<label>Client secret (optional; leave blank to keep stored secret)</label><input type="password" name="oauth_client_secret" autocomplete="new-password">
|
|
100
|
+
<label>Scopes (optional, space separated)</label><input name="oauth_scope" value="{{ oauth.get('scope', '') }}">
|
|
101
|
+
<div class="help">Leave blank to use the MCP server's advertised scopes.</div>
|
|
102
|
+
<label>Additional OAuth origins (optional, comma separated)</label><input name="oauth_origins" value="{{ oauth.get('origins', []) | join(', ') }}" placeholder="https://login.example.com">
|
|
103
|
+
<div class="help">The MCP server's origin is trusted by default. Add an origin only when your provider uses a separate authorization server.</div>
|
|
104
|
+
</details>
|
|
105
|
+
<label>Headers (one per line, <code>Name: value</code>; stored secrets show as ***)</label>
|
|
106
|
+
<textarea name="headers">{% if res %}{% for k, v in res.config.headers.items() %}{{ k }}: {{ '***' if ('auth' in k.lower() or 'token' in k.lower() or 'key' in k.lower()) else v }}
|
|
107
|
+
{% endfor %}{% endif %}</textarea>
|
|
108
|
+
{% if preset and preset.url %}</details>{% endif %}
|
|
109
|
+
{% endif %}
|
|
110
|
+
<details class="integration-advanced"><summary>Advanced access settings</summary>
|
|
111
|
+
<label for="result-inspection">MCP result inspection</label>
|
|
112
|
+
{% set inspection_mode = res.config.get('result_inspection', 'off') if res else 'off' %}
|
|
113
|
+
<select id="result-inspection" name="result_inspection_mode">
|
|
114
|
+
<option value="off" {{ 'selected' if inspection_mode == 'off' }}>Off</option>
|
|
115
|
+
<option value="observe" {{ 'selected' if inspection_mode == 'observe' }}>Observe and report</option>
|
|
116
|
+
<option value="redact" {{ 'selected' if inspection_mode == 'redact' }}>Redact detected credentials</option>
|
|
117
|
+
</select>
|
|
118
|
+
<div class="help">Start with Observe to check compatibility: it reports findings and delivers original output. Redact removes configured credentials and recognizable secrets. Suspicious instructions are advisory only. Scanning is local; no external AI receives results. Images, audio, binary resources, linked content and results over 256 KiB cannot be fully inspected and are withheld in Redact mode. Results needing changes to structured data are also withheld to preserve the tool's output schema. The tool has already run when a result is withheld. Findings appear in the audit log and result metadata.</div>
|
|
119
|
+
<label>Argument keys that qualify a call <span class="mut">(comma-separated dotted names, optional)</span></label>
|
|
120
|
+
<input type="text" name="arg_keys" value="{{ (res.config.arg_keys or []) | join(', ') if res else ('site_id' if adapter == 'omada' else '') }}" placeholder="service, entity_id, data.entity_id, identifier">
|
|
121
|
+
<div class="help">Each key present in a call adds a path such as <code>mcp/{{ res.name if res else 'ha' }}/tools/call_service/service=light.turn_on</code>. A rule or grant on the bare tool still covers every call; a deny on one qualifier blocks just that value. Without keys, calls are gated per tool.</div>
|
|
122
|
+
<label style="margin-top:8px">Bundles <span class="mut">(one per line: <code>name: tools/ha_get_*, tools/ha_search</code>; agents request <code>mcp/{{ res.name if res else 'ha' }}/bundle/<name></code>)</span></label>
|
|
123
|
+
<textarea name="bundles_text" rows="5" placeholder="read-only: tools/ha_get_*, tools/ha_search destructive: tools/ha_restart, tools/ha_call_service/service=turn_off">{{ bundles_text(res.config.get('bundles')) if res else bundles_text(inventory_bundles) if adapter == 'keywright' else '' }}</textarea>
|
|
124
|
+
<label style="margin-top:8px">Hazard catalog <span class="mut">(one per line: <code>high: tools/ha_manage_app, tools/ha_call_service/service=homeassistant.*</code>; severities medium, high, critical)</span></label>
|
|
125
|
+
<textarea name="hazard_tools_text" rows="3" placeholder="high: tools/ha_manage_app critical: tools/ha_call_service/service=homeassistant.stop">{{ bundles_text(res.config.get('hazard_tools')) if res else '' }}</textarea>
|
|
126
|
+
<div class="help">A tool whose name says delete, restart, reset… is flagged <b>medium</b> on its own (shown to you; access unchanged). <b>High</b> needs a fresh once-only approval whatever access exists; <b>critical</b> is refused. A qualifier such as <code>service=…</code> needs that argument key listed above.</div>
|
|
127
|
+
<label class="inline" style="margin-top:8px"><input type="checkbox" name="audit_handle" value="1" {{ 'checked' if not res or res.config.get('audit_handle', True) }}> append a <code>[keywright] audit #…</code> line and <code>_meta.keywright</code> to results so agents can cite the approval</label>
|
|
128
|
+
</details>
|
|
129
|
+
{% endif %}
|
|
130
|
+
<div class="integration-actions">{% if preset and preset.auth_type == 'oauth' and not res %}<button class="btn p" name="setup_action" value="connect">Continue to {{ preset.title }}</button><p>Saves this resource, then opens {{ preset.title }} sign-in.</p>{% else %}<button class="btn p">{{ 'Save' if res else 'Add resource' }}</button>{% endif %}</div>
|
|
131
|
+
</form>
|
|
132
|
+
{% if res and kind == 'mcp' and res.config.get('auth_type') == 'oauth' %}
|
|
133
|
+
<h2>OAuth connection</h2>
|
|
134
|
+
<p>Status: <strong>{{ {'connected': 'Connected', 'connecting': 'Sign-in pending', 'needs_reconnect': 'Reconnect required', 'disconnected': 'Disconnected'}.get(oauth_status, 'Disconnected') }}</strong></p>
|
|
135
|
+
<div class="row">
|
|
136
|
+
<form method="post" action="/ui/mcp-oauth/{{ res.id }}/connect"><button class="btn p">{{ 'Reconnect' if oauth_status == 'connected' else 'Connect' }}</button></form>
|
|
137
|
+
<form method="post" action="/ui/mcp-oauth/{{ res.id }}/disconnect"><button class="btn">Disconnect</button></form>
|
|
138
|
+
</div>
|
|
139
|
+
<div class="help">Disconnect removes Keywright's saved tokens. To revoke the provider's authorization too, remove Keywright in the provider's connection settings.</div>
|
|
140
|
+
{% endif %}
|
|
141
|
+
{% if res and kind == 'llm' and leases %}
|
|
142
|
+
<h2>Gateway</h2>
|
|
143
|
+
{% if leases.enforced %}<p class="small"><span class="pill ok">Budgets enforced</span> LiteLLM {{ leases.health.version }} · database connected · checked {{ leases.health.checked_at[:16] | replace('T', ' ') }}</p>
|
|
144
|
+
{% elif leases.health %}<p class="small"><span class="pill deny">Budget enforcement unavailable</span> LiteLLM reports its database <code>{{ leases.health.db }}</code>. Budgets are read from that database; without it the proxy serves past every limit, so no key is minted until it is connected.</p>
|
|
145
|
+
{% else %}<p class="small mut">Not probed yet. <b>Test connection</b> checks the database and refreshes the model list.</p>{% endif %}
|
|
146
|
+
<h2>Active leases</h2>
|
|
147
|
+
{% if leases.leases %}
|
|
148
|
+
<table class="data-table"><thead><tr><th>Agent</th><th>Models</th><th>Spent / ceiling</th><th>Time left</th></tr></thead><tbody>
|
|
149
|
+
{% for k in leases.leases %}<tr><td>{{ k.agent }}</td><td class="small">{{ k.row.models | join(', ') }}</td>
|
|
150
|
+
<td>{% if k.spend %}${{ '%.2f' | format(k.spend.spend) }}{% else %}?{% endif %} / ${{ '%.2f' | format(k.row.limits.max_budget or 0) }}</td>
|
|
151
|
+
<td class="small mut">{{ k.remaining }}{% if k.row.request_id %} · <a href="/ui/requests/{{ k.row.request_id }}">Approval</a>{% endif %}</td></tr>{% endfor %}
|
|
152
|
+
</tbody></table>
|
|
153
|
+
<p class="small"><b>Exposure remaining: ${{ '%.2f' | format(leases.exposure) }}</b> <span class="mut">· what every active lease on this gateway could still spend if it all went wrong right now</span></p>
|
|
154
|
+
{% else %}<p class="small mut">No active leases. Nothing can be spent through this gateway until you approve a request.</p>{% endif %}
|
|
155
|
+
{% endif %}
|
|
156
|
+
{% if res and kind == 'mcp' %}
|
|
157
|
+
<form method="post" action="/ui/resources/{{ res.id }}/suggest-bundles" class="row" style="margin-top:8px"><button class="btn">Suggest bundles from the tool list</button>
|
|
158
|
+
<span class="small mut">groups upstream tools by verb (get/list → read-only, call/run → control, remove/restart → destructive, set/create → config-write); adds only names you don't have</span></form>
|
|
159
|
+
{% endif %}
|
|
160
|
+
{% if res and kind == 'ssh' %}
|
|
161
|
+
<h2>Key & enrolment</h2>
|
|
162
|
+
{% if not (key and key.exists) %}
|
|
163
|
+
<form method="post" action="/ui/resources/{{ res.id }}/keygen" class="row"><button class="btn p">Generate key on this Keywright</button>
|
|
164
|
+
<span class="small mut">ed25519, stored at {{ keys_dir }}/{{ res.name }}, never leaves this machine</span></form>
|
|
165
|
+
{% else %}
|
|
166
|
+
<div class="grid">
|
|
167
|
+
<details class="card enrollment-option">
|
|
168
|
+
<summary>Enrol with a password <span class="small mut">(used once, not stored)</span></summary>
|
|
169
|
+
<form method="post" action="/ui/resources/{{ res.id }}/enroll">
|
|
170
|
+
<label>Log in as <span class="mut">(leave blank for {{ res.config.user }}; set this when root can't SSH with a password)</span></label>
|
|
171
|
+
<input type="text" name="login_user" placeholder="{{ res.config.user }}" autocomplete="off">
|
|
172
|
+
<label>That user's password</label><input type="password" name="password" required autocomplete="off">
|
|
173
|
+
<label>Become {{ res.config.user }} via</label>
|
|
174
|
+
<select name="escalate"><option value="">not needed (logging in as {{ res.config.user }})</option><option value="sudo">sudo</option><option value="su">su -</option></select>
|
|
175
|
+
<label>Root password <span class="mut">(for su; for sudo only if it differs from the login password)</span></label>
|
|
176
|
+
<input type="password" name="root_password" autocomplete="off">
|
|
177
|
+
<label class="inline" style="margin-top:8px"><input type="checkbox" name="restrict" value="1" checked> restrict the key to this Keywright's IP (<code>from=</code>)</label>
|
|
178
|
+
<div style="margin-top:10px"><button class="btn p">Install key & verify</button></div>
|
|
179
|
+
</form>
|
|
180
|
+
<div class="help">Connects once, appends the public key to <code>~/.ssh/authorized_keys</code>, pins the host key, then re-connects with the key to prove it works.</div>
|
|
181
|
+
</details>
|
|
182
|
+
<details class="card enrollment-option" open>
|
|
183
|
+
<summary>Run a command on the host</summary>
|
|
184
|
+
<p class="small mut">As the login user; link valid 30 min.</p>
|
|
185
|
+
<pre style="white-space:pre-wrap">{{ key.enroll_cmd }}</pre>
|
|
186
|
+
<div class="help">Then press <b>Test connection</b> below to pin the host key. Set the Keywright outbound IP in Settings to have this script add a <code>from=</code> restriction.</div>
|
|
187
|
+
</details>
|
|
188
|
+
</div>
|
|
189
|
+
{% endif %}
|
|
190
|
+
{% endif %}
|
|
191
|
+
{% if res %}
|
|
192
|
+
<div class="row" style="margin-top:12px">
|
|
193
|
+
<form method="post" action="/ui/resources/{{ res.id }}/probe"><button class="btn">Test connection</button></form>
|
|
194
|
+
<form method="post" action="/ui/resources/{{ res.id }}/delete" onsubmit="return confirm('Delete this resource?')"><button class="btn bad">Delete</button></form>
|
|
195
|
+
</div>{% endif %}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
{% endblock %}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{% extends "base.html" %}{% from "_icons.html" import icon %}
|
|
2
|
+
{% block title %}Resources{% endblock %}{% block body %}
|
|
3
|
+
{% include "_infrastructure_nav.html" %}
|
|
4
|
+
<div class="collection-heading">
|
|
5
|
+
<h1>Resources</h1>
|
|
6
|
+
<a class="btn p" href="/ui/resources/new">+ Add resource</a>
|
|
7
|
+
</div>
|
|
8
|
+
<p class="page-intro collection-intro">Manage the systems and endpoints your agents can reach.</p>
|
|
9
|
+
{% if rows %}
|
|
10
|
+
<form method="get" class="resource-organize row">
|
|
11
|
+
<label for="resource-sort">Sort</label><select id="resource-sort" name="sort">{% for key,label in [('type','Type'),('name','Name'),('ip','IP address')] %}<option value="{{ key }}" {{ 'selected' if sort==key }}>{{ label }}</option>{% endfor %}</select>
|
|
12
|
+
<label for="resource-group">Group</label><select id="resource-group" name="group">{% for key,label in [('none','None'),('host','Inventory host'),('network','Network')] %}<option value="{{ key }}" {{ 'selected' if group==key }}>{{ label }}</option>{% endfor %}</select><button class="btn sm">Apply</button>
|
|
13
|
+
</form>
|
|
14
|
+
<div id="resource-filters" hidden>
|
|
15
|
+
<div class="resource-tabs" role="group" aria-label="Filter by resource type">
|
|
16
|
+
{% for kind, label in [('all', 'All'), ('ssh', 'SSH'), ('mcp', 'MCP'), ('llm', 'LLM')] %}<button type="button" data-kind="{{ kind }}" aria-pressed="{{ 'true' if kind == 'all' else 'false' }}">{{ label }}</button>{% endfor %}
|
|
17
|
+
</div>
|
|
18
|
+
<div class="resource-search">{{ icon('search') }}<input type="search" id="resource-search" placeholder="Search resources…" aria-label="Search resources" autocomplete="off"></div>
|
|
19
|
+
</div>
|
|
20
|
+
{% endif %}
|
|
21
|
+
<div class="resource-list">
|
|
22
|
+
{% for r in rows %}
|
|
23
|
+
{% if group!='none' and loop.changed(resource_groups[r.id]) %}<div class="resource-group-heading" data-group-heading="{{ resource_groups[r.id] }}">{{ resource_groups[r.id] }}</div>{% endif %}
|
|
24
|
+
<article class="resource-card" data-group="{{ resource_groups[r.id] }}" data-resource-kind="{{ r.kind }}" data-search="{{ resource_groups[r.id] }} {{ resource_assets[r.id].name if resource_assets.get(r.id) else '' }} {{ r.name }} {{ r.description }} {{ r.kind }} {{ r.default_mode }} {% if r.kind == 'ssh' %}{{ r.config.user }} {{ r.config.host }} {{ r.config.get('port', 22) }}{% else %}{{ r.config.url }}{% endif %}">
|
|
25
|
+
<a class="resource-link" href="/ui/resources/{{ r.id }}">
|
|
26
|
+
<span class="resource-icon {{ r.kind }}">{{ icon(r.kind) }}</span>
|
|
27
|
+
<div class="resource-copy"><h2>{{ r.name }}</h2><div class="resource-endpoint">{{ 'Omada' if r.config.get('adapter') == 'omada' else r.kind | upper }} · {% if r.kind == 'ssh' %}{{ r.config.user }}@{{ r.config.host }}:{{ r.config.get('port', 22) }}{% elif r.kind == 'llm' %}{{ r.config.url }} · {{ (r.config.models or []) | length }} models · ${{ '%.2f' | format((r.config.limits or {}).get('max_budget', 2.0)) }} per lease{% elif r.config.get('adapter') == 'keywright' %}Built-in inventory and topology{% else %}{{ r.config.url }}{% endif %}</div></div>
|
|
28
|
+
<span class="resource-chevron">{{ icon('chevron') }}</span>
|
|
29
|
+
</a>
|
|
30
|
+
<div class="resource-labels">{% if resource_assets.get(r.id) %}<a class="resource-host" href="/ui/inventory/assets/{{ resource_assets[r.id].id }}">{{ resource_assets[r.id].name }}</a>{% endif %}<span class="resource-mode {{ r.default_mode }}"><span aria-hidden="true"></span>{{ {'open': 'Open access', 'approval': 'Approval required', 'locked': 'Locked'}.get(r.default_mode, r.default_mode) }}</span>{% if r.kind == 'mcp' and r.config.tools_drift %}<a class="resource-warning" href="/ui/access?resource={{ r.id }}">Tools changed · review</a>{% endif %}{% if r.kind == 'ssh' and not r.config.host_key %}<span class="resource-warning">Host key not pinned</span>{% endif %}{% if r.kind == 'llm' %}<span class="pill">Billable</span>{% if (r.config.health or {}).get('db') != 'connected' %}<span class="resource-warning">Budget enforcement unavailable · no key will be minted</span>{% endif %}{% endif %}</div>
|
|
31
|
+
{% if r.description %}<p class="resource-description">{{ r.description }}</p>{% endif %}
|
|
32
|
+
<details class="resource-actions action-menu"><summary aria-label="Actions for {{ r.name }}">{{ icon('more') }}</summary><div class="action-menu-items"><a href="/ui/resources/{{ r.id }}">Edit resource</a><a href="{{ '/ui/inventory/assets/' ~ resource_assets[r.id].id if resource_assets.get(r.id) else '/ui/inventory/assets/new?resource=' ~ r.id }}">{{ 'View inventory' if resource_assets.get(r.id) else 'Add to inventory' }}</a>{% if r.kind in ('mcp', 'llm') %}<a href="/ui/access?resource={{ r.id }}">Edit access</a>{% endif %}<form method="post" action="/ui/resources/{{ r.id }}/probe"><button type="submit">Test connection</button></form></div></details>
|
|
33
|
+
</article>
|
|
34
|
+
{% else %}<div class="collection-empty card">{{ icon('mcp') }}<h2>No resources yet</h2><p class="mut">Choose an integration or set up a custom connection.</p><a class="btn p" href="/ui/resources/new">+ Add resource</a></div>{% endfor %}
|
|
35
|
+
</div>
|
|
36
|
+
<div id="resource-empty" class="collection-empty card" hidden><h2>No matching resources</h2><p class="mut">Try a different name, endpoint, or resource type.</p><button type="button" class="btn" id="resource-reset">Clear filters</button></div>
|
|
37
|
+
{% if rows %}<p class="resource-count mut small" id="resource-count" role="status">Showing {{ rows|length }} resource{{ '' if rows|length == 1 else 's' }}</p>{% endif %}
|
|
38
|
+
<details class="resource-help"><summary>About resource access</summary><p class="small mut">Default <b>open</b> = allowed & audited, <b>approval</b> = ask, <b>locked</b> = only explicit rules. Access modes do not indicate connection health. Use “Test connection” in a resource’s menu to check connectivity.</p><p class="small mut">Permission paths: <code>ssh/<name>/exec/<command>[/<subcommand>]</code>, <code>mcp/<name>/tools/<tool></code> and <code>llm/<name>/model/<model></code>.</p></details>
|
|
39
|
+
{% endblock %}
|
|
40
|
+
{% block scripts %}
|
|
41
|
+
<script>
|
|
42
|
+
(function () {
|
|
43
|
+
var controls = document.getElementById('resource-filters');
|
|
44
|
+
if (!controls) return;
|
|
45
|
+
controls.hidden = false;
|
|
46
|
+
var search = document.getElementById('resource-search');
|
|
47
|
+
var cards = Array.from(document.querySelectorAll('[data-resource-kind]'));
|
|
48
|
+
var buttons = Array.from(controls.querySelectorAll('[data-kind]'));
|
|
49
|
+
var kind = 'all';
|
|
50
|
+
function filter() {
|
|
51
|
+
var query = search.value.trim().toLowerCase();
|
|
52
|
+
var count = 0;
|
|
53
|
+
cards.forEach(function (card) {
|
|
54
|
+
var matches = (kind === 'all' || card.dataset.resourceKind === kind) && card.dataset.search.toLowerCase().includes(query);
|
|
55
|
+
card.hidden = !matches;
|
|
56
|
+
if (matches) count++;
|
|
57
|
+
else card.querySelector('details').open = false;
|
|
58
|
+
});
|
|
59
|
+
document.querySelectorAll('[data-group-heading]').forEach(function (heading) { heading.hidden = !cards.some(function (card) { return !card.hidden && card.dataset.group === heading.dataset.groupHeading; }); });
|
|
60
|
+
buttons.forEach(function (button) { button.setAttribute('aria-pressed', String(button.dataset.kind === kind)); });
|
|
61
|
+
document.getElementById('resource-count').textContent = 'Showing ' + count + ' of ' + cards.length + ' resource' + (cards.length === 1 ? '' : 's');
|
|
62
|
+
document.getElementById('resource-empty').hidden = count !== 0;
|
|
63
|
+
}
|
|
64
|
+
buttons.forEach(function (button) { button.addEventListener('click', function () { kind = button.dataset.kind; filter(); }); });
|
|
65
|
+
search.addEventListener('input', filter);
|
|
66
|
+
document.getElementById('resource-reset').addEventListener('click', function () { kind = 'all'; search.value = ''; filter(); search.focus(); });
|
|
67
|
+
})();
|
|
68
|
+
document.querySelectorAll('.action-menu').forEach(function (menu) {
|
|
69
|
+
menu.addEventListener('toggle', function () {
|
|
70
|
+
if (menu.open) document.querySelectorAll('.action-menu').forEach(function (other) { if (other !== menu) other.open = false; });
|
|
71
|
+
});
|
|
72
|
+
menu.addEventListener('keydown', function (event) { if (event.key === 'Escape') { menu.open = false; menu.querySelector('summary').focus(); } });
|
|
73
|
+
document.addEventListener('click', function (event) { if (!menu.contains(event.target)) menu.open = false; });
|
|
74
|
+
});
|
|
75
|
+
</script>
|
|
76
|
+
{% endblock %}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Settings{% endblock %}{% block body %}
|
|
2
|
+
<h1>Settings</h1>
|
|
3
|
+
<div class="card"><h2 style="margin-top:0">General</h2>
|
|
4
|
+
<form method="post" action="/ui/settings/general">
|
|
5
|
+
<label>Public URL (used in notification links and enrol commands)</label><input type="url" name="public_url" value="{{ general.public_url }}" placeholder="http://keywright.lan:8700">
|
|
6
|
+
<label>Outbound IP of this Keywright (optional; adds <code>from=</code> to keys enrolled with the paste-able script)</label><input type="text" name="outbound_ip" value="{{ general.outbound_ip or '' }}" placeholder="10.0.0.2">
|
|
7
|
+
<div style="margin-top:10px"><button class="btn p">Save</button></div></form></div>
|
|
8
|
+
|
|
9
|
+
<div class="card"><h2 style="margin-top:0">Jev second opinion</h2>
|
|
10
|
+
<div class="help">Jev (TypeSafe) reads each operation that is about to ask you and answers what it does and whether the agent's stated reason fits. Commands, paths, tool arguments and the reason are sent to TypeSafe; resource names are not, and values that look like credentials stop the call. Nothing is sent for operations already allowed, denied or refused.</div>
|
|
11
|
+
{% if not jev_configured %}<div class="hazard medium" style="margin-top:8px"><b>No key.</b> Set <code>KW_TYPESAFE_API_KEY</code> in the broker's environment to enable this.</div>{% endif %}
|
|
12
|
+
<form method="post" action="/ui/settings/semantic" style="margin-top:8px">
|
|
13
|
+
<label class="inline"><input type="radio" name="mode" value="off" {{ 'checked' if jev.mode == 'off' }} {{ 'disabled' if not jev_configured }}> Off</label>
|
|
14
|
+
<label class="inline"><input type="radio" name="mode" value="shadow" {{ 'checked' if jev.mode == 'shadow' }} {{ 'disabled' if not jev_configured }}> Shadow: record the verdict and show it to me; I decide everything</label>
|
|
15
|
+
<label class="inline mut"><input type="radio" name="mode" value="trust" {{ 'checked' if jev.mode == 'trust' }} {{ '' if jev_trust_selectable and jev_configured else 'disabled' }}> Trust: a clean verdict on an unflagged, exactly-bound read with a reason that fits is approved once in Jev's name; everything else asks me{% if not jev_trust_selectable %} <span class="small">(disabled until shadow data earns it)</span>{% endif %}</label>
|
|
16
|
+
<div class="row" style="margin-top:8px"><div><label>Timeout (seconds)</label><input type="number" name="timeout_s" min="1" max="30" step="0.5" value="{{ jev.timeout_s }}"></div>
|
|
17
|
+
<div><label>Daily call cap</label><input type="number" name="daily_cap" min="0" max="100000" value="{{ jev.daily_cap }}"></div></div>
|
|
18
|
+
<div class="help">Past the timeout or the cap the request goes to you without a verdict. Under trust, no verdict is never an approval.</div>
|
|
19
|
+
<div style="margin-top:10px"><button class="btn p" {{ 'disabled' if not jev_configured }}>Save</button></div></form>
|
|
20
|
+
{% if jev_tally.verdicts %}<div class="help" style="margin-top:10px"><b>Shadow so far:</b> {{ jev_tally.verdicts }} verdicts ({{ jev_tally.no_verdict }} without an answer), {{ jev_tally.safe }} judged safe. Would have auto-approved {{ jev_tally.would_auto }}: you approved {{ jev_tally.would_auto_approved }} and denied <b>{{ jev_tally.would_auto_denied }}</b> of those. Judged unsafe but approved by you: {{ jev_tally.unsafe_approved }}.</div>{% endif %}
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="card"><h2 style="margin-top:0">Notifications</h2>
|
|
24
|
+
<div class="small mut">Active: <b>{{ notifier.driver_id }}</b>{% if notifier.last_error %} · <span style="color:var(--bad)">{{ notifier.last_error }}</span>{% endif %}</div>
|
|
25
|
+
<form method="post" action="/ui/settings/notifications">
|
|
26
|
+
<label>Driver</label>
|
|
27
|
+
<select name="driver" onchange="this.form.action='/ui/settings/notifications/probe';this.form.submit()">
|
|
28
|
+
{% for id, cls in drivers.items() %}<option value="{{ id }}" {{ 'selected' if id == notif.driver }}>{{ cls.label }}</option>{% endfor %}</select>
|
|
29
|
+
<div class="help">{{ driver.description }}</div>
|
|
30
|
+
{% for f in driver.fields %}
|
|
31
|
+
<label>{{ f.label }}{% if f.required %} *{% endif %}</label>
|
|
32
|
+
{% set val = notif.config.get(f.name) %}
|
|
33
|
+
{% if f.type == 'multiselect' %}
|
|
34
|
+
{% set opts = f.options or (notif.choices or {}).get(f.options_from_probe) or [] %}
|
|
35
|
+
{% if not opts %}<div class="help">No options yet: fill the fields above and press <b>Probe</b>.</div>{% endif %}
|
|
36
|
+
{% for o in opts %}<div><label class="inline"><input type="checkbox" name="{{ f.name }}" value="{{ o[0] }}" {{ 'checked' if val and o[0] in val }}> {{ o[1] }} <span class="mono mut small">{{ o[0] }}</span></label></div>{% endfor %}
|
|
37
|
+
{% elif f.type == 'select' %}<select name="{{ f.name }}">{% for o in f.options %}<option value="{{ o[0] }}" {{ 'selected' if val == o[0] }}>{{ o[1] }}</option>{% endfor %}</select>
|
|
38
|
+
{% elif f.type == 'checkbox' %}<label class="inline"><input type="checkbox" name="{{ f.name }}" {{ 'checked' if val }}> {{ f.help }}</label>
|
|
39
|
+
{% elif f.type == 'textarea' %}<textarea name="{{ f.name }}">{{ val or '' }}</textarea>
|
|
40
|
+
{% else %}<input type="{{ 'password' if f.type == 'password' else ('url' if f.type == 'url' else 'text') }}" name="{{ f.name }}" value="{{ ('***' if val else '') if f.type == 'password' else (val or '') }}" {{ 'required' if f.required }}>
|
|
41
|
+
{% endif %}
|
|
42
|
+
{% if f.help and f.type != 'checkbox' %}<div class="help">{{ f.help }}</div>{% endif %}
|
|
43
|
+
{% endfor %}
|
|
44
|
+
<label>Buttons on the notification (max {{ driver.max_actions }} for {{ driver.label }}; Always is UI-only on purpose)</label>
|
|
45
|
+
{% for b in ['once','session','ttl15','path15','ttl30','ttl','deny'] %}<label class="inline" style="margin-right:14px"><input type="checkbox" name="buttons" value="{{ b }}" {{ 'checked' if b in (notif.buttons or default_buttons) }}> {{ scope_labels[b] }}</label>{% endfor %}
|
|
46
|
+
<div class="row" style="margin-top:12px">
|
|
47
|
+
<button class="btn p">Save</button>
|
|
48
|
+
<button class="btn" formaction="/ui/settings/notifications/probe">Probe</button>
|
|
49
|
+
</div>
|
|
50
|
+
</form>
|
|
51
|
+
<form method="post" action="/ui/settings/notifications/test" style="margin-top:8px"><button class="btn sm">Send a test request</button> <span class="small mut">creates a real pending request (10 min) in the first agent's name</span></form>
|
|
52
|
+
</div>
|
|
53
|
+
{% endblock %}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{% extends "base.html" %}{% set hide_nav = True %}{% block title %}Keywright setup{% endblock %}{% block body %}
|
|
2
|
+
<div class="card auth-card"><h1 class="brand">{{ icon('shield') }}Set up Keywright</h1>
|
|
3
|
+
<p class="small">Set the admin password for this Keywright. Then add resources, create an agent, and pick a notification driver.</p>
|
|
4
|
+
<form method="post" action="/ui/setup">
|
|
5
|
+
<label>Password (8+ chars)</label><input type="password" name="password" required autofocus>
|
|
6
|
+
<label>Repeat</label><input type="password" name="repeat" required>
|
|
7
|
+
<div style="margin-top:12px"><button class="btn p">Set password</button></div></form></div>
|
|
8
|
+
{% endblock %}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}LLM usage · Keywright{% endblock %}
|
|
3
|
+
{% macro number(value) %}{{ '{:,}'.format(value) }}{% endmacro %}
|
|
4
|
+
{% macro counts(t) %}
|
|
5
|
+
<td data-label="Requests">{{ number(t.requests) }}</td>
|
|
6
|
+
<td data-label="Input">{{ number(t.input) }}</td>
|
|
7
|
+
<td data-label="Output">{{ number(t.output) }}</td>
|
|
8
|
+
<td data-label="Total tokens">{{ number(t.total) }}</td>
|
|
9
|
+
<td data-label="Coverage">{{ number(t.reported) }}/{{ number(t.requests) }} reported{% if t.partial %}<div class="help">{{ t.partial }} partial</div>{% endif %}</td>
|
|
10
|
+
<td data-label="Errors">{{ number(t.errors) }}</td>
|
|
11
|
+
{% endmacro %}
|
|
12
|
+
{% macro headings(first) %}<thead><tr><th>{{ first }}</th><th>Requests</th><th>Input</th><th>Output</th><th>Total tokens</th><th>Coverage</th><th>Errors</th></tr></thead>{% endmacro %}
|
|
13
|
+
{% block body %}
|
|
14
|
+
<h1>LLM usage</h1>
|
|
15
|
+
<p class="page-intro">Track model activity across providers and see which agents and gateway keys drive it.</p>
|
|
16
|
+
<form method="get" action="/ui/usage" class="card">
|
|
17
|
+
<div class="grid">
|
|
18
|
+
<div><label for="usage-start">From (UTC)</label><input id="usage-start" type="date" name="start" value="{{ filters.start }}" required></div>
|
|
19
|
+
<div><label for="usage-end">Through (UTC)</label><input id="usage-end" type="date" name="end" value="{{ filters.end }}" required></div>
|
|
20
|
+
{% for dimension, label in [('provider', 'Provider / gateway'), ('model', 'Model'), ('agent', 'Agent'), ('key', 'Gateway key')] %}
|
|
21
|
+
<div><label for="usage-{{ dimension }}">{{ label }}</label><select id="usage-{{ dimension }}" name="{{ dimension }}">
|
|
22
|
+
<option value="">All {{ {'provider': 'providers / gateways', 'model': 'models', 'agent': 'agents', 'key': 'gateway keys'}[dimension] }}</option>
|
|
23
|
+
{% for value, name in options[dimension] %}<option value="{{ value }}" {{ 'selected' if filters[dimension] == value }}>{{ name }}</option>{% endfor %}
|
|
24
|
+
{% if filters[dimension] and filters[dimension] not in dict(options[dimension]) %}<option value="{{ filters[dimension] }}" selected>Selected {{ label | lower }} (no usage in this period)</option>{% endif %}
|
|
25
|
+
</select></div>
|
|
26
|
+
{% endfor %}
|
|
27
|
+
</div>
|
|
28
|
+
<div class="row"><button class="btn p">Apply filters</button><a class="btn" href="/ui/usage">Reset</a><span class="mut">Dates are inclusive. Ranked by reported tokens, then requests.</span></div>
|
|
29
|
+
</form>
|
|
30
|
+
<div class="grid stats">
|
|
31
|
+
<div class="card"><div class="mut">Requests</div><div class="stat">{{ number(totals.requests) }}</div><div class="help">{{ totals.errors }} errors</div></div>
|
|
32
|
+
<div class="card"><div class="mut">Reported tokens</div><div class="stat">{{ number(totals.total) }}</div><div class="help">{{ number(totals.input) }} input · {{ number(totals.output) }} output</div></div>
|
|
33
|
+
<div class="card"><div class="mut">Provider / gateways</div><div class="stat">{{ providers | length }}</div><div class="help">{{ models | length }} model names</div></div>
|
|
34
|
+
<div class="card"><div class="mut">Requests with usage</div><div class="stat">{{ totals.reported }} / {{ totals.requests }}</div><div class="help">{{ totals.unreported }} unreported · {{ totals.partial }} partial</div></div>
|
|
35
|
+
</div>
|
|
36
|
+
<p class="help">Usage observed by the Keywright proxy, including failed upstream attempts. Denied requests and model listings are excluded. Missing usage is unknown, not zero; totals include only reported counts. Input includes cache reads and writes. Older streams, compressed responses, and streams without usage may be unreported. Direct provider calls and provider billing costs are not included.</p>
|
|
37
|
+
{% if totals.requests %}
|
|
38
|
+
<h2>Providers → models</h2>
|
|
39
|
+
<p class="mut">Each provider is a configured gateway. The same model stays separate under each gateway.</p>
|
|
40
|
+
{% for p in providers %}
|
|
41
|
+
<details class="card usage-provider" {{ 'open' if filters.provider or providers | length == 1 }}>
|
|
42
|
+
<summary><strong>{{ p.name }}</strong><span>{{ number(p.totals.total) }} tokens · {{ number(p.totals.requests) }} requests</span></summary>
|
|
43
|
+
<p><a href="{{ usage_url(provider=p.id, key='') }}">Focus on {{ p.name }}</a></p>
|
|
44
|
+
<table class="data-table">{{ headings('Model') }}<tbody>
|
|
45
|
+
{% for m in p.models.values() | sort(attribute='totals.total', reverse=true) %}
|
|
46
|
+
<tr><td data-label="Model" class="wide"><a href="{{ usage_url(provider=p.id, model=m.name, key='') }}">{{ m.name }}</a></td>{{ counts(m.totals) }}</tr>
|
|
47
|
+
{% endfor %}
|
|
48
|
+
</tbody></table>
|
|
49
|
+
</details>
|
|
50
|
+
{% endfor %}
|
|
51
|
+
<h2>Models across selected providers</h2>
|
|
52
|
+
<div class="card"><table class="data-table">{{ headings('Model') }}<tbody>
|
|
53
|
+
{% for name, t in models %}<tr><td data-label="Model" class="wide"><a href="{{ usage_url(model=name, key='') }}">{{ name }}</a></td>{{ counts(t) }}</tr>{% endfor %}
|
|
54
|
+
</tbody></table></div>
|
|
55
|
+
<h2>Keys driving usage</h2>
|
|
56
|
+
<div class="card"><table class="data-table">{{ headings('Gateway key / agent') }}<tbody>
|
|
57
|
+
{% for k in keys %}<tr><td data-label="Gateway key / agent" class="wide">
|
|
58
|
+
<a href="{{ usage_url(key=k.id) }}">{{ k.alias }}</a><div>{{ k.agent }} · {{ k.provider }}</div>
|
|
59
|
+
<div class="help">{{ k.models | sort | join(', ') }}</div>{% if not k.key_id.startswith('legacy:') %}<div class="help">{{ k.key_id }}</div>{% endif %}
|
|
60
|
+
</td>{{ counts(k.totals) }}</tr>{% endfor %}
|
|
61
|
+
</tbody></table></div>
|
|
62
|
+
<h2>Daily usage (UTC)</h2>
|
|
63
|
+
<div class="card"><table class="data-table">{{ headings('Date') }}<tbody>
|
|
64
|
+
{% for day, t in days | reverse %}<tr><td data-label="Date"><a href="{{ usage_url(start=day, end=day) }}">{{ day }}</a></td>{{ counts(t) }}</tr>{% endfor %}
|
|
65
|
+
</tbody></table></div>
|
|
66
|
+
<details class="card"><summary>Recent requests · latest {{ recent | length }} matching calls</summary>
|
|
67
|
+
<table class="data-table"><thead><tr><th>Time (UTC)</th><th>Provider / model</th><th>Agent / key</th><th>Tokens</th><th>Result</th></tr></thead><tbody>
|
|
68
|
+
{% for event in recent %}<tr>
|
|
69
|
+
<td data-label="Time (UTC)"><a href="/ui/audit?q=%23{{ event.id }}">{{ event.ts.strftime('%Y-%m-%d %H:%M:%S') }}</a></td>
|
|
70
|
+
<td data-label="Provider / model">{{ event.provider }}<div>{{ event.model }}</div></td>
|
|
71
|
+
<td data-label="Agent / key">{{ event.agent }}<div class="help">{{ event.alias }}</div></td>
|
|
72
|
+
<td data-label="Tokens">{{ number(event.total) if event.total is not none else 'Unreported' }}</td>
|
|
73
|
+
<td data-label="Result"><span class="pill {{ event.decision }}">{{ event.decision }}</span></td>
|
|
74
|
+
</tr>{% endfor %}</tbody></table></details>
|
|
75
|
+
{% else %}
|
|
76
|
+
<div class="card"><h2>No usage in this selection</h2><p>Try a wider date range or clear the filters. Model calls through Keywright will appear here after their responses finish.</p></div>
|
|
77
|
+
{% endif %}
|
|
78
|
+
<style>
|
|
79
|
+
.usage-provider>summary{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px;cursor:pointer;min-height:36px;align-items:center}
|
|
80
|
+
.usage-provider>summary strong::before{content:'▸';display:inline-block;margin-right:8px}.usage-provider[open]>summary strong::before{content:'▾'}
|
|
81
|
+
input[type=date]{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:var(--r);background:var(--bg);color:var(--fg);font:inherit}
|
|
82
|
+
@media(max-width:900px){input[type=date]{font-size:16px}}
|
|
83
|
+
</style>
|
|
84
|
+
{% endblock %}
|