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,174 @@
|
|
|
1
|
+
/* Pure topology model and geometry. No DOM, network requests, or device-specific coordinates. */
|
|
2
|
+
(function(root, factory) {
|
|
3
|
+
const api = factory();
|
|
4
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
5
|
+
else root.KeywrightTopology = api;
|
|
6
|
+
})(globalThis, function() {
|
|
7
|
+
'use strict';
|
|
8
|
+
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
9
|
+
const roles = {external: 0, gateway: 1, router: 1, switch: 2, network: 3, ap: 4};
|
|
10
|
+
const order = (a, b) => (roles[a.role] ?? 5) - (roles[b.role] ?? 5) || compare(a.id, b.id);
|
|
11
|
+
|
|
12
|
+
function normalize(graph, site = '') {
|
|
13
|
+
const nodes = new Map((graph.nodes || []).filter(n => !site || n.site_id === site)
|
|
14
|
+
.map(n => [n.id, {...n, name: n.name || n.mac || 'Unnamed device', addresses: n.addresses || []}]));
|
|
15
|
+
const edges = [], dangling = [], seen = new Set(), incoming = new Map();
|
|
16
|
+
for (const edge of graph.edges || []) {
|
|
17
|
+
if (!nodes.has(edge.target)) continue;
|
|
18
|
+
if (!nodes.has(edge.source)) { dangling.push(edge); continue; }
|
|
19
|
+
const key = JSON.stringify([edge.source, edge.target, edge.medium, edge.provenance, edge.label]);
|
|
20
|
+
if (seen.has(key)) continue;
|
|
21
|
+
seen.add(key);
|
|
22
|
+
const e = {...edge, key, backbone: !!(nodes.get(edge.source).hub && nodes.get(edge.target).hub)};
|
|
23
|
+
edges.push(e);
|
|
24
|
+
if (!incoming.has(e.target)) incoming.set(e.target, []);
|
|
25
|
+
incoming.get(e.target).push(e);
|
|
26
|
+
}
|
|
27
|
+
edges.sort((a, b) => compare(a.key, b.key));
|
|
28
|
+
const groups = new Map(), parent = new Map(), groupOf = new Map(), cycles = new Set();
|
|
29
|
+
for (const n of nodes.values()) if (n.hub) groups.set(n.id, {node: n, clients: [], children: [], synthetic: false});
|
|
30
|
+
function reportedParent(n) {
|
|
31
|
+
const choices = (incoming.get(n.id) || []).filter(e => e.source !== n.id && nodes.get(e.source).hub)
|
|
32
|
+
.sort((a, b) => compare(a.source, b.source));
|
|
33
|
+
return choices.find(e => e.source === n.attachment)?.source || choices[0]?.source;
|
|
34
|
+
}
|
|
35
|
+
for (const n of nodes.values()) {
|
|
36
|
+
const upstream = reportedParent(n);
|
|
37
|
+
if (n.hub) { if (upstream) parent.set(n.id, upstream); continue; }
|
|
38
|
+
let group = upstream;
|
|
39
|
+
if (!group) {
|
|
40
|
+
group = '@unplaced:' + (n.site_id || '');
|
|
41
|
+
while (nodes.has(group)) group = '@' + group;
|
|
42
|
+
if (!groups.has(group)) groups.set(group, {synthetic: true, children: [], clients: [], node: {
|
|
43
|
+
id: group, name: 'Connection unknown', hub: true, role: 'unknown', site: n.site,
|
|
44
|
+
site_id: n.site_id, state: 'unknown', unreviewed: false, virtual: true
|
|
45
|
+
}});
|
|
46
|
+
}
|
|
47
|
+
groups.get(group).clients.push(n.id);
|
|
48
|
+
groupOf.set(n.id, group);
|
|
49
|
+
}
|
|
50
|
+
// Use a spanning forest for placement only. Every reported edge is retained for rendering.
|
|
51
|
+
for (const id of groups.keys()) {
|
|
52
|
+
const path = [], at = new Map(); let cursor = id;
|
|
53
|
+
while (parent.has(cursor)) {
|
|
54
|
+
if (at.has(cursor)) {
|
|
55
|
+
const cycle = path.slice(at.get(cursor));
|
|
56
|
+
cycle.forEach(key => cycles.add(key));
|
|
57
|
+
parent.delete([...cycle].sort(compare)[0]);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
at.set(cursor, path.length); path.push(cursor); cursor = parent.get(cursor);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const [child, upstream] of parent) groups.get(upstream).children.push(child);
|
|
64
|
+
const sortGroups = (a, b) => order(groups.get(a).node, groups.get(b).node);
|
|
65
|
+
for (const group of groups.values()) {
|
|
66
|
+
group.clients.sort(compare); group.children.sort(sortGroups);
|
|
67
|
+
}
|
|
68
|
+
const roots = [...groups.keys()].filter(id => !parent.has(id)).sort(sortGroups);
|
|
69
|
+
const signature = JSON.stringify({nodes: [...nodes.values()].sort((a,b)=>compare(a.id,b.id))
|
|
70
|
+
.map(n => [n.id, n.hub, n.role, n.site_id]), edges: edges.map(e=>[e.source,e.target,e.medium]), parents: [...parent].sort((a,b)=>compare(a[0],b[0])), roots});
|
|
71
|
+
return {nodes, edges, incoming, dangling, groups, groupOf, parent, roots, cycles, signature};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function bounds(rects) {
|
|
75
|
+
if (!rects.length) return {x: 0, y: 0, width: 300, height: 220};
|
|
76
|
+
const x = Math.min(...rects.map(r=>r.x)), y = Math.min(...rects.map(r=>r.y));
|
|
77
|
+
return {x, y, width: Math.max(...rects.map(r=>r.x+r.width))-x,
|
|
78
|
+
height: Math.max(...rects.map(r=>r.y+r.height))-y};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function layout(model, options = {}) {
|
|
82
|
+
const mobile = !!options.mobile, expanded = options.expanded || new Set(), pages = options.pages || new Map();
|
|
83
|
+
const pageSize = mobile ? 8 : 10;
|
|
84
|
+
const hubW = mobile ? 132 : 164, hubH = 68, clientW = mobile ? 148 : 172, clientH = 44;
|
|
85
|
+
const rowGap = 56, siblingGap = mobile ? 18 : 34, levelGap = mobile ? 28 : 52;
|
|
86
|
+
const positions = new Map(), blocks = new Map(), levels = new Map(), spans = new Map(), depth = new Map();
|
|
87
|
+
const sides = new Map();
|
|
88
|
+
function sideWalk(id, side) {
|
|
89
|
+
sides.set(id, side);
|
|
90
|
+
const children = model.groups.get(id).children;
|
|
91
|
+
children.forEach((child, i) => sideWalk(child, children.length > 1 ? (i < children.length / 2 ? -1 : 1) : side));
|
|
92
|
+
}
|
|
93
|
+
model.roots.forEach((id,i)=>sideWalk(id,model.roots.length > 1 && i < model.roots.length/2 ? -1 : 1));
|
|
94
|
+
function measure(id, level) {
|
|
95
|
+
const group = model.groups.get(id), total = group.clients.length;
|
|
96
|
+
const page = Math.min(Math.max(0,pages.get(id)||0), Math.max(0,Math.ceil(total/pageSize)-1));
|
|
97
|
+
const clients = expanded.has(id) ? group.clients.slice(page*pageSize,(page+1)*pageSize) : [];
|
|
98
|
+
const side = sides.get(id), wing = clients.length ? clientW + 44 : 0;
|
|
99
|
+
const height = Math.max(hubH, clients.length*rowGap-12);
|
|
100
|
+
blocks.set(id, {width:hubW+wing, height, side, clients, page, total, pageSize});
|
|
101
|
+
depth.set(id, level); levels.set(level, Math.max(levels.get(level)||0,height));
|
|
102
|
+
const widths = group.children.map(child=>measure(child,level+1));
|
|
103
|
+
const span = Math.max(hubW+wing, widths.reduce((a,b)=>a+b,0)+Math.max(0,widths.length-1)*siblingGap);
|
|
104
|
+
spans.set(id,span); return span;
|
|
105
|
+
}
|
|
106
|
+
model.roots.forEach(id=>measure(id,0));
|
|
107
|
+
let nextY = 16; const centers = new Map();
|
|
108
|
+
for (const [level,height] of [...levels].sort((a,b)=>a[0]-b[0])) {
|
|
109
|
+
centers.set(level,nextY+height/2); nextY += height+levelGap;
|
|
110
|
+
}
|
|
111
|
+
function place(id, left) {
|
|
112
|
+
const group=model.groups.get(id), block=blocks.get(id), span=spans.get(id), y=centers.get(depth.get(id));
|
|
113
|
+
const blockLeft=left+(span-block.width)/2;
|
|
114
|
+
const x=blockLeft+(block.side<0?block.width-hubW/2:hubW/2);
|
|
115
|
+
positions.set(id,{id,x:x-hubW/2,y:y-hubH/2,width:hubW,height:hubH,hub:true,group:id});
|
|
116
|
+
block.clients.forEach((client,i)=>{
|
|
117
|
+
const fraction=block.clients.length===1?0:i/(block.clients.length-1)*2-1;
|
|
118
|
+
const bow=18*Math.cos(fraction*Math.PI/2);
|
|
119
|
+
const cx=block.side>0?x+hubW/2+26+bow:x-hubW/2-26-bow-clientW;
|
|
120
|
+
positions.set(client,{id:client,x:cx,y:y+(i-(block.clients.length-1)/2)*rowGap-clientH/2,
|
|
121
|
+
width:clientW,height:clientH,hub:false,group:id,side:block.side});
|
|
122
|
+
});
|
|
123
|
+
const childrenWidth=group.children.reduce((sum,c)=>sum+spans.get(c),0)+Math.max(0,group.children.length-1)*siblingGap;
|
|
124
|
+
let childLeft=left+(span-childrenWidth)/2;
|
|
125
|
+
for(const child of group.children){place(child,childLeft);childLeft+=spans.get(child)+siblingGap;}
|
|
126
|
+
}
|
|
127
|
+
let rootLeft=16;
|
|
128
|
+
for(const id of model.roots){place(id,rootLeft);rootLeft+=spans.get(id)+siblingGap*2;}
|
|
129
|
+
const routes=[];
|
|
130
|
+
for(const e of model.edges){
|
|
131
|
+
const a=positions.get(e.source),b=positions.get(e.target);if(!a||!b)continue;
|
|
132
|
+
let path;
|
|
133
|
+
if(e.source===e.target){path=`M${a.x+a.width} ${a.y+15} c40 -30 40 70 0 40`;}
|
|
134
|
+
else if(e.backbone){
|
|
135
|
+
const ax=a.x+a.width/2,ay=a.y+a.height,bx=b.x+b.width/2,by=b.y;
|
|
136
|
+
const mid=(ay+by)/2;
|
|
137
|
+
path=`M${ax} ${ay} C${ax} ${mid} ${bx} ${mid} ${bx} ${by}`;
|
|
138
|
+
}else{
|
|
139
|
+
const side=b.side||1,ax=side>0?a.x+a.width:a.x,ay=a.y+a.height/2;
|
|
140
|
+
const bx=side>0?b.x:b.x+b.width,by=b.y+b.height/2;
|
|
141
|
+
path=`M${ax} ${ay} C${ax+side*14} ${ay} ${bx-side*14} ${by} ${bx} ${by}`;
|
|
142
|
+
}
|
|
143
|
+
routes.push({...e,path});
|
|
144
|
+
}
|
|
145
|
+
const clusterBounds=new Map([...blocks].map(([id,block])=>[id,bounds([positions.get(id),...block.clients.map(c=>positions.get(c))])]));
|
|
146
|
+
return {positions,blocks,routes,clusterBounds,pageSize,bounds:bounds([...positions.values()]),
|
|
147
|
+
infrastructureBounds:bounds([...positions.values()].filter(p=>p.hub))};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function upstream(model, id) {
|
|
151
|
+
const nodes=new Set([id]), edges=new Set(), pending=[id];
|
|
152
|
+
while(pending.length){for(const e of model.incoming.get(pending.pop())||[]){
|
|
153
|
+
edges.add(e.key);if(!nodes.has(e.source)){nodes.add(e.source);pending.push(e.source);}
|
|
154
|
+
}}
|
|
155
|
+
return {nodes,edges};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function category(n) {
|
|
159
|
+
if(n.hub) return ({external:'internet',gateway:'router',router:'router',ap:'ap',switch:'switch'})[n.role]||'network';
|
|
160
|
+
const value=[n.category,n.device_type,n.model,n.name].filter(Boolean).join(' ').toLowerCase();
|
|
161
|
+
if(/camera|doorbell|webcam|\bipc\b/.test(value))return 'camera';
|
|
162
|
+
if(/iphone|android|smartphone|\bphone\b|pixel/.test(value))return 'phone';
|
|
163
|
+
if(/ipad|tablet/.test(value))return 'tablet';
|
|
164
|
+
if(/macbook|laptop|notebook/.test(value))return 'laptop';
|
|
165
|
+
if(/computer|desktop|\bpc\b|\bmac\b|imac/.test(value))return 'computer';
|
|
166
|
+
if(/server|\bnas\b|storage|proxmox/.test(value))return 'server';
|
|
167
|
+
if(/printer/.test(value))return 'printer';
|
|
168
|
+
if(/television|\btv\b|roku|display/.test(value))return 'tv';
|
|
169
|
+
if(/speaker|sonos|audio/.test(value))return 'speaker';
|
|
170
|
+
if(/sensor|thermostat|plug|light|bulb|vacuum|feeder|kettle|iot|home assistant/.test(value))return 'iot';
|
|
171
|
+
return 'device';
|
|
172
|
+
}
|
|
173
|
+
return {normalize,layout,bounds,upstream,category};
|
|
174
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/* SVG presentation only. Geometry and topology are supplied by the pure layout module. */
|
|
2
|
+
(function(root) {
|
|
3
|
+
'use strict';
|
|
4
|
+
const NS='http://www.w3.org/2000/svg';
|
|
5
|
+
const states={online:'Online (reported)',inactive:'Inactive (reported)',missing:'Not in latest snapshot',
|
|
6
|
+
old:'Not observed for 24h+',unknown:'Unknown',stale:'Stale / unverified'};
|
|
7
|
+
function svg(tag,attrs={},parent){
|
|
8
|
+
const node=document.createElementNS(NS,tag);
|
|
9
|
+
for(const [key,value] of Object.entries(attrs))node.setAttribute(key,String(value));
|
|
10
|
+
if(parent)parent.append(node);return node;
|
|
11
|
+
}
|
|
12
|
+
const paths={
|
|
13
|
+
internet:'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20M2 12h20M12 2c6 6 6 14 0 20M12 2c-6 6-6 14 0 20',
|
|
14
|
+
router:'M4 11h16v9H4zM7 11V5m10 6V3M7 16h.1M11 16h.1M15 16h2',
|
|
15
|
+
switch:'M3 6h18v13H3zM6 10h3v3H6zM11 10h3v3h-3zM16 10h2v3h-2M6 16h12',
|
|
16
|
+
ap:'M2 8q10-10 20 0M5 12q7-7 14 0M8 16q4-4 8 0M12 20h.1',
|
|
17
|
+
network:'M9 3h6v5H9zM2 16h6v5H2zM16 16h6v5h-6zM12 8v4M5 16v-4h14v4',
|
|
18
|
+
camera:'M3 7h12v13H3zM15 11l6-3v11l-6-3M6 7l2-3h5l2 3',
|
|
19
|
+
phone:'M7 2h10v20H7zM10 5h4M11 19h2',
|
|
20
|
+
tablet:'M4 2h16v20H4zM11 19h2',
|
|
21
|
+
computer:'M2 3h20v14H2zM12 17v4M7 21h10',
|
|
22
|
+
laptop:'M5 3h14v14H5zM5 17l-3 4h20l-3-4',
|
|
23
|
+
server:'M4 2h16v8H4zM4 14h16v8H4zM7 6h.1M7 18h.1M11 6h6M11 18h6',
|
|
24
|
+
printer:'M7 8V2h10v6M5 17H2V8h20v9h-3M6 13h12v9H6zM17 11h.1',
|
|
25
|
+
tv:'M2 5h20v14H2zM8 22h8M8 1l4 4 4-4',
|
|
26
|
+
speaker:'M6 2h12v20H6zM9 6h6M12 11a4 4 0 1 0 0 8 4 4 0 0 0 0-8',
|
|
27
|
+
iot:'M9 3h6v3h3v3h3v6h-3v3h-3v3H9v-3H6v-3H3V9h3V6h3zM9 9h6v6H9z',
|
|
28
|
+
device:'M5 3h14v18H5zM9 7h6M9 11h6M11 17h2'
|
|
29
|
+
};
|
|
30
|
+
function icon(parent,category,x,y){
|
|
31
|
+
const group=svg('g',{transform:`translate(${x} ${y})`,class:'topo-icon','aria-hidden':'true'},parent);
|
|
32
|
+
svg('path',{d:paths[category]||paths.device},group);return group;
|
|
33
|
+
}
|
|
34
|
+
const canvas=document.createElement('canvas'),context=canvas.getContext('2d');
|
|
35
|
+
function width(text,font=13){context.font=`${font}px system-ui, sans-serif`;return context.measureText(text).width;}
|
|
36
|
+
function truncate(text,available,font=13){
|
|
37
|
+
if(width(text,font)<=available)return text;
|
|
38
|
+
let out=text;while(out.length&&width(out+'…',font)>available)out=out.slice(0,-1);
|
|
39
|
+
return out+'…';
|
|
40
|
+
}
|
|
41
|
+
function lines(text,available){
|
|
42
|
+
if(width(text)<=available)return [text];
|
|
43
|
+
const words=text.split(/(?<=[\s\-_/])/);let first='';
|
|
44
|
+
while(words.length&&width(first+words[0])<=available)first+=words.shift();
|
|
45
|
+
if(!first){while(text.length&&width(first+text[0])<=available){first+=text[0];text=text.slice(1);}return [first,truncate(text,available)];}
|
|
46
|
+
return [first.trim(),truncate(words.join('').trim(),available)];
|
|
47
|
+
}
|
|
48
|
+
function text(parent,value,attrs){const t=svg('text',attrs,parent);t.textContent=value;return t;}
|
|
49
|
+
class Renderer {
|
|
50
|
+
constructor(scene){this.scene=scene;this.nodeElements=new Map();this.edgeElements=new Map();}
|
|
51
|
+
draw(model,geometry,ui){
|
|
52
|
+
const active=document.activeElement?.dataset?.id;
|
|
53
|
+
this.scene.replaceChildren();this.nodeElements.clear();this.edgeElements.clear();
|
|
54
|
+
const edges=svg('g',{'aria-hidden':'true'},this.scene);
|
|
55
|
+
for(const route of geometry.routes){
|
|
56
|
+
const line=svg('path',{d:route.path,class:`topo-edge ${route.backbone?'backbone':'client'} ${route.medium||'unknown'}${route.historical?' historical':''}`,
|
|
57
|
+
'data-edge':route.key},edges);
|
|
58
|
+
const a=model.nodes.get(route.source),b=model.nodes.get(route.target);
|
|
59
|
+
const title=svg('title',{},line);title.textContent=`${a.name} → ${b.name} · ${route.label||route.medium} · ${route.provenance||'Source unknown'}`;
|
|
60
|
+
this.edgeElements.set(route.key,line);
|
|
61
|
+
}
|
|
62
|
+
for(const [id,p] of geometry.positions){
|
|
63
|
+
const group=model.groups.get(id),n=model.nodes.get(id)||group?.node;if(!n)continue;
|
|
64
|
+
const block=geometry.blocks.get(id),synthetic=!!group?.synthetic;
|
|
65
|
+
const label=synthetic?`${n.name}, ${block.total} devices. Select to explore.`:
|
|
66
|
+
`${n.name}, ${n.virtual?'not monitored':states[n.state]||'Unknown'}${n.unreviewed?', unreviewed':''}${p.hub?`, ${block.total} clients. Select to focus.`:''}`;
|
|
67
|
+
const g=svg('g',{transform:`translate(${p.x} ${p.y})`,class:`topo-node ${p.hub?'hub':'client'} ${n.state||'unknown'}${synthetic?' unplaced':''}`,
|
|
68
|
+
role:'button',tabindex:0,'aria-label':label,'data-id':id,'data-action':'select','data-x':p.x,'data-y':p.y},this.scene);
|
|
69
|
+
svg('rect',{width:p.width,height:p.height,rx:p.hub?12:8,class:'node-surface'},g);
|
|
70
|
+
svg('rect',{x:0,y:10,width:3,height:p.height-20,rx:1.5,class:'state-rail'},g);
|
|
71
|
+
icon(g,synthetic?'device':KeywrightTopology.category(n),10,p.hub?13:10);
|
|
72
|
+
const available=p.width-47;
|
|
73
|
+
if(p.hub){
|
|
74
|
+
const nameLines=lines(n.name,available);
|
|
75
|
+
nameLines.forEach((line,i)=>text(g,line,{x:40,y:nameLines.length>1?23+i*16:29,class:'node-name'}));
|
|
76
|
+
const count=block.total?`${block.total} client${block.total===1?'':'s'} ${ui.expanded.has(id)?'−':'+'}`:
|
|
77
|
+
n.role==='external'?'Not monitored':({gateway:'Gateway',router:'Router',switch:'Switch',ap:'Access point'})[n.role]||'Infrastructure';
|
|
78
|
+
text(g,count,{x:10,y:57,class:'node-meta'});
|
|
79
|
+
if(block.total)svg('circle',{cx:p.width-13,cy:55,r:3,class:ui.expanded.has(id)?'expanded-dot':'collapsed-dot'},g);
|
|
80
|
+
}else{
|
|
81
|
+
text(g,truncate(n.name,available),{x:40,y:19,class:'node-name'});
|
|
82
|
+
text(g,truncate(n.addresses[0]||states[n.state]||'Unknown',available,11),{x:40,y:34,class:'node-meta'});
|
|
83
|
+
}
|
|
84
|
+
if(n.unreviewed){svg('circle',{cx:p.width-5,cy:5,r:5,class:'review-dot'},g);}
|
|
85
|
+
if(!synthetic&&p.hub&&!model.parent.has(id)&&n.role!=='external'&&n.role!=='gateway'&&n.role!=='router'){
|
|
86
|
+
const mark=svg('circle',{cx:p.width-12,cy:12,r:3,class:'unknown-uplink'},g);
|
|
87
|
+
const title=svg('title',{},mark);title.textContent='Upstream connection not reported';
|
|
88
|
+
}
|
|
89
|
+
const title=svg('title',{},g);title.textContent=label;
|
|
90
|
+
this.nodeElements.set(id,g);
|
|
91
|
+
}
|
|
92
|
+
this.emphasize(model,ui);
|
|
93
|
+
if(active&&this.nodeElements.has(active))this.nodeElements.get(active).focus({preventScroll:true});
|
|
94
|
+
}
|
|
95
|
+
emphasize(model,ui){
|
|
96
|
+
const selected=ui.selected,branch=ui.branch;
|
|
97
|
+
const ancestry=selected&&model.nodes.has(selected)?KeywrightTopology.upstream(model,selected):
|
|
98
|
+
branch&&model.nodes.has(branch)?KeywrightTopology.upstream(model,branch):{nodes:new Set(),edges:new Set()};
|
|
99
|
+
const related=new Set(ancestry.nodes);
|
|
100
|
+
if(branch){related.add(branch);for(const id of model.groups.get(branch)?.clients||[])related.add(id);}
|
|
101
|
+
for(const [id,g] of this.nodeElements){
|
|
102
|
+
const n=model.nodes.get(id)||model.groups.get(id)?.node;
|
|
103
|
+
const matches=!ui.status||ui.status==='unreviewed'&&n.unreviewed||
|
|
104
|
+
ui.status==='inactive'&&['inactive','missing'].includes(n.state)||
|
|
105
|
+
ui.status==='unknown'&&['unknown','stale'].includes(n.state)||n.state===ui.status;
|
|
106
|
+
g.classList.toggle('selected',id===selected);
|
|
107
|
+
g.classList.toggle('on-path',ancestry.nodes.has(id));
|
|
108
|
+
g.classList.toggle('dimmed',!!branch&&!related.has(id)||!matches);
|
|
109
|
+
}
|
|
110
|
+
for(const e of model.edges){const line=this.edgeElements.get(e.key);if(!line)continue;
|
|
111
|
+
line.classList.toggle('on-path',ancestry.edges.has(e.key));
|
|
112
|
+
line.classList.toggle('dimmed',!!branch&&!ancestry.edges.has(e.key)&&e.source!==branch);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
root.KeywrightTopologyView={Renderer,svg,states,truncate};
|
|
117
|
+
})(globalThis);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Access{% endblock %}{% block body %}
|
|
2
|
+
<style>
|
|
3
|
+
.acc-pick{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-end}.acc-pick .field{min-width:180px;flex:1}
|
|
4
|
+
.acc-group{margin-top:14px}.acc-group-head{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:space-between;margin-bottom:6px}
|
|
5
|
+
.acc-group-head h2{margin:0;font-size:16px;text-transform:capitalize}
|
|
6
|
+
.acc-row{display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center;justify-content:space-between;padding:9px 0;border-top:1px solid var(--line)}
|
|
7
|
+
.acc-row.changed{background:rgba(90,169,255,.07)}
|
|
8
|
+
.acc-name{min-width:0;flex:1 1 240px}.acc-name b{word-break:break-all}.acc-name code{font-size:11px;word-break:break-all}
|
|
9
|
+
.seg{display:inline-flex;border:1px solid var(--line);border-radius:var(--r);overflow:hidden;flex:0 0 auto}
|
|
10
|
+
.seg label{margin:0;padding:6px 11px;font-size:13px;cursor:pointer;color:var(--mut);border-left:1px solid var(--line)}
|
|
11
|
+
.seg label:first-of-type{border-left:0}.seg input{position:absolute;opacity:0;pointer-events:none}
|
|
12
|
+
.seg input:checked+span{color:var(--fg);font-weight:600}
|
|
13
|
+
.seg label:has(input[value=allow]:checked){background:rgba(46,204,113,.16)}
|
|
14
|
+
.seg label:has(input[value=deny]:checked){background:rgba(231,76,60,.18)}
|
|
15
|
+
.seg label:has(input[value=ask]:checked){background:rgba(90,169,255,.12)}
|
|
16
|
+
.seg input:focus-visible+span{outline:2px solid var(--acc);outline-offset:2px}
|
|
17
|
+
.acc-bulk{display:flex;gap:4px;flex-wrap:wrap}
|
|
18
|
+
.acc-save{position:sticky;bottom:0;z-index:5;display:flex;gap:10px;align-items:center;justify-content:space-between;padding:10px 0;background:var(--bg)}
|
|
19
|
+
@media (max-width:700px){.acc-save{bottom:62px}}
|
|
20
|
+
</style>
|
|
21
|
+
<h1>Access</h1>
|
|
22
|
+
<p class="page-intro">Everything a server offers, and what one agent may do with each. <b>Allow</b> and <b>Deny</b> are standing rules; <b>Ask</b> means no rule, so the resource default decides (usually a prompt on your phone). Saving writes only what changed.</p>
|
|
23
|
+
|
|
24
|
+
{% if ag %}
|
|
25
|
+
<section class="card" aria-label="Agent settings">
|
|
26
|
+
<div class="row access-heading"><b>{{ ag.name }}</b>{% if ag.disabled %}<span class="pill deny">disabled</span>{% else %}<span class="pill ok">active</span>{% endif %}</div>
|
|
27
|
+
<p class="small mut">Token <span class="mono">{{ ag.token_prefix }}…</span> · Last seen {{ ag.last_seen_at.strftime('%m-%d %H:%M') if ag.last_seen_at else 'never' }}</p>
|
|
28
|
+
{% if ag.note %}<p class="small">{{ ag.note }}</p>{% endif %}
|
|
29
|
+
<div class="row">
|
|
30
|
+
<form method="post" action="/ui/agents/{{ ag.id }}/rotate" onsubmit="return confirm('Rotate token? The old one stops working immediately.')"><button class="btn sm">Rotate token</button></form>
|
|
31
|
+
<form method="post" action="/ui/agents/{{ ag.id }}/delete" onsubmit="return confirm('Delete agent and all its grants/rules?')"><button class="btn sm bad">Delete agent</button></form>
|
|
32
|
+
</div>
|
|
33
|
+
</section>
|
|
34
|
+
{% endif %}
|
|
35
|
+
|
|
36
|
+
<form method="get" action="/ui/access" class="card acc-pick">
|
|
37
|
+
<div class="field"><label for="acc-res">Resource</label><select id="acc-res" name="resource" onchange="if(this.form.scope)this.form.scope.disabled=true;this.form.submit()">
|
|
38
|
+
{% for r in resources %}<option value="{{ r.id }}" {{ 'selected' if res and r.id == res.id }}>{{ r.kind|upper }} · {{ r.name }}</option>{% endfor %}</select></div>
|
|
39
|
+
<div class="field"><label for="acc-agent">Agent</label><select id="acc-agent" name="agent" onchange="this.form.submit()">
|
|
40
|
+
<option value="">Every agent</option>{% for a in agents %}<option value="{{ a.id }}" {{ 'selected' if ag and a.id == ag.id }}>{{ a.name }}</option>{% endfor %}</select></div>
|
|
41
|
+
{% if res and res.kind == 'mcp' %}<div class="field"><label for="acc-scope">Scope <span class="mut">(optional)</span></label>
|
|
42
|
+
<input id="acc-scope" type="text" name="scope" value="{{ scope }}" placeholder="{{ (arg_keys[0] ~ '=value') if arg_keys else 'set argument keys on the resource first' }}"></div>{% endif %}
|
|
43
|
+
<button class="btn">Show</button>
|
|
44
|
+
{% if res and res.kind == 'mcp' %}<button class="btn" name="refresh" value="1">Refresh tool list</button>{% endif %}
|
|
45
|
+
</form>
|
|
46
|
+
{% if res and res.kind == 'mcp' %}<p class="small mut">Scope narrows each rule to calls with that argument (for example <code>account_name=agents</code>), on the tools that take it. Keys: {{ arg_keys|join(', ') or 'none' }}. Resource default: <b>{{ res.default_mode }}</b>.</p>{% endif %}
|
|
47
|
+
|
|
48
|
+
{% if not resources %}<div class="card"><p class="mut">No MCP servers or LLM gateways yet. <a href="/ui/resources">Add one</a>.</p></div>{% endif %}
|
|
49
|
+
{% if error %}<div class="card"><b style="color:var(--bad)">{{ error }}</b></div>{% endif %}
|
|
50
|
+
|
|
51
|
+
{% if drift %}
|
|
52
|
+
<div class="card" role="status">
|
|
53
|
+
<b>Tool changes on {{ res.name }}</b> <span class="small mut">since the baseline of {{ (res.config.tools_seen_at or '')[:16] }}, found {{ (drift.at or '')[:16] }}</span>
|
|
54
|
+
{% if drift.added %}<div class="small">New: {% for t in drift.added %}<code>{{ t }}</code> {% endfor %}<span class="mut">(no rule covers them, so they ask)</span></div>{% endif %}
|
|
55
|
+
{% if drift.removed %}<div class="small">Removed: {% for t in drift.removed %}<code>{{ t }}</code> {% endfor %}</div>{% endif %}
|
|
56
|
+
{% for c in drift.changed %}<div class="small">Changed: <code>{{ c.tool }}</code>{% if c.new_args %} · new arguments <b>{{ c.new_args|join(', ') }}</b> <span class="mut">(not checked by any rule until listed in the resource's argument keys)</span>{% endif %}{% if c.dropped_args %} · dropped {{ c.dropped_args|join(', ') }}{% endif %}</div>{% endfor %}
|
|
57
|
+
<form method="post" action="/ui/resources/{{ res.id }}/tools/accept" style="margin-top:8px"><input type="hidden" name="agent" value="{{ ag.id if ag else '' }}"><button class="btn sm">I've reviewed these; make today's list the baseline</button></form>
|
|
58
|
+
</div>
|
|
59
|
+
{% endif %}
|
|
60
|
+
|
|
61
|
+
{% if groups %}
|
|
62
|
+
<form method="post" action="/ui/access" id="acc-form">
|
|
63
|
+
<input type="hidden" name="resource" value="{{ res.id }}"><input type="hidden" name="agent" value="{{ ag.id if ag else '' }}"><input type="hidden" name="scope" value="{{ scope }}">
|
|
64
|
+
{% for group, rows in groups.items() %}
|
|
65
|
+
<section class="card acc-group" aria-labelledby="g-{{ loop.index }}">
|
|
66
|
+
<div class="acc-group-head"><h2 id="g-{{ loop.index }}">{{ group }} <span class="mut small">{{ rows|length }}</span></h2>
|
|
67
|
+
{% if group != 'gone' %}<div class="acc-bulk" data-group="{{ loop.index }}"><span class="small mut">All:</span>{% for s in ['allow','ask','deny'] %}<button type="button" class="btn sm" data-set="{{ s }}">{{ s|capitalize }}</button>{% endfor %}</div>{% endif %}</div>
|
|
68
|
+
{% set gi = loop.index %}
|
|
69
|
+
{% for r in rows %}
|
|
70
|
+
<div class="acc-row" data-group="{{ gi }}" data-initial="{{ r.state }}">
|
|
71
|
+
<div class="acc-name"><b>{{ r.name }}</b>
|
|
72
|
+
{% if r.flag == 'new' %} <span class="pill pending">new</span>{% elif r.flag == 'changed' %} <span class="pill high">changed</span>{% elif r.flag == 'gone' %} <span class="pill revoked">gone</span>{% endif %}
|
|
73
|
+
{% if r.description %}<div class="small mut">{{ r.description }}</div>{% endif %}
|
|
74
|
+
<div><code>{{ r.pattern }}</code></div>
|
|
75
|
+
{% if r.new_args %}<div class="small" style="color:var(--warn)">New arguments: {{ r.new_args|join(', ') }}</div>{% endif %}
|
|
76
|
+
{% if r.everyone and r.everyone != 'ask' %}<div class="small">Every-agent rule: <span class="pill {{ r.everyone }}">{{ r.everyone }}</span></div>{% endif %}
|
|
77
|
+
{% for c in r.covered_by %}<div class="small">Also decided by <code>{{ c }}</code></div>{% endfor %}
|
|
78
|
+
{% if r.other_rules %}<div class="small mut">{{ r.other_rules }} other rule(s) on this tool (qualified or constrained), <a href="/ui/policies">see Rules</a></div>{% endif %}
|
|
79
|
+
</div>
|
|
80
|
+
<div class="seg" role="radiogroup" aria-label="{{ r.name }}">
|
|
81
|
+
{% for s in ['allow','ask','deny'] %}<label><input type="radio" name="s:{{ r.name }}" value="{{ s }}" {{ 'checked' if r.state == s }}><span>{{ s|capitalize }}</span></label>{% endfor %}
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
{% endfor %}
|
|
85
|
+
</section>
|
|
86
|
+
{% endfor %}
|
|
87
|
+
<div class="acc-save"><span class="small mut" id="acc-count" role="status">No changes</span><button class="btn p">Save changes</button></div>
|
|
88
|
+
</form>
|
|
89
|
+
{% endif %}
|
|
90
|
+
{% endblock %}
|
|
91
|
+
{% block scripts %}
|
|
92
|
+
<script>
|
|
93
|
+
(function () {
|
|
94
|
+
var form = document.getElementById('acc-form');
|
|
95
|
+
if (!form) return;
|
|
96
|
+
var count = document.getElementById('acc-count');
|
|
97
|
+
function update() {
|
|
98
|
+
var n = 0;
|
|
99
|
+
form.querySelectorAll('.acc-row').forEach(function (row) {
|
|
100
|
+
var picked = row.querySelector('input:checked');
|
|
101
|
+
var changed = picked && picked.value !== row.dataset.initial;
|
|
102
|
+
row.classList.toggle('changed', !!changed);
|
|
103
|
+
if (changed) n++;
|
|
104
|
+
});
|
|
105
|
+
count.textContent = n ? n + ' change' + (n === 1 ? '' : 's') + ' to save' : 'No changes';
|
|
106
|
+
}
|
|
107
|
+
form.addEventListener('change', update);
|
|
108
|
+
form.querySelectorAll('.acc-bulk').forEach(function (bulk) {
|
|
109
|
+
bulk.addEventListener('click', function (e) {
|
|
110
|
+
var s = e.target.dataset && e.target.dataset.set;
|
|
111
|
+
if (!s) return;
|
|
112
|
+
form.querySelectorAll('.acc-row[data-group="' + bulk.dataset.group + '"] input[value="' + s + '"]')
|
|
113
|
+
.forEach(function (input) { input.checked = true; });
|
|
114
|
+
update();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
})();
|
|
118
|
+
</script>
|
|
119
|
+
{% endblock %}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Agents{% endblock %}{% block body %}
|
|
2
|
+
<style>
|
|
3
|
+
.agent-create>summary{cursor:pointer;min-height:40px;padding:8px 0;color:var(--acc);font-weight:600}
|
|
4
|
+
.agent-create{margin-bottom:12px}.agent-create .toolbar{margin:8px 0 16px}
|
|
5
|
+
.agent-list{list-style:none;margin:0 0 16px;padding:0 12px;background:var(--panel);border:1px solid var(--line);border-radius:var(--r)}
|
|
6
|
+
.agent-item{display:flex;gap:12px;align-items:center;padding:12px 0;border-bottom:1px solid var(--line)}
|
|
7
|
+
.agent-item:last-child{border-bottom:0}.agent-info{flex:1;min-width:0}
|
|
8
|
+
.agent-heading{display:flex;flex-wrap:wrap;align-items:center;gap:4px 8px}.agent-heading b{font-size:14px;overflow-wrap:anywhere}
|
|
9
|
+
.agent-meta{display:flex;flex-wrap:wrap;gap:0 12px;margin-top:3px;color:var(--mut);font-size:12px}.agent-meta .mono{font-size:12px}
|
|
10
|
+
.agent-note{margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--mut);font-size:12px}
|
|
11
|
+
.agent-actions{display:flex;gap:6px;flex-shrink:0}
|
|
12
|
+
@media(max-width:600px){.agent-actions{flex-direction:column;gap:4px}.agent-actions .btn{width:100%;min-height:40px}.agent-item{padding:10px 0;gap:10px}}
|
|
13
|
+
</style>
|
|
14
|
+
<h1>Agents <span class="small mut">{{ rows|length }}</span></h1>
|
|
15
|
+
<p class="page-intro">Manage agent access and connect your MCP clients.</p>
|
|
16
|
+
{% if new_token %}
|
|
17
|
+
<div class="card token">
|
|
18
|
+
<b>New token · shown once</b>
|
|
19
|
+
<pre>{{ new_token }}</pre>
|
|
20
|
+
<div class="small mut">Save this token now. Creating or rotating a token is the only time it is shown.</div>
|
|
21
|
+
</div>
|
|
22
|
+
{% include "_agent_setup.html" %}
|
|
23
|
+
{% endif %}
|
|
24
|
+
<details class="agent-create" {{ 'open' if not rows }}><summary>New agent</summary>
|
|
25
|
+
<form method="post" action="/ui/agents" class="row toolbar">
|
|
26
|
+
<div class="field"><label for="agent-name">Agent name</label><input id="agent-name" type="text" name="name" placeholder="e.g. ai-vm-codex" required></div>
|
|
27
|
+
<div class="field"><label for="agent-note">Note <span class="mut">(optional)</span></label><input id="agent-note" type="text" name="note" placeholder="What this agent is used for"></div>
|
|
28
|
+
<button class="btn p">Create agent</button></form>
|
|
29
|
+
</details>
|
|
30
|
+
{% if rows %}
|
|
31
|
+
<ul class="agent-list" aria-label="Agents">
|
|
32
|
+
{% for a in rows %}
|
|
33
|
+
<li class="agent-item">
|
|
34
|
+
<div class="agent-info">
|
|
35
|
+
<div class="agent-heading"><b>{{ a.name }}</b>{% if a.disabled %}<span class="pill deny">disabled</span>{% else %}<span class="pill ok">active</span>{% endif %}</div>
|
|
36
|
+
<div class="agent-meta"><span>Seen {{ a.last_seen_at.strftime('%m-%d %H:%M') if a.last_seen_at else 'never' }}</span><span class="mono" aria-label="Token prefix">{{ a.token_prefix }}…</span></div>
|
|
37
|
+
{% if a.note %}<div class="agent-note" title="{{ a.note }}">{{ a.note }}</div>{% endif %}
|
|
38
|
+
</div>
|
|
39
|
+
<div class="agent-actions">
|
|
40
|
+
<a class="btn sm" href="/ui/access?agent={{ a.id }}" aria-label="Access for {{ a.name }}">Access</a>
|
|
41
|
+
{% if a.disabled %}<form method="post" action="/ui/agents/{{ a.id }}/enable"><button class="btn sm" aria-label="Enable {{ a.name }}">Enable</button></form>
|
|
42
|
+
{% else %}<form method="post" action="/ui/agents/{{ a.id }}/disable"><button class="btn sm" aria-label="Disable {{ a.name }}">Disable</button></form>{% endif %}
|
|
43
|
+
</div>
|
|
44
|
+
</li>
|
|
45
|
+
{% endfor %}
|
|
46
|
+
</ul>
|
|
47
|
+
{% else %}<p class="mut">No agents yet. Create one above to get a connection token.</p>{% endif %}
|
|
48
|
+
{% if not new_token %}{% include "_agent_setup.html" %}{% endif %}
|
|
49
|
+
{% endblock %}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{% extends "base.html" %}{% block title %}Audit{% endblock %}{% block body %}
|
|
2
|
+
<h1>Audit log</h1>
|
|
3
|
+
<p class="page-intro">Access decisions and operations across your infrastructure. <a href="/ui/friction">Review approval friction</a>.</p>
|
|
4
|
+
<form method="get" class="row toolbar" role="search"><input type="text" name="q" value="{{ q }}" placeholder="Search agent, tool, pattern, or detail…" aria-label="Filter audit log"><button class="btn">Filter</button>{% if q %}<a class="btn" href="/ui/audit">Clear</a>{% endif %}</form>
|
|
5
|
+
<div class="card activity-list">{% for e in rows %}{% include "_activity_row.html" %}{% else %}<p class="mut collection-empty">No matching audit events.</p>{% endfor %}</div>
|
|
6
|
+
{% endblock %}
|