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,198 @@
|
|
|
1
|
+
{% from "_icons.html" import icon %}
|
|
2
|
+
<!doctype html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<title>{% block title %}Keywright{% endblock %}</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root{--bg:#101419;--panel:#171c23;--panel-raised:#1d242d;--line:#303945;--fg:#e3e8ef;--mut:#a0acbb;--acc:#8ab4ef;--ok:#70c6a0;--warn:#e4b866;--bad:#ef9191;--r:4px;--button-bg:#315f99;--button-fg:#fff}
|
|
10
|
+
@media (prefers-color-scheme: light){:root{--bg:#f3f5f7;--panel:#fff;--panel-raised:#edf1f5;--line:#ccd3dc;--fg:#202833;--mut:#596777;--acc:#245d9f;--ok:#23734e;--warn:#856009;--bad:#b52f3b;--button-bg:#245d9f;--button-fg:#fff}}
|
|
11
|
+
*{box-sizing:border-box}html{color-scheme:dark light}body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
|
|
12
|
+
a{color:var(--acc);text-decoration:none}a:hover{text-decoration:underline}
|
|
13
|
+
.site-header{background:var(--panel);border-bottom:1px solid var(--line)}
|
|
14
|
+
nav{display:flex;max-width:1280px;margin:auto;flex-wrap:wrap;gap:4px 12px;align-items:center;padding:0 24px;min-height:54px;background:var(--panel)}
|
|
15
|
+
.brand{display:inline-flex;align-items:center;gap:9px;white-space:nowrap;font-weight:650;letter-spacing:-.02em}.brand .ui-icon{width:20px;height:20px;color:var(--acc)}nav .brand{font-size:15px;margin-right:12px;padding:0;border:0;color:var(--fg)}nav a{padding:17px 8px;color:var(--mut);border-bottom:2px solid transparent}nav a:hover{color:var(--fg);text-decoration:none}nav a.active{color:var(--fg);border-bottom-color:var(--acc)}
|
|
16
|
+
.nav-links{display:flex;flex-wrap:wrap;gap:2px;min-width:0}
|
|
17
|
+
nav form{margin-left:auto}
|
|
18
|
+
main{max-width:1280px;margin:0 auto;padding:24px;overflow-wrap:anywhere;min-width:0}
|
|
19
|
+
h1{font-size:22px;font-weight:600;letter-spacing:-.025em;line-height:1.3;margin:0 0 12px}h2{font-size:15px;font-weight:600;margin:20px 0 8px}
|
|
20
|
+
.card{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:10px 12px;margin-bottom:12px;min-width:0}
|
|
21
|
+
table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere}
|
|
22
|
+
th{color:var(--mut);font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.03em}
|
|
23
|
+
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}
|
|
24
|
+
code{background:rgba(127,127,127,.12);padding:1px 5px;border-radius:4px;overflow-wrap:anywhere}
|
|
25
|
+
pre{background:rgba(127,127,127,.12);padding:10px;border-radius:var(--r);overflow:auto;font-size:13px;white-space:pre-wrap;overflow-wrap:anywhere;max-width:100%;line-height:1.6}
|
|
26
|
+
.mut{color:var(--mut)}.small{font-size:13px}
|
|
27
|
+
.btn{display:inline-flex;overflow-wrap:normal;justify-content:center;align-items:center;min-height:32px;text-align:center;border:1px solid var(--line);background:var(--panel);color:var(--fg);padding:5px 10px;border-radius:var(--r);cursor:pointer;font:inherit;font-size:13px}
|
|
28
|
+
.btn:hover{border-color:var(--acc);text-decoration:none;background:var(--panel-raised)}.btn.p{background:var(--button-bg);border-color:var(--button-bg);color:var(--button-fg)}.btn.p:hover{filter:brightness(1.12)}
|
|
29
|
+
.btn.ok{background:transparent;border-color:var(--ok);color:var(--ok)}.btn.ok:hover{background:rgba(112,198,160,.1)}
|
|
30
|
+
.btn.bad{background:transparent;border-color:var(--bad);color:var(--bad)}.btn.bad:hover{background:rgba(239,145,145,.1)}.btn:disabled{opacity:.5;cursor:not-allowed}.btn.sm{min-height:28px;padding:3px 8px;font-size:12px}
|
|
31
|
+
.row>*{min-width:0;max-width:100%}
|
|
32
|
+
.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
|
33
|
+
.pill{display:inline-block;white-space:nowrap;padding:1px 6px;border-radius:3px;font-size:12px;border:1px solid var(--line)}
|
|
34
|
+
.pill.open,.pill.allow,.pill.approved,.pill.ok{border-color:var(--ok);color:var(--ok)}
|
|
35
|
+
.pill.approval,.pill.pending{border-color:var(--warn);color:var(--warn)}
|
|
36
|
+
.pill.locked,.pill.deny,.pill.denied,.pill.error,.pill.revoked,.pill.refused,.pill.critical{border-color:var(--bad);color:var(--bad)}
|
|
37
|
+
.pill.high{border-color:var(--bad);color:var(--bad);background:rgba(255,107,107,.12)}.pill.medium{border-color:var(--warn);color:var(--warn)}
|
|
38
|
+
.semantic{border:1px solid var(--mut);border-radius:var(--r);padding:8px 12px;margin:8px 0}.semantic.safe{border-color:var(--ok)}.semantic.unsafe{border-color:var(--bad);background:rgba(255,107,107,.1)}.semantic.unclear{border-color:var(--warn)}.semantic ul{margin:4px 0 0 0;padding-left:0;list-style:none}.semantic li{margin:3px 0}
|
|
39
|
+
.hazard{border:1px solid var(--warn);background:rgba(247,185,40,.1);border-radius:var(--r);padding:8px 12px;margin:8px 0}.hazard.high,.hazard.critical{border-color:var(--bad);background:rgba(255,107,107,.1)}.hazard ul{margin:4px 0 0 0;padding-left:0;list-style:none}.hazard li{margin:3px 0}
|
|
40
|
+
.flash{background:rgba(90,169,255,.12);border:1px solid var(--acc);padding:10px 12px;border-radius:var(--r);margin-bottom:12px}
|
|
41
|
+
.err{background:rgba(255,107,107,.12);border:1px solid var(--bad);padding:10px 12px;border-radius:var(--r);margin-bottom:12px}
|
|
42
|
+
label{display:block;font-size:13px;color:var(--mut);margin:10px 0 4px}
|
|
43
|
+
input[type=text],input[type=password],input[type=url],input[type=number],select,textarea{width:100%;padding:6px 8px;border:1px solid var(--line);border-radius:var(--r);background:var(--bg);color:var(--fg);font:inherit}
|
|
44
|
+
textarea{min-height:90px;font-family:ui-monospace,monospace;font-size:13px}
|
|
45
|
+
.inline{display:inline}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:10px}
|
|
46
|
+
.stat{font-size:24px;line-height:1.2;font-weight:600;font-variant-numeric:tabular-nums}.help{font-size:12px;color:var(--mut);margin-top:3px}
|
|
47
|
+
|
|
48
|
+
/* Waiting on you: request tiles and attention cards. A tile is a summary you can act on; the
|
|
49
|
+
detail behind it is one tap away and never gets in the way of a queue you are clearing. */
|
|
50
|
+
.tile{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);margin-bottom:10px;overflow:hidden}
|
|
51
|
+
.req-tile.flagged{border-color:var(--bad)}
|
|
52
|
+
.tile-open{display:block}.tile-open>summary{list-style:none;cursor:pointer;padding:10px 12px}
|
|
53
|
+
.tile-open>summary::-webkit-details-marker{display:none}
|
|
54
|
+
.tile-open>summary:hover{background:rgba(127,127,127,.05)}
|
|
55
|
+
.tile-head{display:flex;flex-wrap:wrap;gap:6px 8px;align-items:baseline}
|
|
56
|
+
.tile-head>b{min-width:0;overflow-wrap:anywhere}
|
|
57
|
+
.tile-dot{width:8px;height:8px;border-radius:50%;background:var(--warn);flex:0 0 auto;align-self:center}
|
|
58
|
+
.req-tile.flagged .tile-dot{background:var(--bad)}
|
|
59
|
+
/* Times and chevron travel as one unit, so a narrow tile wraps them onto a single row with the
|
|
60
|
+
chevron still at the edge where a thumb expects it, instead of stranding it on a line of its own. */
|
|
61
|
+
.tile-meta{margin-left:auto;display:flex;gap:8px;align-items:center;white-space:nowrap}
|
|
62
|
+
.tile-chevron{transition:transform .15s ease;color:var(--mut);align-self:center}
|
|
63
|
+
.tile-open[open]>summary .tile-chevron{transform:rotate(180deg)}
|
|
64
|
+
.tile-line{margin-top:7px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;line-height:1.45;overflow-wrap:anywhere;max-height:9em;overflow:auto}
|
|
65
|
+
.tile-cont{margin-top:2px;padding-left:12px;color:var(--mut)}
|
|
66
|
+
.tile-reason{margin-top:4px;color:var(--mut)}
|
|
67
|
+
.tile-body{padding:0 14px 12px;border-top:1px solid var(--line);margin-top:2px}
|
|
68
|
+
.tile-field{margin:12px 0}.tile-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--mut);margin-bottom:4px}
|
|
69
|
+
.tile-quick{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 14px;border-top:1px solid var(--line);background:rgba(127,127,127,.04)}
|
|
70
|
+
.tile-quick .small{flex:1 1 140px;min-width:0}
|
|
71
|
+
.tile-open[open]~.tile-quick{display:none}
|
|
72
|
+
.nudge-tile{border-left:3px solid var(--acc)}.nudge-tile>.tile-head{padding:10px 12px 0}
|
|
73
|
+
.nudge-message{padding:6px 14px 10px;overflow-wrap:anywhere}
|
|
74
|
+
/* A tile arriving by itself should be noticed, and one that has just been decided should say so
|
|
75
|
+
before it goes, so a decision made on the phone reads as an answer and not as a disappearance. */
|
|
76
|
+
@keyframes tile-in{from{opacity:0;transform:translateY(-6px)}to{opacity:1;transform:none}}
|
|
77
|
+
.tile-new{animation:tile-in .25s ease-out}
|
|
78
|
+
.tile-done>.tile-open{opacity:.5;transition:opacity .4s ease}
|
|
79
|
+
.tile-done .tile-quick,.tile-done .decision-actions{display:none}
|
|
80
|
+
.tile-resolved{padding:10px 14px;border-top:1px solid var(--line);font-size:13px}
|
|
81
|
+
.tile-approved .tile-resolved{color:var(--ok)}.tile-denied .tile-resolved,.tile-expired .tile-resolved{color:var(--mut)}
|
|
82
|
+
.live-state{font-size:11px;font-weight:400;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);vertical-align:middle}
|
|
83
|
+
.live-state::before{content:'';display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--mut);margin-right:5px;vertical-align:baseline}
|
|
84
|
+
.live-state.on::before{background:var(--ok)}.live-state.off::before{background:var(--warn)}
|
|
85
|
+
@media(prefers-reduced-motion:reduce){.tile-new{animation:none}.tile-chevron{transition:none}}
|
|
86
|
+
|
|
87
|
+
.enrollment-option summary{cursor:pointer;font-weight:600;min-height:44px}.enrollment-option{align-self:start}
|
|
88
|
+
.grid>*{min-width:0}.grid>.card{margin-bottom:0}.grid{margin-bottom:14px}
|
|
89
|
+
.stats{grid-template-columns:repeat(4,minmax(0,1fr));gap:0;border:1px solid var(--line);border-radius:var(--r);background:var(--panel)}.stats .card{border:0;border-radius:0;background:transparent}.stats .card+.card{border-left:1px solid var(--line)}.stats .card{padding:10px 12px}.stats .stat{margin-top:4px}
|
|
90
|
+
.page-intro{margin:-4px 0 16px;color:var(--mut)}
|
|
91
|
+
.toolbar{margin-bottom:14px;align-items:end}.toolbar input{flex:1 1 200px}.toolbar .field{flex:1 1 220px}.toolbar label{margin-top:0}
|
|
92
|
+
.data-table th{white-space:nowrap}
|
|
93
|
+
.data-table td[data-label="Time"],.data-table td[data-label="When"],.data-table td[data-label="Created"],.data-table td[data-label="Last seen"]{white-space:nowrap}
|
|
94
|
+
.cell-value{min-width:0}.cell-value .row{gap:6px}.data-table tbody tr:last-child td{border-bottom:0}
|
|
95
|
+
.detail-table{table-layout:fixed}.detail-table th{width:100px}.detail-table pre{margin:0}
|
|
96
|
+
.connection-guide summary{font-weight:600;cursor:pointer;padding:10px 0;min-height:44px}.connection-guide details+details{border-top:1px solid var(--line)}.connection-guide h3{font-size:15px;margin:18px 0 6px}
|
|
97
|
+
.connection-guide pre{margin:8px 0}.connection-guide p{margin:8px 0}
|
|
98
|
+
.token{border-color:var(--ok)}.token pre{user-select:all}
|
|
99
|
+
.decision-actions{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
|
|
100
|
+
.decision-scope{grid-column:1/-1}.decision-scope label:first-child{margin-top:0}
|
|
101
|
+
.access-heading{justify-content:space-between;margin-bottom:12px}.access-target{border-top:1px solid var(--line);padding:12px 0}
|
|
102
|
+
.access-target:last-child{padding-bottom:0}.access-lease{border-left:3px solid var(--acc);padding:10px 12px;margin:12px 0;background:rgba(90,169,255,.04)}
|
|
103
|
+
.access-lease pre{margin:8px 0}.access-detail summary{cursor:pointer;min-height:36px;padding:6px 0}.access-source{margin:8px 0}
|
|
104
|
+
.decision-deny{grid-column:1/-1;display:flex;gap:8px}.decision-deny input{flex:1;min-width:0}
|
|
105
|
+
label.inline{display:inline-block;min-height:40px;padding:8px 0;max-width:100%}label.inline input{vertical-align:middle;margin-right:5px}
|
|
106
|
+
input,select,textarea{min-width:0;max-width:100%}input[type=checkbox]{flex-shrink:0}
|
|
107
|
+
:focus-visible{outline:2px solid var(--acc);outline-offset:3px}
|
|
108
|
+
.skip-link{position:absolute;left:16px;top:-100px;background:var(--panel);padding:12px;z-index:10}.skip-link:focus{top:8px}
|
|
109
|
+
.mobile-nav{display:none}.mobile-more{position:relative}.mobile-more summary{list-style:none;cursor:pointer;text-align:center;font-size:11px;line-height:1.15;padding:8px 2px;min-height:42px;color:var(--mut);border-radius:3px}.mobile-more summary::-webkit-details-marker{display:none}.mobile-more summary .ui-icon{margin:0 auto 3px;width:18px;height:18px}.mobile-more[open] summary,.mobile-more.current summary{color:var(--acc);background:rgba(80,169,255,.12)}.mobile-more-menu{position:absolute;right:0;bottom:52px;width:210px;padding:6px;background:var(--panel);border:1px solid var(--line);border-radius:var(--r);box-shadow:0 10px 30px rgba(0,0,0,.35)}.mobile-more-menu a,.mobile-more-menu button{display:block;width:100%;padding:10px 11px;text-align:left;color:var(--fg);background:transparent;border:0;border-radius:var(--r);font:inherit;font-size:13px}.mobile-more-menu a:hover,.mobile-more-menu button:hover{background:rgba(80,169,255,.12);text-decoration:none}.mobile-more-menu form{margin:4px 0 0;border-top:1px solid var(--line);padding-top:4px}
|
|
110
|
+
@media(max-width:900px){
|
|
111
|
+
body{padding-bottom:68px} .site-header{position:sticky;top:0;z-index:5}
|
|
112
|
+
nav{gap:0;padding:8px 14px;min-height:48px}.brand{font-size:15px}
|
|
113
|
+
nav form{margin-left:auto}nav > form{display:none}
|
|
114
|
+
.nav-links{display:none}.mobile-nav{position:fixed;z-index:10;bottom:0;left:0;right:0;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:0;padding:6px 4px max(6px,env(safe-area-inset-bottom));background:var(--panel);border-top:1px solid var(--line)}
|
|
115
|
+
.mobile-nav>a{text-align:center;font-size:11px;line-height:1.15;padding:8px 2px;min-height:42px;color:var(--mut);border-radius:3px}.mobile-nav>a .ui-icon{width:18px;height:18px;margin:0 auto 3px}
|
|
116
|
+
|
|
117
|
+
.mobile-nav a.active{color:var(--acc);background:rgba(80,169,255,.12)}
|
|
118
|
+
main{padding:16px 12px 12px}h1{font-size:20px;line-height:1.2;margin:4px 0 4px}.page-intro{font-size:13px;line-height:1.35;margin:0 0 14px}
|
|
119
|
+
h2{font-size:15px;margin:17px 0 8px}.card{padding:12px;margin-bottom:10px}.grid{gap:8px;margin-bottom:10px}
|
|
120
|
+
.stats{grid-template-columns:repeat(2,minmax(0,1fr));gap:0}.stats .card{padding:10px}.stats .stat{font-size:22px;line-height:1.05;margin-top:3px}.stats .mut{font-size:12px}.stats .small{font-size:12px}.stats .card:nth-child(3){border-left:0}.stats .card:nth-child(n+3){border-top:1px solid var(--line)}
|
|
121
|
+
.btn,.btn.sm{min-height:40px}.toolbar{align-items:end}.toolbar .field{flex-basis:100%}
|
|
122
|
+
.access-heading{margin-bottom:7px}.access-lease{padding:9px 10px;margin:9px 0}.access-target{padding:10px 0}.access-target .row{gap:5px}
|
|
123
|
+
.tile-open>summary{padding:11px 12px}.tile-body{padding:0 12px 11px}.tile-quick{padding:9px 12px;gap:6px}
|
|
124
|
+
.tile-meta{flex-basis:100%;margin-left:0;justify-content:space-between}.tile-quick .small{flex-basis:100%}
|
|
125
|
+
.decision-actions{gap:6px}.decision-actions .help{font-size:11px}.decision-actions .decision-scope label{margin-top:7px}
|
|
126
|
+
input[type=text],input[type=password],input[type=url],input[type=number],select,textarea{font-size:16px}
|
|
127
|
+
/* Keep table semantics in markup while reflowing each record on small screens. */
|
|
128
|
+
.data-table,.data-table tbody{display:block;width:100%}
|
|
129
|
+
.data-table thead{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
|
130
|
+
.data-table tbody tr{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px 16px;padding:14px 0;border-bottom:1px solid var(--line)}
|
|
131
|
+
.data-table tbody tr:first-child{padding-top:0}.data-table tbody tr:last-child{border-bottom:0;padding-bottom:0}
|
|
132
|
+
.data-table td{display:block;min-width:0;padding:0;border:0;font-size:14px}
|
|
133
|
+
.data-table td::before{content:attr(data-label);display:block;color:var(--mut);font:600 11px/1.5 system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;margin-bottom:3px}
|
|
134
|
+
.data-table td.wide{grid-column:1/-1}.data-table td.actions::before{display:none}
|
|
135
|
+
.detail-table,.detail-table tbody,.detail-table tr,.detail-table th,.detail-table td{display:block;width:100%}
|
|
136
|
+
.detail-table th{border:0;padding:10px 0 2px}.detail-table td{padding:0 0 10px}
|
|
137
|
+
.decision-actions{grid-template-columns:repeat(2,minmax(0,1fr))}
|
|
138
|
+
.host-fields{grid-template-columns:repeat(2,minmax(0,1fr))}.host-fields>div:first-child{grid-column:1/-1}
|
|
139
|
+
}
|
|
140
|
+
@media(max-width:420px){
|
|
141
|
+
main{padding-left:10px;padding-right:10px}.card{border-radius:var(--r)}.stats .card{padding:9px 10px}.stats .stat{font-size:21px}
|
|
142
|
+
.pending-request .row{align-items:flex-start}.pending-request .btn{padding-left:7px;padding-right:7px;font-size:13px}
|
|
143
|
+
}
|
|
144
|
+
.auth-card{max-width:380px;margin:48px auto;padding:24px}.auth-card h1{font-size:20px}.auth-card .btn{width:100%;margin-top:4px}
|
|
145
|
+
{% include "_collections_style.html" %}
|
|
146
|
+
{% include "_inventory_style.html" %}
|
|
147
|
+
</style>
|
|
148
|
+
</head>
|
|
149
|
+
<body>
|
|
150
|
+
{% if not hide_nav %}
|
|
151
|
+
<a class="skip-link" href="#main">Skip to content</a>
|
|
152
|
+
<header class="site-header"><nav aria-label="Main navigation">
|
|
153
|
+
<a class="brand" href="/ui" aria-label="Keywright dashboard">{{ icon('shield') }}<span>Keywright</span></a>
|
|
154
|
+
<div class="nav-links">
|
|
155
|
+
<a href="/ui" class="{{ 'active' if request.url.path == '/ui' }}">Dashboard</a>
|
|
156
|
+
<a href="/ui/requests" class="{{ 'active' if request.url.path.startswith('/ui/requests') }}">Requests</a>
|
|
157
|
+
<a href="/ui/grants" class="{{ 'active' if request.url.path.startswith('/ui/grants') }}">Grants</a>
|
|
158
|
+
<a href="/ui/policies" class="{{ 'active' if request.url.path.startswith('/ui/policies') }}">Rules</a>
|
|
159
|
+
<a href="/ui/access" class="{{ 'active' if request.url.path.startswith('/ui/access') }}">Access</a>
|
|
160
|
+
<a href="/ui/resources" class="{{ 'active' if (request.url.path.startswith('/ui/resources') or request.url.path.startswith('/ui/inventory')) }}">Resources</a>
|
|
161
|
+
<a href="/ui/agents" class="{{ 'active' if request.url.path.startswith('/ui/agents') }}">Agents</a>
|
|
162
|
+
<a href="/ui/knowledge" class="{{ 'active' if request.url.path.startswith('/ui/knowledge') }}">Knowledge</a>
|
|
163
|
+
<a href="/ui/mail" class="{{ 'active' if request.url.path.startswith('/ui/mail') }}">Mail</a>
|
|
164
|
+
<a href="/ui/usage" class="{{ 'active' if request.url.path.startswith('/ui/usage') }}">Usage</a>
|
|
165
|
+
<a href="/ui/audit" class="{{ 'active' if request.url.path.startswith('/ui/audit') }}">Audit</a>
|
|
166
|
+
<a href="/ui/settings" class="{{ 'active' if request.url.path.startswith('/ui/settings') }}">Settings</a>
|
|
167
|
+
</div>
|
|
168
|
+
<form method="post" action="/ui/logout"><button class="btn sm">Log out</button></form>
|
|
169
|
+
</nav></header>
|
|
170
|
+
<nav class="mobile-nav" aria-label="Mobile navigation">
|
|
171
|
+
<a href="/ui" class="{{ 'active' if request.url.path == '/ui' }}">{{ icon('dashboard') }}Dashboard</a>
|
|
172
|
+
<a href="/ui/resources" class="{{ 'active' if (request.url.path.startswith('/ui/resources') or request.url.path.startswith('/ui/inventory')) }}">{{ icon('ssh') }}Resources</a>
|
|
173
|
+
<a href="/ui/requests" class="{{ 'active' if request.url.path.startswith('/ui/requests') }}">{{ icon('requests') }}Requests</a>
|
|
174
|
+
<a href="/ui/audit" class="{{ 'active' if request.url.path.startswith('/ui/audit') }}">{{ icon('activity') }}Activity</a>
|
|
175
|
+
<details class="mobile-more{{ ' current' if request.url.path.startswith('/ui/grants') or request.url.path.startswith('/ui/policies') or request.url.path.startswith('/ui/access') or request.url.path.startswith('/ui/agents') or request.url.path.startswith('/ui/settings') or request.url.path.startswith('/ui/usage') or request.url.path.startswith('/ui/mail') or request.url.path.startswith('/ui/knowledge') }}">
|
|
176
|
+
<summary>{{ icon('more') }}More</summary>
|
|
177
|
+
<div class="mobile-more-menu">
|
|
178
|
+
<a href="/ui/knowledge" class="{{ 'active' if request.url.path.startswith('/ui/knowledge') }}">Knowledge</a>
|
|
179
|
+
<a href="/ui/mail" class="{{ 'active' if request.url.path.startswith('/ui/mail') }}">Mail</a>
|
|
180
|
+
<a href="/ui/usage" class="{{ 'active' if request.url.path.startswith('/ui/usage') }}">Usage</a>
|
|
181
|
+
<a href="/ui/grants" class="{{ 'active' if request.url.path.startswith('/ui/grants') }}">Grants</a>
|
|
182
|
+
<a href="/ui/policies" class="{{ 'active' if request.url.path.startswith('/ui/policies') }}">Rules</a>
|
|
183
|
+
<a href="/ui/access" class="{{ 'active' if request.url.path.startswith('/ui/access') }}">Access</a>
|
|
184
|
+
<a href="/ui/agents" class="{{ 'active' if request.url.path.startswith('/ui/agents') }}">Agents</a>
|
|
185
|
+
<a href="/ui/audit" class="{{ 'active' if request.url.path.startswith('/ui/audit') }}">Audit log</a>
|
|
186
|
+
<a href="/ui/settings" class="{{ 'active' if request.url.path.startswith('/ui/settings') }}">Settings</a>
|
|
187
|
+
<form method="post" action="/ui/logout"><button type="submit">Log out</button></form>
|
|
188
|
+
</div>
|
|
189
|
+
</details>
|
|
190
|
+
</nav>
|
|
191
|
+
{% endif %}
|
|
192
|
+
<main id="main">
|
|
193
|
+
{% if flash %}<div class="flash">{{ flash }}</div>{% endif %}
|
|
194
|
+
{% if error %}<div class="err">{{ error }}</div>{% endif %}
|
|
195
|
+
{% block body %}{% endblock %}
|
|
196
|
+
</main>
|
|
197
|
+
{% block scripts %}{% endblock %}
|
|
198
|
+
</body></html>
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Keywright · Dashboard{% endblock %}{% block body %}
|
|
2
|
+
<div class="dashboard-heading"><h1>Dashboard</h1>
|
|
3
|
+
<p class="page-intro">Requests that need you and agents with active approvals.</p>
|
|
4
|
+
</div>
|
|
5
|
+
<div class="grid stats">
|
|
6
|
+
<div class="card"><div class="mut small">Pending requests</div><div class="stat" data-count="pending">{{ pending|length }}</div></div>
|
|
7
|
+
<div class="card"><div class="mut small">Temporary approvals</div><div class="stat" data-count="leases">{{ lease_count }}</div></div>
|
|
8
|
+
<div class="card"><div class="mut small">Targets / agents</div><div class="stat">{{ target_count }} / {{ agent_count }}</div></div>
|
|
9
|
+
<div class="card"><div class="mut small">Notifier</div><div><b>{{ notifier.driver_id }}</b>
|
|
10
|
+
{% if notifier.last_error %}<div class="small" style="color:var(--bad)">{{ notifier.last_error }}</div>{% elif notifier.driver_id == 'log' %}<div class="small mut">No push channel. <a href="/ui/settings">Configure</a></div>{% endif %}</div></div>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
{% for r in drifted %}<div class="card" role="status"><b>Tools changed on {{ r.name }}</b>
|
|
14
|
+
<span class="small">{% if r.config.tools_drift.added %}{{ r.config.tools_drift.added|length }} new · {% endif %}{% if r.config.tools_drift.changed %}{{ r.config.tools_drift.changed|length }} changed · {% endif %}{% if r.config.tools_drift.removed %}{{ r.config.tools_drift.removed|length }} removed · {% endif %}<a href="/ui/access?resource={{ r.id }}">review on the Access page</a></span></div>{% endfor %}
|
|
15
|
+
<div id="live">
|
|
16
|
+
{% include "_live.html" %}
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="activity-heading"><h2>Active approvals</h2><a href="/ui/grants">View all grants</a></div>
|
|
20
|
+
<p class="small mut">Only agents with temporary approvals are shown. Standing rules and target defaults still apply. <a href="/ui/access">Manage access</a></p>
|
|
21
|
+
{% for entry in access_agents if entry.leases %}
|
|
22
|
+
<section class="card" aria-label="Access for {{ entry.agent.name }}">
|
|
23
|
+
<div class="row access-heading"><div><b>{{ entry.agent.name }}</b> <span class="pill">{{ entry.leases|length }} approval{{ 's' if entry.leases|length != 1 else '' }}</span>{% if entry.agent.disabled %} <span class="pill locked">Suspended · new operations blocked</span>{% endif %}</div>
|
|
24
|
+
<form method="post" action="/ui/agents/{{ entry.agent.id }}/{{ 'enable' if entry.agent.disabled else 'disable' }}">
|
|
25
|
+
<input type="hidden" name="next" value="/ui"><button class="btn sm {{ '' if entry.agent.disabled else 'bad' }}">{{ 'Resume agent' if entry.agent.disabled else 'Suspend agent' }}</button></form></div>
|
|
26
|
+
{% for lease in entry.leases %}
|
|
27
|
+
<div class="access-lease">
|
|
28
|
+
<div class="row access-heading"><div><b>{{ 'Retained approval while suspended' if entry.agent.disabled else 'Temporary access' }}</b> · {{ lease.remaining }}{% if lease.scope == 'session' %} · session <code>{{ lease.session_id }}</code>{% elif lease.scope == 'once' %} · once per listed scope{% endif %}</div>
|
|
29
|
+
<form method="post" action="{% if lease.request %}/ui/requests/{{ lease.request.id }}/revoke{% else %}/ui/grants/{{ lease.grants[0].id }}/revoke{% endif %}"><input type="hidden" name="next" value="/ui"><button class="btn sm">Revoke approval</button></form></div>
|
|
30
|
+
<div class="small">{{ lease.label }}</div>
|
|
31
|
+
{% if lease.constraints %}<details class="access-detail"><summary>Operation constraints</summary>{% with constraints=lease.constraints %}{% include "_constraints.html" %}{% endwith %}</details>{% endif %}
|
|
32
|
+
{% for g in lease.grants %}<div><code>{{ g.pattern }}</code></div>{% endfor %}
|
|
33
|
+
{% if lease.request %}<div class="small mut">{{ lease.request.reason }}</div><a class="small" href="/ui/requests/{{ lease.request.id }}">Approval and activity · {{ lease.request.id }}</a>{% endif %}
|
|
34
|
+
</div>
|
|
35
|
+
{% endfor %}
|
|
36
|
+
{% set approved_targets = entry.targets|selectattr('temporary')|list %}
|
|
37
|
+
{% if approved_targets %}<details class="access-detail"><summary>Target access details · {{ approved_targets|length }}</summary>
|
|
38
|
+
<p class="small mut">Deny rules override approvals. Revoking an approval removes only that permission source. Suspending an agent blocks new operations; running operations continue.</p>
|
|
39
|
+
{% for target in approved_targets %}
|
|
40
|
+
<div class="access-target">
|
|
41
|
+
<div class="row"><b>{{ target.resource.name }}</b><span class="pill">{{ target.resource.kind | upper }}</span><span>{{ target.state }}</span>{% if target.has_restrictions %}<span class="pill deny">Deny rules apply</span>{% endif %}</div>
|
|
42
|
+
<div class="small mut">Default: {{ target.resource.default_mode }} · {{ target.temporary }} temporary permission scopes</div>
|
|
43
|
+
{% if target.allows or target.denies %}<details class="access-detail"><summary>{{ target.allows|length }} standing allows · {{ target.denies|length }} denies</summary>
|
|
44
|
+
{% for p in target.denies + target.allows %}<div class="access-source"><span class="pill {{ p.effect }}">{{ p.effect }}</span> <code>{{ p.pattern }}</code><div class="small">{{ constraint_label(p.constraints) }} · {{ 'All agents' if not p.agent_id else 'This agent' }}</div>
|
|
45
|
+
{% if p.constraints %}{% with constraints=p.constraints %}{% include "_constraints.html" %}{% endwith %}{% endif %}</div>{% endfor %}
|
|
46
|
+
<a class="small" href="/ui/policies">Manage standing rules</a></details>{% endif %}
|
|
47
|
+
</div>
|
|
48
|
+
{% endfor %}
|
|
49
|
+
</details>{% endif %}
|
|
50
|
+
</section>
|
|
51
|
+
{% else %}<div class="card mut">No active approvals. Agents will appear here when a temporary approval is granted.</div>{% endfor %}
|
|
52
|
+
|
|
53
|
+
<div class="activity-heading"><h2>Recent activity</h2><a href="/ui/audit">View all</a></div>
|
|
54
|
+
<div class="card activity-list">{% for e in recent %}{% include "_activity_row.html" %}{% else %}<p class="mut collection-empty">No activity yet.</p>{% endfor %}<a class="btn activity-all" href="/ui/audit">View all activity →</a></div>
|
|
55
|
+
{% endblock %}
|
|
56
|
+
{% block scripts %}
|
|
57
|
+
<script>
|
|
58
|
+
/* Live dashboard. Without this the page is what it always was: server-rendered, correct on load,
|
|
59
|
+
and stale afterwards. With it, requests arrive as they are made and resolve as they are decided,
|
|
60
|
+
including when the decision came from a phone and this tab had nothing to do with it.
|
|
61
|
+
Every mutation still goes through the ordinary forms; this socket only ever reads. */
|
|
62
|
+
(function () {
|
|
63
|
+
var live = document.getElementById('live');
|
|
64
|
+
if (!live || !window.WebSocket) return;
|
|
65
|
+
|
|
66
|
+
// ---- relative times: rendered absolute so they are right without JS, relative once we are here
|
|
67
|
+
function since(seconds) {
|
|
68
|
+
if (seconds < 60) return Math.max(0, Math.round(seconds)) + 's ago';
|
|
69
|
+
if (seconds < 3600) return Math.round(seconds / 60) + 'm ago';
|
|
70
|
+
return Math.round(seconds / 3600) + 'h ago';
|
|
71
|
+
}
|
|
72
|
+
function until(seconds) {
|
|
73
|
+
if (seconds <= 0) return 'expired';
|
|
74
|
+
if (seconds < 90) return 'expires in ' + Math.max(1, Math.round(seconds)) + 's';
|
|
75
|
+
return 'expires in ' + Math.round(seconds / 60) + 'm';
|
|
76
|
+
}
|
|
77
|
+
function clock() {
|
|
78
|
+
var at = Date.now();
|
|
79
|
+
live.querySelectorAll('[data-since]').forEach(function (el) {
|
|
80
|
+
el.textContent = since((at - Date.parse(el.getAttribute('data-since'))) / 1000);
|
|
81
|
+
});
|
|
82
|
+
live.querySelectorAll('[data-until]').forEach(function (el) {
|
|
83
|
+
el.textContent = until((Date.parse(el.getAttribute('data-until')) - at) / 1000);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
setInterval(clock, 5000);
|
|
87
|
+
clock();
|
|
88
|
+
|
|
89
|
+
// ---- counts and the two empty states ----------------------------------------------------
|
|
90
|
+
function settle(counts) {
|
|
91
|
+
if (counts) {
|
|
92
|
+
Object.keys(counts).forEach(function (key) {
|
|
93
|
+
document.querySelectorAll('[data-count="' + key + '"]').forEach(function (el) {
|
|
94
|
+
el.textContent = counts[key];
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
var heading = document.getElementById('nudges-heading');
|
|
99
|
+
if (heading) heading.hidden = !document.querySelector('#nudge-list .nudge-tile');
|
|
100
|
+
var empty = document.getElementById('pending-empty');
|
|
101
|
+
if (empty) empty.hidden = !!document.querySelector('#pending-list .req-tile');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function add(listId, domId, html) {
|
|
105
|
+
var list = document.getElementById(listId);
|
|
106
|
+
if (!list || document.getElementById(domId)) return;
|
|
107
|
+
var holder = document.createElement('div');
|
|
108
|
+
holder.innerHTML = html;
|
|
109
|
+
var tile = holder.firstElementChild;
|
|
110
|
+
if (!tile) return;
|
|
111
|
+
tile.classList.add('tile-new');
|
|
112
|
+
list.insertBefore(tile, list.firstChild);
|
|
113
|
+
clock();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function drop(domId) {
|
|
117
|
+
var tile = document.getElementById(domId);
|
|
118
|
+
if (tile) tile.remove();
|
|
119
|
+
settle(null);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* A decided request says how it was decided before it goes. Its buttons are disabled first:
|
|
123
|
+
the decision is already made, and a click landing afterwards would only raise an error. */
|
|
124
|
+
function resolved(event) {
|
|
125
|
+
var tile = document.getElementById('req-' + event.id);
|
|
126
|
+
if (!tile) return;
|
|
127
|
+
tile.querySelectorAll('button').forEach(function (b) { b.disabled = true; });
|
|
128
|
+
tile.classList.add('tile-done', 'tile-' + event.status);
|
|
129
|
+
var note = document.createElement('div');
|
|
130
|
+
note.className = 'tile-resolved';
|
|
131
|
+
if (event.status === 'expired') {
|
|
132
|
+
note.textContent = 'Expired before it was decided. Nothing was granted.';
|
|
133
|
+
} else {
|
|
134
|
+
note.textContent = (event.status === 'approved' ? 'Approved' : 'Denied')
|
|
135
|
+
+ (event.label ? ' · ' + event.label : '')
|
|
136
|
+
+ (event.via && event.via !== 'ui' ? ' · via ' + event.via : '');
|
|
137
|
+
}
|
|
138
|
+
tile.appendChild(note);
|
|
139
|
+
setTimeout(function () { drop('req-' + event.id); }, 6000);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* A full redraw, used after a reconnect and whenever the server says we fell behind. Whatever
|
|
143
|
+
the human had open stays open: losing an expanded request mid-read would be its own bug. */
|
|
144
|
+
function resync(state) {
|
|
145
|
+
if (!state || typeof state.html !== 'string') return;
|
|
146
|
+
var open = [];
|
|
147
|
+
live.querySelectorAll('[data-request] details[open]').forEach(function (d) {
|
|
148
|
+
open.push(d.closest('[data-request]').getAttribute('data-request'));
|
|
149
|
+
});
|
|
150
|
+
live.innerHTML = state.html;
|
|
151
|
+
open.forEach(function (id) {
|
|
152
|
+
var d = live.querySelector('[data-request="' + id + '"] details');
|
|
153
|
+
if (d) d.open = true;
|
|
154
|
+
});
|
|
155
|
+
settle(state.counts);
|
|
156
|
+
clock();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function apply(event) {
|
|
160
|
+
if (event.type === 'ping' || event.type === 'hello') return settle(event.counts);
|
|
161
|
+
if (event.type === 'resync') return resync(event);
|
|
162
|
+
if (event.type === 'request.created') add('pending-list', 'req-' + event.id, event.html);
|
|
163
|
+
else if (event.type === 'request.decided') resolved(event);
|
|
164
|
+
else if (event.type === 'nudge.created') add('nudge-list', 'nudge-' + event.id, event.html);
|
|
165
|
+
else if (event.type === 'nudge.cleared') drop('nudge-' + event.id);
|
|
166
|
+
settle(event.counts);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- the socket -------------------------------------------------------------------------
|
|
170
|
+
var state = document.getElementById('live-state');
|
|
171
|
+
var backoff = 1000, socket = null, retrying = false, everOpened = false;
|
|
172
|
+
|
|
173
|
+
function show(text, cls) {
|
|
174
|
+
if (!state) return;
|
|
175
|
+
state.hidden = false;
|
|
176
|
+
state.textContent = text;
|
|
177
|
+
state.className = 'live-state ' + cls;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function retry() {
|
|
181
|
+
if (retrying) return;
|
|
182
|
+
retrying = true;
|
|
183
|
+
show('reconnecting', 'off');
|
|
184
|
+
setTimeout(connect, backoff);
|
|
185
|
+
backoff = Math.min(backoff * 2, 30000);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function refresh() {
|
|
189
|
+
fetch('/ui/live', {credentials: 'same-origin'})
|
|
190
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
191
|
+
.then(resync)
|
|
192
|
+
.catch(function () {});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function connect() {
|
|
196
|
+
retrying = false;
|
|
197
|
+
try {
|
|
198
|
+
socket = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ui/events');
|
|
199
|
+
} catch (err) {
|
|
200
|
+
return retry(); // blocked or malformed: keep trying quietly rather than going silently stale
|
|
201
|
+
}
|
|
202
|
+
socket.onopen = function () {
|
|
203
|
+
backoff = 1000;
|
|
204
|
+
show('live', 'on');
|
|
205
|
+
if (everOpened) refresh(); // catch up on anything decided while we were away
|
|
206
|
+
everOpened = true;
|
|
207
|
+
};
|
|
208
|
+
socket.onmessage = function (e) {
|
|
209
|
+
try { apply(JSON.parse(e.data)); } catch (err) { /* a message we do not understand is not fatal */ }
|
|
210
|
+
};
|
|
211
|
+
socket.onclose = retry;
|
|
212
|
+
socket.onerror = function () { try { socket.close(); } catch (err) { retry(); } };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Phones suspend the tab rather than closing the socket; a socket that looks open after a long
|
|
216
|
+
// sleep is usually dead, so returning to the page always re-reads the truth from the server.
|
|
217
|
+
document.addEventListener('visibilitychange', function () {
|
|
218
|
+
if (document.visibilityState !== 'visible') return;
|
|
219
|
+
if (socket && socket.readyState === WebSocket.OPEN) refresh();
|
|
220
|
+
else retry();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
connect();
|
|
224
|
+
})();
|
|
225
|
+
</script>
|
|
226
|
+
{% endblock %}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{% extends "base.html" %}{% set hide_nav = True %}{% block title %}Keywright decision{% endblock %}{% block body %}
|
|
2
|
+
<h1 class="brand">{{ icon('shield') }}Keywright</h1>
|
|
3
|
+
{% if req %}
|
|
4
|
+
<div class="card">
|
|
5
|
+
{% include "_hazards.html" %}
|
|
6
|
+
<div><b>{{ req.agent.name }}</b> asks for:</div>
|
|
7
|
+
<div style="margin:8px 0">{% include "_patterns.html" %}</div>
|
|
8
|
+
{% for pats, op in operations(req.detail) %}{% with constraints=op %}{% include "_constraints.html" %}{% endwith %}{% endfor %}
|
|
9
|
+
{% if req.reason %}<div>“{{ req.reason }}”</div>{% endif %}
|
|
10
|
+
<div class="small mut" style="margin-top:6px">{{ req.created_at.strftime('%Y-%m-%d %H:%M:%S') }} UTC · status <span class="pill {{ req.status }}">{{ req.status }}</span></div>
|
|
11
|
+
</div>
|
|
12
|
+
{% if done %}
|
|
13
|
+
<div class="card"><b>Recorded:</b> {{ scope_labels[scope] }}. You can close this page.</div>
|
|
14
|
+
{% elif req.status != 'pending' %}
|
|
15
|
+
<div class="card">This request was already <b>{{ req.status }}</b>{% if req.decision_scope %} ({{ decision_label(req) }}){% endif %}.</div>
|
|
16
|
+
{% else %}
|
|
17
|
+
<form method="post" class="card">
|
|
18
|
+
<div>Confirm: <b>{{ scope_labels[scope] }}</b></div>
|
|
19
|
+
<p class="small">{% if hazard_blocks(hazard_level(req.detail)) %}Flagged destructive: this allows the exact operation shown once, or denies it. {% endif %}{% if binds_exactly(req.detail) %}This decision covers the exact operation{% if operations(req.detail)|length > 1 %}s{% endif %} shown, for the selected duration.{% else %}This decision covers the listed permission patterns.{% endif %} <a href="/ui/requests/{{ req.id }}">Review other scope and duration choices</a> in the admin UI.</p>
|
|
20
|
+
<p class="help">{% if scope == 'session' %}Session access uses the client-supplied ID and expires within {{ session_cap_minutes }} minutes. {% endif %}Expiry and revocation prevent future calls; running operations continue.</p>
|
|
21
|
+
{% if scope == 'deny' %}<label>Reason (optional, shown to the agent)</label><input type="text" name="deny_reason">{% endif %}
|
|
22
|
+
<div style="margin-top:12px" class="row"><button class="btn {{ 'bad' if scope == 'deny' else 'ok' }}">{{ scope_labels[scope] }}</button></div>
|
|
23
|
+
</form>
|
|
24
|
+
{% endif %}
|
|
25
|
+
{% endif %}
|
|
26
|
+
{% endblock %}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Approval friction{% endblock %}{% block body %}
|
|
2
|
+
<h1>Approval friction</h1>
|
|
3
|
+
<p class="page-intro">Find recurring requests, time spent waiting for decisions, and calls that keep failing. <a href="/ui/requests">Requests</a> · <a href="/ui/audit">Audit log</a> · <a href="/ui/policies">Rules</a></p>
|
|
4
|
+
<form method="get" class="card grid" role="search">
|
|
5
|
+
<div><label for="friction-start">From (UTC)</label><input id="friction-start" type="date" name="start" value="{{ filters.start }}" required></div>
|
|
6
|
+
<div><label for="friction-end">Through (UTC)</label><input id="friction-end" type="date" name="end" value="{{ filters.end }}" required></div>
|
|
7
|
+
<div><label for="friction-agent">Agent</label><select id="friction-agent" name="agent"><option value="">Every agent</option>{% for a in agents %}<option value="{{ a.id }}" {{ 'selected' if filters.agent == a.id }}>{{ a.name }}</option>{% endfor %}</select></div>
|
|
8
|
+
<div class="row"><button class="btn p">Show report</button><a class="btn" href="/ui/friction">Last 30 days</a></div>
|
|
9
|
+
</form>
|
|
10
|
+
<section class="card" aria-labelledby="friction-requests">
|
|
11
|
+
<h2 id="friction-requests">Requests · {{ totals.requests }} distinct requests</h2>
|
|
12
|
+
<p>{{ totals.human_approved }} approved by a human · {{ totals.other_approved }} other / unattributed approvals · {{ totals.denied }} denied · {{ totals.pending }} pending · {{ totals.expired }} expired</p>
|
|
13
|
+
<p>Median human decision wait: <b>{{ (median_wait ~ ' s') if median_wait is not none else 'Unknown' }}</b> · {{ wait_samples }} timed decisions.</p>
|
|
14
|
+
<p class="small mut">Counts use request IDs, so repeated polling and approval/execution audit entries do not add requests. Requests were created in the selected dates; statuses are current. Wait times cover completed human approvals and denials, not pending requests or notification delivery time. Pending requests past their expiry are shown as expired without changing them.</p>
|
|
15
|
+
</section>
|
|
16
|
+
<section class="card" aria-labelledby="friction-calls">
|
|
17
|
+
<h2 id="friction-calls">Call evidence · {{ totals.call_records }} records</h2>
|
|
18
|
+
<p>{{ totals.failed }} call errors or failed results · {{ totals.blocked }} policy blocks / refusals · {{ totals.succeeded }} successful results · {{ totals.unknown }} unreported results.</p>
|
|
19
|
+
<p class="small mut">{{ totals.lifecycle }} pending/decision records excluded from result counts. Allowed calls can fail; denied calls are not execution failures. These are recorded outcomes, not transcript analysis or a claim that every failed attempt was captured.</p>
|
|
20
|
+
</section>
|
|
21
|
+
{% if limited %}<div class="card" role="status"><b>Partial sample.</b> At most the newest {{ limit }} requests and {{ limit }} call records are included. Narrow the dates or choose an agent. Suggestions are disabled until both samples fit.</div>{% endif %}
|
|
22
|
+
<p class="small mut">Coverage: {{ coverage.missing_wait }} human decisions missing valid timing; {{ coverage.incomplete_requests }} requests and {{ coverage.incomplete_events }} failed/blocked records lack comparable operation evidence. Incomplete records stay separate. Only Keywright activity is included. Commands, arguments, reasons and error output are omitted from this report.</p>
|
|
23
|
+
<h2>Recurring activity and suggestions</h2>
|
|
24
|
+
<p class="small">Suggestions require at least three human approvals of the identical complete operation for one agent and target. They retain the exact arguments or command and require preview and explicit save. Repetition does not establish safety; recorded failures, denials, hazards, wildcard scopes and incomplete evidence exclude a suggestion.</p>
|
|
25
|
+
{% for group in rows[:50] %}
|
|
26
|
+
<section class="card" aria-label="{{ group.agent }} · {{ group.tool }}">
|
|
27
|
+
<div class="row"><b>{{ group.agent }} · {{ group.resource }}</b><span class="pill">{{ group.tool }}</span></div>
|
|
28
|
+
<p>{{ group.requests|length }} requests · {{ group.statuses.human_approved }} human approvals · {{ group.statuses.denied }} denials · {{ group.statuses.pending }} pending · {{ group.failures }} failed results / errors · {{ group.blocked }} blocks</p>
|
|
29
|
+
{% if group.median_wait is not none %}<p class="small">Median human decision wait: {{ group.median_wait }} s</p>{% endif %}
|
|
30
|
+
<div class="row small">{% for r in group.requests[:3] %}<a href="/ui/requests/{{ r.id }}">Request {{ r.at.strftime('%m-%d %H:%M') }} UTC</a>{% endfor %}{% for e in group.events[:3] %}<a href="/ui/audit?q=%23{{ e.id }}">Audit #{{ e.id }} · {{ e.status }}</a>{% endfor %}</div>
|
|
31
|
+
{% if group.suggestion %}<p>Could have covered <b>{{ group.suggestion.support }} matching approval requests</b> in this sample. Future savings are unknown; approvals may cover different numbers of executions.</p>
|
|
32
|
+
<a class="btn" href="/ui/friction/review/{{ group.suggestion.request_id }}?{{ review_query }}">Review exact-operation suggestion</a>{% endif %}
|
|
33
|
+
</section>
|
|
34
|
+
{% else %}<div class="card"><p>No requests or failed/blocked calls in this selection.</p></div>{% endfor %}
|
|
35
|
+
{% if rows|length > 50 %}<p class="small mut">Showing the 50 busiest groups of {{ rows|length }}. Narrow the dates or select an agent to inspect more.</p>{% endif %}
|
|
36
|
+
{% endblock %}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Review exact-operation rule{% endblock %}{% block body %}
|
|
2
|
+
<h1>Review exact-operation suggestion</h1>
|
|
3
|
+
<p class="page-intro"><a href="{{ back }}">Back to approval friction</a></p>
|
|
4
|
+
<section class="card">
|
|
5
|
+
<h2>{{ proposal.agent }} · {{ proposal.resource }} · {{ proposal.tool }}</h2>
|
|
6
|
+
<p>This proposes {{ rule_count }} standing allow rule(s) for <b>only this agent and the exact recorded operation</b>, including its target identity, SSH account, command, file digest or all MCP arguments as applicable. The duration becomes permanent until revoked; the operation scope stays exact.</p>
|
|
7
|
+
<p><b>Review the operation before saving:</b> <a href="/ui/requests/{{ proposal.request_id }}" target="_blank" rel="noopener">Open the original approval</a>. Commands and arguments are kept on that existing detail page.</p>
|
|
8
|
+
<p>{{ proposal.support }} matching human approvals in the selected report. Supporting requests:</p>
|
|
9
|
+
<div class="row small">{% for id in proposal.examples %}<a href="/ui/requests/{{ id }}" target="_blank" rel="noopener">{{ id }}</a>{% endfor %}</div>
|
|
10
|
+
<p class="small">This is an estimate of historical requests covered, not guaranteed future prompts avoided. Deny rules and destructive-operation checks remain authoritative. Access settings and evidence are checked again when saving.</p>
|
|
11
|
+
</section>
|
|
12
|
+
<section class="card" aria-labelledby="preview-title">
|
|
13
|
+
<h2 id="preview-title">Rule preview · not saved</h2>
|
|
14
|
+
{% include "_policy_preview_results.html" %}
|
|
15
|
+
</section>
|
|
16
|
+
<form method="post" class="card">
|
|
17
|
+
<input type="hidden" name="token" value="{{ token }}">
|
|
18
|
+
<p>Save only after reviewing the original operation and this preview. This review expires in 10 minutes.</p>
|
|
19
|
+
<button class="btn p">Save exact-operation rule(s)</button> <a class="btn" href="{{ back }}">Cancel</a>
|
|
20
|
+
</form>
|
|
21
|
+
{% endblock %}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Grants{% endblock %}{% block body %}
|
|
2
|
+
<style>
|
|
3
|
+
.gr-agent{padding:0;overflow:hidden}
|
|
4
|
+
.gr-agent>summary{list-style:none;cursor:pointer;display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center;justify-content:space-between;padding:14px 16px}
|
|
5
|
+
.gr-agent>summary::-webkit-details-marker{display:none}
|
|
6
|
+
.gr-agent>summary h2{margin:0;font-size:17px}
|
|
7
|
+
.gr-agent>summary .gr-sub{font-size:13px;color:var(--mut)}
|
|
8
|
+
.gr-agent>summary::after{content:'▾';color:var(--mut);margin-left:auto}.gr-agent:not([open])>summary::after{content:'▸'}
|
|
9
|
+
.gr-host{border-top:1px solid var(--line);padding:10px 16px 12px}
|
|
10
|
+
.gr-host-head{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:6px}
|
|
11
|
+
.gr-host-head b{font-size:15px}
|
|
12
|
+
.gr-appr{display:flex;flex-wrap:wrap;gap:8px 14px;justify-content:space-between;align-items:flex-start;padding:10px 12px;margin-top:8px;border:1px solid var(--line);border-radius:var(--r)}
|
|
13
|
+
.gr-appr.ended,.gr-appr.revoked{opacity:.62}
|
|
14
|
+
.gr-body{min-width:0;flex:1 1 260px}
|
|
15
|
+
.gr-meta{display:flex;flex-wrap:wrap;gap:4px 10px;align-items:center;font-size:13px}
|
|
16
|
+
.gr-paths{margin-top:6px}.gr-paths code{display:block;font-size:12px;word-break:break-all;margin-top:2px}
|
|
17
|
+
.gr-reason{font-size:13px;color:var(--mut);margin-top:6px}
|
|
18
|
+
.gr-toolbar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between;margin-bottom:12px}
|
|
19
|
+
.gr-tabs{display:inline-flex;border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
|
|
20
|
+
.gr-tabs a{padding:6px 12px;font-size:13px;color:var(--mut)}.gr-tabs a.on{color:var(--fg);font-weight:600;background:rgba(90,169,255,.12)}
|
|
21
|
+
</style>
|
|
22
|
+
<h1>Grants</h1>
|
|
23
|
+
<p class="page-intro">Temporary approvals, by agent and by the host or server they reach, newest first. Revoking one does not remove standing rules or open defaults, or stop a running operation. <a href="/ui">All access sources</a> · <a href="/ui/access">Standing rules by tool</a>.</p>
|
|
24
|
+
|
|
25
|
+
<div class="gr-toolbar">
|
|
26
|
+
<div class="gr-tabs" role="group" aria-label="Which grants"><a href="/ui/grants" class="{{ 'on' if not history }}">Active{% if not history %} · {{ active_total }}{% endif %}</a><a href="/ui/grants?show=all" class="{{ 'on' if history }}">History</a></div>
|
|
27
|
+
{% if active_total %}<form method="post" action="/ui/grants/revoke-all" onsubmit="return confirm('Revoke every active grant, for every agent?')"><button class="btn bad sm">Revoke all active</button></form>{% endif %}
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
{% for a in groups %}
|
|
31
|
+
<details class="card gr-agent" open>
|
|
32
|
+
<summary><div><h2>{{ a.name }}{% if a.agent and a.agent.disabled %} <span class="pill locked">Suspended</span>{% endif %}</h2>
|
|
33
|
+
<div class="gr-sub">{{ a.active }} active · {{ a.hosts|length }} host{{ '' if a.hosts|length == 1 else 's' }} · last approval {{ a.latest.strftime('%m-%d %H:%M') }}</div></div>
|
|
34
|
+
{% if a.active %}<form method="post" action="/ui/grants/revoke-all" onsubmit="return confirm('Revoke every active grant for {{ a.name }}?')" onclick="event.stopPropagation()"><input type="hidden" name="agent_id" value="{{ a.agent_id }}"><button class="btn sm">Revoke all for {{ a.name }}</button></form>{% endif %}
|
|
35
|
+
</summary>
|
|
36
|
+
{% for h in a.hosts %}
|
|
37
|
+
<section class="gr-host" aria-label="{{ a.name }} on {{ h.name }}">
|
|
38
|
+
<div class="gr-host-head"><span class="pill">{{ h.kind|upper }}</span><b>{{ h.name }}</b><span class="small mut">{{ h.active }} active · last {{ h.latest.strftime('%m-%d %H:%M') }}</span></div>
|
|
39
|
+
{% for ap in h.approvals %}
|
|
40
|
+
<div class="gr-appr {{ ap.state }}">
|
|
41
|
+
<div class="gr-body">
|
|
42
|
+
<div class="gr-meta"><span class="pill {{ 'ok' if ap.state == 'active' else ('revoked' if ap.state == 'revoked' else '') }}">{{ ap.label }}</span>
|
|
43
|
+
<span>{{ ap.approved_at.strftime('%m-%d %H:%M') }}</span>
|
|
44
|
+
{% if ap.state == 'active' %}<b>{{ ap.remaining }}</b>{% else %}<span class="mut">{{ 'revoked' if ap.state == 'revoked' else 'used up / expired' }}</span>{% endif %}
|
|
45
|
+
{% if ap.uses %}<span class="mut">{{ ap.uses|sum }} use{{ '' if ap.uses|sum == 1 else 's' }} left</span>{% endif %}
|
|
46
|
+
{% if ap.session %}<span class="mut mono small">session {{ ap.session[:12] }}</span>{% endif %}</div>
|
|
47
|
+
<div class="gr-paths">{% for g in ap.grants[:4] %}<code>{{ g.pattern }}</code>{% endfor %}
|
|
48
|
+
{% if ap.grants|length > 4 %}<details><summary class="small">{{ ap.grants|length - 4 }} more path{{ '' if ap.grants|length == 5 else 's' }}</summary>{% for g in ap.grants[4:] %}<code>{{ g.pattern }}</code>{% endfor %}</details>{% endif %}</div>
|
|
49
|
+
{% for g, c in ap.constraints if c %}<details class="small"><summary>{{ constraint_label(c) }}</summary>{% with constraints=c %}{% include "_constraints.html" %}{% endwith %}</details>{% endfor %}
|
|
50
|
+
{% if ap.request %}<div class="gr-reason">{{ ap.request.reason[:200] }} · <a href="/ui/requests/{{ ap.request.id }}">approval</a></div>{% endif %}
|
|
51
|
+
</div>
|
|
52
|
+
{% if ap.revoke %}<form method="post" action="{{ ap.revoke }}"><input type="hidden" name="next" value="/ui/grants{{ '?show=all' if history }}"><button class="btn sm">Revoke</button>{% if ap.elsewhere %}<div class="small mut">also revokes it on {{ ap.elsewhere }} other host{{ '' if ap.elsewhere == 1 else 's' }}</div>{% endif %}</form>{% endif %}
|
|
53
|
+
</div>
|
|
54
|
+
{% endfor %}
|
|
55
|
+
</section>
|
|
56
|
+
{% endfor %}
|
|
57
|
+
</details>
|
|
58
|
+
{% else %}
|
|
59
|
+
<div class="card"><p class="mut">{% if history %}No grants yet. Approving a request creates them.{% else %}No active grants. Standing rules and resource defaults still apply; <a href="/ui/grants?show=all">see history</a>.{% endif %}</p></div>
|
|
60
|
+
{% endfor %}
|
|
61
|
+
{% endblock %}
|