@way_marks/server 2.0.3 → 3.0.0
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.
- package/dist/api/events.js +45 -0
- package/dist/api/server.js +49 -6
- package/package.json +2 -2
- package/src/ui-dist/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-cyrillic-500-normal-Ael50iVv.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-cyrillic-500-normal-Bq9vWWag.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BIfNGwUT.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-cyrillic-ext-500-normal-BqneJy0T.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-latin-ext-500-normal-CAhNIIs5.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-latin-ext-500-normal-CZ70TYgx.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-vietnamese-500-normal-C8zxqsMH.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-mono-vietnamese-500-normal-DZ4AoWbu.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
- package/src/ui-dist/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
- package/src/ui-dist/assets/index-DNdosrlQ.css +1 -0
- package/src/ui-dist/assets/index-EkwgRogY.js +87 -0
- package/src/ui-dist/index.html +14 -0
- package/src/ui/index.html +0 -1452
- package/src/ui/index.html.bak +0 -429
package/src/ui/index.html
DELETED
|
@@ -1,1452 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8" />
|
|
5
|
-
<title>waymark — agent action viewer</title>
|
|
6
|
-
<style>
|
|
7
|
-
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
8
|
-
body { font-family: monospace; font-size: 13px; background: #0f0f0f; color: #d0d0d0; padding: 16px; }
|
|
9
|
-
h1 { font-size: 18px; margin-bottom: 4px; color: #fff; }
|
|
10
|
-
.subtitle { color: #555; margin-bottom: 16px; font-size: 12px; }
|
|
11
|
-
.meta { display: flex; gap: 16px; margin-bottom: 12px; color: #666; font-size: 11px; }
|
|
12
|
-
table { width: 100%; border-collapse: collapse; }
|
|
13
|
-
th { text-align: left; padding: 6px 8px; background: #1a1a1a; color: #888; font-weight: normal; border-bottom: 1px solid #333; }
|
|
14
|
-
td { padding: 6px 8px; border-bottom: 1px solid #1e1e1e; vertical-align: top; }
|
|
15
|
-
tr:hover td { background: #161616; }
|
|
16
|
-
.badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 11px; font-weight: bold; }
|
|
17
|
-
.badge-write_file { background: #7c3a00; color: #ffb347; }
|
|
18
|
-
.badge-read_file { background: #002f5c; color: #5ab4ff; }
|
|
19
|
-
.badge-bash { background: #222; color: #aaa; border: 1px solid #444; }
|
|
20
|
-
.event-execution { opacity: 1; }
|
|
21
|
-
.event-observation { opacity: 0.6; background: #0a0a0a; }
|
|
22
|
-
.observation-label { display: inline-block; padding: 1px 4px; background: #333; color: #999; font-size: 10px; border-radius: 2px; margin-left: 4px; }
|
|
23
|
-
.decision-block { background: #5c0000; color: #ff6b6b; }
|
|
24
|
-
.decision-pending { background: #4a3800; color: #ffd166; }
|
|
25
|
-
.decision-rejected { background: #3a0000; color: #ff9999; }
|
|
26
|
-
.status-success { color: #4caf50; }
|
|
27
|
-
.status-error { color: #f44336; }
|
|
28
|
-
.status-pending { color: #ffb347; }
|
|
29
|
-
.status-blocked { color: #ff6b6b; }
|
|
30
|
-
.path { color: #888; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
|
31
|
-
button.rollback {
|
|
32
|
-
background: #3a1f00; border: 1px solid #7c3a00; color: #ffb347;
|
|
33
|
-
padding: 2px 8px; cursor: pointer; font-size: 11px; font-family: monospace;
|
|
34
|
-
border-radius: 3px;
|
|
35
|
-
}
|
|
36
|
-
button.rollback:hover { background: #5c3000; }
|
|
37
|
-
button.rollback:disabled { opacity: 0.4; cursor: default; }
|
|
38
|
-
.msg-ok { color: #4caf50; margin-left: 6px; }
|
|
39
|
-
.msg-err { color: #f44336; margin-left: 6px; }
|
|
40
|
-
.rolled-back { color: #555; font-size: 11px; }
|
|
41
|
-
#status-bar { position: fixed; bottom: 8px; right: 16px; color: #333; font-size: 11px; }
|
|
42
|
-
.empty { padding: 32px; text-align: center; color: #444; }
|
|
43
|
-
|
|
44
|
-
/* Config viewer */
|
|
45
|
-
#config-section { margin-top: 24px; }
|
|
46
|
-
#config-toggle {
|
|
47
|
-
background: none; border: 1px solid #333; color: #888; padding: 4px 10px;
|
|
48
|
-
cursor: pointer; font-family: monospace; font-size: 12px; border-radius: 3px;
|
|
49
|
-
}
|
|
50
|
-
#config-toggle:hover { border-color: #555; color: #aaa; }
|
|
51
|
-
#config-content { display: none; margin-top: 12px; padding: 12px; background: #141414; border: 1px solid #222; border-radius: 4px; }
|
|
52
|
-
#config-content h3 { color: #666; font-size: 11px; font-weight: normal; margin-bottom: 10px; }
|
|
53
|
-
.policy-group { margin-bottom: 12px; }
|
|
54
|
-
.policy-group h4 { font-size: 11px; color: #555; margin-bottom: 4px; font-weight: normal; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
55
|
-
.policy-group ul { list-style: none; padding: 0; }
|
|
56
|
-
.policy-group li { padding: 2px 0; font-size: 12px; }
|
|
57
|
-
.policy-allow { color: #4caf50; }
|
|
58
|
-
.policy-block { color: #ff6b6b; }
|
|
59
|
-
.policy-pending { color: #ffd166; }
|
|
60
|
-
|
|
61
|
-
/* v3: approval flow */
|
|
62
|
-
.status-rejected { color: #e57373; }
|
|
63
|
-
.status-approved { color: #4caf50; }
|
|
64
|
-
.btn-approve {
|
|
65
|
-
background: #003a1a; border: 1px solid #2e7d32; color: #81c784;
|
|
66
|
-
padding: 2px 8px; cursor: pointer; font-size: 11px; font-family: monospace;
|
|
67
|
-
border-radius: 3px; margin-right: 4px;
|
|
68
|
-
}
|
|
69
|
-
.btn-approve:hover { background: #1b5e20; }
|
|
70
|
-
.btn-approve:disabled { opacity: 0.4; cursor: default; }
|
|
71
|
-
.btn-reject {
|
|
72
|
-
background: #3a0000; border: 1px solid #7f0000; color: #ef9a9a;
|
|
73
|
-
padding: 2px 8px; cursor: pointer; font-size: 11px; font-family: monospace;
|
|
74
|
-
border-radius: 3px;
|
|
75
|
-
}
|
|
76
|
-
.btn-reject:hover { background: #5c0000; }
|
|
77
|
-
.btn-reject:disabled { opacity: 0.4; cursor: default; }
|
|
78
|
-
.reject-input {
|
|
79
|
-
background: #1a1a1a; border: 1px solid #444; color: #ccc;
|
|
80
|
-
padding: 2px 6px; font-family: monospace; font-size: 11px;
|
|
81
|
-
border-radius: 3px; width: 120px; margin-right: 4px;
|
|
82
|
-
}
|
|
83
|
-
#pending-badge {
|
|
84
|
-
background: #7f0000; color: #ef9a9a; border-radius: 3px;
|
|
85
|
-
padding: 1px 6px; font-size: 13px; margin-left: 8px;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/* Phase 2: Hub navigation sidebar */
|
|
89
|
-
#hub-nav { display: none; position: fixed; left: 0; top: 0; width: 200px; height: 100vh; background: #0a0a0a; border-right: 1px solid #222; padding: 12px; overflow-y: auto; }
|
|
90
|
-
#hub-nav.open { display: block; }
|
|
91
|
-
#main-content { margin-left: 0; }
|
|
92
|
-
#main-content.with-nav { margin-left: 200px; }
|
|
93
|
-
#hub-toggle { position: fixed; left: 8px; top: 8px; background: none; border: 1px solid #333; color: #888; padding: 4px 6px; cursor: pointer; font-size: 12px; border-radius: 3px; z-index: 1000; }
|
|
94
|
-
#hub-nav h3 { font-size: 11px; color: #666; margin: 12px 0 8px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
95
|
-
.project-list { list-style: none; padding: 0; }
|
|
96
|
-
.project-item { padding: 6px 4px; margin: 2px 0; border-radius: 2px; cursor: pointer; font-size: 12px; border-left: 2px solid transparent; }
|
|
97
|
-
.project-item.running { color: #81c784; border-left-color: #4caf50; }
|
|
98
|
-
.project-item.running:hover { background: #1a1a1a; }
|
|
99
|
-
.project-item.paused { color: #ffd166; border-left-color: #ffa000; }
|
|
100
|
-
.project-item.paused:hover { background: #1a1a1a; }
|
|
101
|
-
.project-item.stopped { color: #888; border-left-color: #555; }
|
|
102
|
-
.project-item.stopped:hover { background: #1a1a1a; }
|
|
103
|
-
.project-item.current { background: #1a1a1a; border-left-color: #5ab4ff; }
|
|
104
|
-
.project-port { font-size: 10px; color: #555; margin-left: 4px; }
|
|
105
|
-
|
|
106
|
-
/* Phase 1: Tab switcher */
|
|
107
|
-
.tab-button { transition: color 0.2s, border-color 0.2s; }
|
|
108
|
-
.tab-button.active { color: #5ab4ff; border-bottom-color: #5ab4ff !important; font-weight: bold; }
|
|
109
|
-
.tab-content { display: none; }
|
|
110
|
-
.tab-content.active { display: block; }
|
|
111
|
-
#sessions-table { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
|
|
112
|
-
#sessions-table thead tr { background: #1a1a1a; }
|
|
113
|
-
#sessions-table th, #sessions-table td { padding: 6px 8px; border-bottom: 1px solid #1e1e1e; text-align: left; }
|
|
114
|
-
#sessions-table tbody tr:hover { background: #161616; }
|
|
115
|
-
.session-status-active { color: #4caf50; }
|
|
116
|
-
.session-status-rolled_back { color: #5ab4ff; }
|
|
117
|
-
.session-id { color: #888; font-size: 11px; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
118
|
-
.btn-view-session { background: #003a5c; border: 1px solid #0066cc; color: #5ab4ff; padding: 2px 6px; cursor: pointer; font-size: 10px; font-family: monospace; border-radius: 2px; margin-right: 2px; }
|
|
119
|
-
.btn-view-session:hover { background: #004080; }
|
|
120
|
-
.btn-rollback-session { background: #3a1f00; border: 1px solid #7c3a00; color: #ffb347; padding: 2px 6px; cursor: pointer; font-size: 10px; font-family: monospace; border-radius: 2px; }
|
|
121
|
-
.btn-rollback-session:hover { background: #5c3000; }
|
|
122
|
-
.btn-rollback-session:disabled { opacity: 0.4; cursor: default; }
|
|
123
|
-
|
|
124
|
-
</style>
|
|
125
|
-
</head>
|
|
126
|
-
<body>
|
|
127
|
-
<h1>waymark <span id="project-name" style="color:#555"></span><span id="pending-badge" style="display:none"></span></h1>
|
|
128
|
-
<p class="subtitle">uglybugly agent action viewer — intercepts and logs MCP tool calls</p>
|
|
129
|
-
<div class="meta">
|
|
130
|
-
<span id="count-display">loading...</span>
|
|
131
|
-
<span id="refresh-display"></span>
|
|
132
|
-
</div>
|
|
133
|
-
<div style="margin-bottom:16px;font-size:12px">
|
|
134
|
-
<!-- Phase 1: Event type filters -->
|
|
135
|
-
<div style="margin-bottom:12px">
|
|
136
|
-
<span style="color:#888">Event:</span>
|
|
137
|
-
<label style="margin-left:8px"><input type="checkbox" id="filter-execution" checked> Execution</label>
|
|
138
|
-
<label style="margin-left:12px"><input type="checkbox" id="filter-observation" checked> Observation</label>
|
|
139
|
-
</div>
|
|
140
|
-
|
|
141
|
-
<!-- Phase 3: Status and tool filters -->
|
|
142
|
-
<div style="margin-bottom:12px">
|
|
143
|
-
<span style="color:#888">Status:</span>
|
|
144
|
-
<select id="filter-status" style="margin-left:8px;padding:2px;font-family:monospace;font-size:11px;background:#1a1a1a;color:#d0d0d0;border:1px solid #333">
|
|
145
|
-
<option value="">All Statuses</option>
|
|
146
|
-
<option value="pending">Pending</option>
|
|
147
|
-
<option value="success">Success</option>
|
|
148
|
-
<option value="rejected">Rejected</option>
|
|
149
|
-
</select>
|
|
150
|
-
</div>
|
|
151
|
-
|
|
152
|
-
<div style="margin-bottom:12px">
|
|
153
|
-
<span style="color:#888">Tool:</span>
|
|
154
|
-
<select id="filter-tool" style="margin-left:8px;padding:2px;font-family:monospace;font-size:11px;background:#1a1a1a;color:#d0d0d0;border:1px solid #333">
|
|
155
|
-
<option value="">All Tools</option>
|
|
156
|
-
<option value="write_file">write_file</option>
|
|
157
|
-
<option value="read_file">read_file</option>
|
|
158
|
-
<option value="bash">bash</option>
|
|
159
|
-
</select>
|
|
160
|
-
</div>
|
|
161
|
-
|
|
162
|
-
<div style="margin-bottom:12px">
|
|
163
|
-
<span style="color:#888">Search:</span>
|
|
164
|
-
<input type="text" id="filter-search" placeholder="action ID or path..." style="margin-left:8px;padding:2px;font-family:monospace;font-size:11px;background:#1a1a1a;color:#d0d0d0;border:1px solid #333;width:200px">
|
|
165
|
-
</div>
|
|
166
|
-
</div>
|
|
167
|
-
|
|
168
|
-
<!-- Phase 1 & 2: View tabs -->
|
|
169
|
-
<div style="margin-bottom:12px; border-bottom: 1px solid #333; padding-bottom: 8px;">
|
|
170
|
-
<button id="tab-actions" class="tab-button" data-tab="actions" style="background: none; border: none; color: #5ab4ff; padding: 4px 12px; cursor: pointer; font-family: monospace; font-size: 12px; border-bottom: 2px solid #5ab4ff; font-weight: bold;">Actions</button>
|
|
171
|
-
<button id="tab-sessions" class="tab-button" data-tab="sessions" style="background: none; border: none; color: #888; padding: 4px 12px; cursor: pointer; font-family: monospace; font-size: 12px; border-bottom: 2px solid transparent;">Sessions (Phase 1)</button>
|
|
172
|
-
<button id="tab-team" class="tab-button" data-tab="team" style="background: none; border: none; color: #888; padding: 4px 12px; cursor: pointer; font-family: monospace; font-size: 12px; border-bottom: 2px solid transparent;">Team (Phase 2)</button>
|
|
173
|
-
<button id="tab-approvals" class="tab-button" data-tab="approvals" style="background: none; border: none; color: #888; padding: 4px 12px; cursor: pointer; font-family: monospace; font-size: 12px; border-bottom: 2px solid transparent;">Approvals (Phase 2)</button>
|
|
174
|
-
<button id="tab-escalations" class="tab-button" data-tab="escalations" style="background: none; border: none; color: #888; padding: 4px 12px; cursor: pointer; font-family: monospace; font-size: 12px; border-bottom: 2px solid transparent;">Escalations (Phase 3)</button>
|
|
175
|
-
<button id="tab-remediation" class="tab-button" data-tab="remediation" style="background: none; border: none; color: #888; padding: 4px 12px; cursor: pointer; font-family: monospace; font-size: 12px; border-bottom: 2px solid transparent;">Remediation (Phase 4)</button>
|
|
176
|
-
</div>
|
|
177
|
-
|
|
178
|
-
<button id="hub-toggle">📋</button>
|
|
179
|
-
<nav id="hub-nav">
|
|
180
|
-
<h3>Projects</h3>
|
|
181
|
-
<ul id="project-list" class="project-list"></ul>
|
|
182
|
-
</nav>
|
|
183
|
-
<div id="main-content">
|
|
184
|
-
<!-- Actions Tab -->
|
|
185
|
-
<div id="tab-actions-content" class="tab-content active">
|
|
186
|
-
<table>
|
|
187
|
-
<thead>
|
|
188
|
-
<tr>
|
|
189
|
-
<th>Time</th>
|
|
190
|
-
<th>Tool</th>
|
|
191
|
-
<th>Decision</th>
|
|
192
|
-
<th>Target / Command</th>
|
|
193
|
-
<th>Status</th>
|
|
194
|
-
<th>Stdout</th>
|
|
195
|
-
<th>Action</th>
|
|
196
|
-
</tr>
|
|
197
|
-
</thead>
|
|
198
|
-
<tbody id="action-tbody">
|
|
199
|
-
<tr><td colspan="7" class="empty">loading...</td></tr>
|
|
200
|
-
</tbody>
|
|
201
|
-
</table>
|
|
202
|
-
</div>
|
|
203
|
-
|
|
204
|
-
<!-- Phase 1: Sessions Tab -->
|
|
205
|
-
<div id="tab-sessions-content" class="tab-content">
|
|
206
|
-
<table id="sessions-table">
|
|
207
|
-
<thead>
|
|
208
|
-
<tr>
|
|
209
|
-
<th>Session ID</th>
|
|
210
|
-
<th>Actions</th>
|
|
211
|
-
<th>Created</th>
|
|
212
|
-
<th>Status</th>
|
|
213
|
-
<th>Actions</th>
|
|
214
|
-
</tr>
|
|
215
|
-
</thead>
|
|
216
|
-
<tbody id="sessions-tbody">
|
|
217
|
-
<tr><td colspan="5" class="empty">loading...</td></tr>
|
|
218
|
-
</tbody>
|
|
219
|
-
</table>
|
|
220
|
-
<div id="session-details" style="display:none; margin-top: 16px; padding: 12px; background: #141414; border: 1px solid #222; border-radius: 4px;">
|
|
221
|
-
<h3 style="color: #888; margin-bottom: 8px;">Session Details</h3>
|
|
222
|
-
<div id="session-details-content"></div>
|
|
223
|
-
</div>
|
|
224
|
-
</div>
|
|
225
|
-
|
|
226
|
-
<!-- Phase 2: Team Tab -->
|
|
227
|
-
<div id="tab-team-content" class="tab-content">
|
|
228
|
-
<div style="margin-bottom: 16px;">
|
|
229
|
-
<h3>Team Members</h3>
|
|
230
|
-
<div style="margin-top: 8px; padding: 8px; background: #141414; border-radius: 3px;">
|
|
231
|
-
<input id="new-member-name" type="text" placeholder="Name" style="padding: 4px; margin-right: 6px; background: #0a0a0a; border: 1px solid #333; color: #d0d0d0; border-radius: 2px;" />
|
|
232
|
-
<input id="new-member-email" type="email" placeholder="Email" style="padding: 4px; margin-right: 6px; background: #0a0a0a; border: 1px solid #333; color: #d0d0d0; border-radius: 2px;" />
|
|
233
|
-
<button id="add-team-member-btn" style="padding: 4px 8px; background: #003a4d; border: 1px solid #00556b; color: #5ab4ff; cursor: pointer; border-radius: 2px;">Add Member</button>
|
|
234
|
-
</div>
|
|
235
|
-
</div>
|
|
236
|
-
<table id="team-members-table">
|
|
237
|
-
<thead>
|
|
238
|
-
<tr>
|
|
239
|
-
<th>Name</th>
|
|
240
|
-
<th>Email</th>
|
|
241
|
-
<th>Role</th>
|
|
242
|
-
<th>Added</th>
|
|
243
|
-
<th>Action</th>
|
|
244
|
-
</tr>
|
|
245
|
-
</thead>
|
|
246
|
-
<tbody id="team-tbody">
|
|
247
|
-
<tr><td colspan="5" class="empty">loading...</td></tr>
|
|
248
|
-
</tbody>
|
|
249
|
-
</table>
|
|
250
|
-
</div>
|
|
251
|
-
|
|
252
|
-
<!-- Phase 2: Approvals Tab -->
|
|
253
|
-
<div id="tab-approvals-content" class="tab-content">
|
|
254
|
-
<div style="margin-bottom: 16px;">
|
|
255
|
-
<h3>Approval Routing Rules</h3>
|
|
256
|
-
<div style="margin-top: 8px; padding: 8px; background: #141414; border-radius: 3px;">
|
|
257
|
-
<input id="new-route-name" type="text" placeholder="Route name" style="padding: 4px; margin-right: 6px; background: #0a0a0a; border: 1px solid #333; color: #d0d0d0; border-radius: 2px;" />
|
|
258
|
-
<select id="new-route-type" style="padding: 4px; margin-right: 6px; background: #0a0a0a; border: 1px solid #333; color: #d0d0d0; border-radius: 2px;">
|
|
259
|
-
<option value="all_sessions">All Sessions</option>
|
|
260
|
-
<option value="high_impact">High Impact</option>
|
|
261
|
-
</select>
|
|
262
|
-
<button id="create-route-btn" style="padding: 4px 8px; background: #003a4d; border: 1px solid #00556b; color: #5ab4ff; cursor: pointer; border-radius: 2px;">Create Route</button>
|
|
263
|
-
</div>
|
|
264
|
-
</div>
|
|
265
|
-
<table id="routes-table">
|
|
266
|
-
<thead>
|
|
267
|
-
<tr>
|
|
268
|
-
<th>Name</th>
|
|
269
|
-
<th>Type</th>
|
|
270
|
-
<th>Approvers</th>
|
|
271
|
-
<th>Created</th>
|
|
272
|
-
<th>Action</th>
|
|
273
|
-
</tr>
|
|
274
|
-
</thead>
|
|
275
|
-
<tbody id="routes-tbody">
|
|
276
|
-
<tr><td colspan="5" class="empty">loading...</td></tr>
|
|
277
|
-
</tbody>
|
|
278
|
-
</table>
|
|
279
|
-
<div style="margin-top: 24px; border-top: 1px solid #333; padding-top: 16px;">
|
|
280
|
-
<h3>Pending Approvals</h3>
|
|
281
|
-
<table id="approvals-queue-table">
|
|
282
|
-
<thead>
|
|
283
|
-
<tr>
|
|
284
|
-
<th>Session</th>
|
|
285
|
-
<th>Requester</th>
|
|
286
|
-
<th>Status</th>
|
|
287
|
-
<th>Approvals Needed</th>
|
|
288
|
-
<th>Created</th>
|
|
289
|
-
<th>Action</th>
|
|
290
|
-
</tr>
|
|
291
|
-
</thead>
|
|
292
|
-
<tbody id="approvals-queue-tbody">
|
|
293
|
-
<tr><td colspan="6" class="empty">loading...</td></tr>
|
|
294
|
-
</tbody>
|
|
295
|
-
</table>
|
|
296
|
-
</div>
|
|
297
|
-
</div>
|
|
298
|
-
|
|
299
|
-
<!-- Phase 3: Escalations Tab -->
|
|
300
|
-
<div id="tab-escalations-content" class="tab-content">
|
|
301
|
-
<div style="margin-bottom: 16px;">
|
|
302
|
-
<h3>Escalation Rules</h3>
|
|
303
|
-
<div style="margin-top: 8px; padding: 8px; background: #141414; border-radius: 3px;">
|
|
304
|
-
<input id="new-escalation-name" type="text" placeholder="Rule name" style="padding: 4px; margin-right: 6px; background: #0a0a0a; border: 1px solid #333; color: #d0d0d0; border-radius: 2px;" />
|
|
305
|
-
<input id="new-escalation-timeout" type="number" placeholder="Timeout (hours)" style="padding: 4px; margin-right: 6px; background: #0a0a0a; border: 1px solid #333; color: #d0d0d0; border-radius: 2px;" />
|
|
306
|
-
<button id="create-escalation-rule-btn" style="padding: 4px 8px; background: #3a2a00; border: 1px solid #7c5a00; color: #ffb347; cursor: pointer; border-radius: 2px;">Create Rule</button>
|
|
307
|
-
</div>
|
|
308
|
-
</div>
|
|
309
|
-
<table id="escalation-rules-table">
|
|
310
|
-
<thead>
|
|
311
|
-
<tr>
|
|
312
|
-
<th>Name</th>
|
|
313
|
-
<th>Timeout (hours)</th>
|
|
314
|
-
<th>Targets</th>
|
|
315
|
-
<th>Created</th>
|
|
316
|
-
<th>Action</th>
|
|
317
|
-
</tr>
|
|
318
|
-
</thead>
|
|
319
|
-
<tbody id="escalation-rules-tbody">
|
|
320
|
-
<tr><td colspan="5" class="empty">loading...</td></tr>
|
|
321
|
-
</tbody>
|
|
322
|
-
</table>
|
|
323
|
-
<div style="margin-top: 24px; border-top: 1px solid #333; padding-top: 16px;">
|
|
324
|
-
<h3>Pending Escalations</h3>
|
|
325
|
-
<table id="escalation-queue-table">
|
|
326
|
-
<thead>
|
|
327
|
-
<tr>
|
|
328
|
-
<th>Session</th>
|
|
329
|
-
<th>Requester</th>
|
|
330
|
-
<th>Status</th>
|
|
331
|
-
<th>Deadline</th>
|
|
332
|
-
<th>Action</th>
|
|
333
|
-
</tr>
|
|
334
|
-
</thead>
|
|
335
|
-
<tbody id="escalation-queue-tbody">
|
|
336
|
-
<tr><td colspan="5" class="empty">loading...</td></tr>
|
|
337
|
-
</tbody>
|
|
338
|
-
</table>
|
|
339
|
-
</div>
|
|
340
|
-
</div>
|
|
341
|
-
|
|
342
|
-
<!-- Phase 4: Remediation Tab -->
|
|
343
|
-
<div id="tab-remediation-content" class="tab-content">
|
|
344
|
-
<div style="margin-bottom: 16px;">
|
|
345
|
-
<h3>Risk Assessment</h3>
|
|
346
|
-
<div style="background: #1a1a1a; padding: 12px; border-radius: 3px; margin-bottom: 16px;">
|
|
347
|
-
<div style="font-size: 24px; font-weight: bold; margin-bottom: 8px;">
|
|
348
|
-
Risk Score: <span id="risk-score" style="color: #ffb347;">—</span>/10
|
|
349
|
-
</div>
|
|
350
|
-
<div>
|
|
351
|
-
Risk Level: <span id="risk-level" style="color: #ffb347;">—</span>
|
|
352
|
-
</div>
|
|
353
|
-
<div style="margin-top: 12px; font-size: 12px; color: #888;">
|
|
354
|
-
<div id="risk-factors" style="margin-top: 8px;">Loading risk assessment...</div>
|
|
355
|
-
</div>
|
|
356
|
-
</div>
|
|
357
|
-
</div>
|
|
358
|
-
|
|
359
|
-
<div style="margin-bottom: 16px;">
|
|
360
|
-
<h3>Policy Compliance</h3>
|
|
361
|
-
<table id="policy-status-table">
|
|
362
|
-
<thead>
|
|
363
|
-
<tr>
|
|
364
|
-
<th>Policy</th>
|
|
365
|
-
<th>Category</th>
|
|
366
|
-
<th>Status</th>
|
|
367
|
-
<th>Violations</th>
|
|
368
|
-
</tr>
|
|
369
|
-
</thead>
|
|
370
|
-
<tbody id="policy-status-tbody">
|
|
371
|
-
<tr><td colspan="4" class="empty">No policy violations detected</td></tr>
|
|
372
|
-
</tbody>
|
|
373
|
-
</table>
|
|
374
|
-
</div>
|
|
375
|
-
|
|
376
|
-
<div style="margin-bottom: 16px;">
|
|
377
|
-
<h3>Remediation Recommendations</h3>
|
|
378
|
-
<div id="remediation-recommendations" style="background: #0a0a0a; padding: 12px; border-radius: 3px;">
|
|
379
|
-
<div style="margin-bottom: 12px;">
|
|
380
|
-
<strong>Primary Strategy:</strong>
|
|
381
|
-
<div id="primary-strategy" style="color: #4caf50; margin-top: 4px;">—</div>
|
|
382
|
-
</div>
|
|
383
|
-
<div style="margin-bottom: 12px;">
|
|
384
|
-
<strong>Alternative Strategies:</strong>
|
|
385
|
-
<div id="alternative-strategies" style="color: #888; font-size: 12px; margin-top: 4px;">—</div>
|
|
386
|
-
</div>
|
|
387
|
-
<div style="margin-bottom: 12px;">
|
|
388
|
-
<strong>Estimated Safety:</strong>
|
|
389
|
-
<div id="estimated-safety" style="color: #ffb347; margin-top: 4px;">—</div>
|
|
390
|
-
</div>
|
|
391
|
-
<div>
|
|
392
|
-
<strong>Required Approvals:</strong>
|
|
393
|
-
<div id="required-approvals" style="color: #888; font-size: 12px; margin-top: 4px;">—</div>
|
|
394
|
-
</div>
|
|
395
|
-
</div>
|
|
396
|
-
</div>
|
|
397
|
-
|
|
398
|
-
<div style="margin-bottom: 16px;">
|
|
399
|
-
<h3>Active Block Rules</h3>
|
|
400
|
-
<table id="block-rules-table">
|
|
401
|
-
<thead>
|
|
402
|
-
<tr>
|
|
403
|
-
<th>Rule</th>
|
|
404
|
-
<th>Condition</th>
|
|
405
|
-
<th>Action</th>
|
|
406
|
-
<th>Message</th>
|
|
407
|
-
</tr>
|
|
408
|
-
</thead>
|
|
409
|
-
<tbody id="block-rules-tbody">
|
|
410
|
-
<tr><td colspan="4" class="empty">No active block rules</td></tr>
|
|
411
|
-
</tbody>
|
|
412
|
-
</table>
|
|
413
|
-
</div>
|
|
414
|
-
|
|
415
|
-
<div style="margin-bottom: 16px;">
|
|
416
|
-
<button id="run-assessment-btn" style="padding: 8px 12px; background: #2a5a2a; border: 1px solid #4caf50; color: #4caf50; cursor: pointer; border-radius: 2px;">
|
|
417
|
-
Run Risk Assessment
|
|
418
|
-
</button>
|
|
419
|
-
<button id="override-block-btn" style="padding: 8px 12px; background: #5a2a2a; border: 1px solid #f44336; color: #f44336; cursor: pointer; border-radius: 2px; margin-left: 8px; display: none;">
|
|
420
|
-
Admin Override Block
|
|
421
|
-
</button>
|
|
422
|
-
</div>
|
|
423
|
-
</div>
|
|
424
|
-
|
|
425
|
-
<div id="config-section">
|
|
426
|
-
<button id="config-toggle">▶ Current Policy</button>
|
|
427
|
-
<div id="config-content">
|
|
428
|
-
<h3>waymark.config.json</h3>
|
|
429
|
-
<div id="config-body">loading...</div>
|
|
430
|
-
</div>
|
|
431
|
-
</div>
|
|
432
|
-
|
|
433
|
-
<div id="status-bar">auto-refresh every 3s</div>
|
|
434
|
-
|
|
435
|
-
</div>
|
|
436
|
-
<script>
|
|
437
|
-
function timeAgo(dateStr) {
|
|
438
|
-
const now = new Date();
|
|
439
|
-
const then = new Date(dateStr + (dateStr.includes('Z') ? '' : 'Z'));
|
|
440
|
-
const diff = Math.floor((now - then) / 1000);
|
|
441
|
-
if (diff < 5) return 'just now';
|
|
442
|
-
if (diff < 60) return diff + 's ago';
|
|
443
|
-
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
|
444
|
-
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
|
445
|
-
return Math.floor(diff / 86400) + 'd ago';
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function toolBadge(name) {
|
|
449
|
-
return `<span class="badge badge-${name}">${name}</span>`;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
function statusLabel(status) {
|
|
453
|
-
return `<span class="status-${status}">${status}</span>`;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
function decisionBadge(row) {
|
|
457
|
-
const d = row.decision || 'allow';
|
|
458
|
-
if (d === 'block') {
|
|
459
|
-
const tip = row.policy_reason ? row.policy_reason.replace(/"/g, '"') : '';
|
|
460
|
-
return `<span class="badge decision-block" title="${tip}">blocked</span>`;
|
|
461
|
-
}
|
|
462
|
-
if (d === 'pending') {
|
|
463
|
-
const tip = row.policy_reason ? row.policy_reason.replace(/"/g, '"') : '';
|
|
464
|
-
return `<span class="badge decision-pending" title="${tip}">pending</span>`;
|
|
465
|
-
}
|
|
466
|
-
if (d === 'rejected') {
|
|
467
|
-
const tip = row.rejected_reason ? row.rejected_reason.replace(/"/g, '"') : '';
|
|
468
|
-
return `<span class="badge decision-rejected" title="${tip}">rejected</span>`;
|
|
469
|
-
}
|
|
470
|
-
return `<span style="color:#333">—</span>`;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
function truncatePath(p) {
|
|
474
|
-
if (!p) return '<span style="color:#444">—</span>';
|
|
475
|
-
const parts = p.replace(/\\/g, '/').split('/');
|
|
476
|
-
const display = parts.length > 3 ? '…/' + parts.slice(-3).join('/') : p;
|
|
477
|
-
return `<span class="path" title="${p}">${display}</span>`;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
function getTargetDisplay(row) {
|
|
481
|
-
if (row.tool_name === 'bash') {
|
|
482
|
-
const cmd = JSON.parse(row.input_payload).command || '';
|
|
483
|
-
const short = cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd;
|
|
484
|
-
return `<span class="path" title="${cmd.replace(/"/g, '"')}">${short}</span>`;
|
|
485
|
-
}
|
|
486
|
-
return truncatePath(row.target_path);
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
async function doRollback(actionId, btn, msgSpan, isNewFile) {
|
|
490
|
-
btn.disabled = true;
|
|
491
|
-
btn.textContent = isNewFile ? 'deleting...' : 'rolling back...';
|
|
492
|
-
try {
|
|
493
|
-
const res = await fetch(`/api/actions/${actionId}/rollback`, { method: 'POST' });
|
|
494
|
-
const data = await res.json();
|
|
495
|
-
if (res.ok) {
|
|
496
|
-
msgSpan.className = 'msg-ok';
|
|
497
|
-
msgSpan.textContent = data.action === 'deleted' ? '✓ deleted' : '✓ restored';
|
|
498
|
-
btn.textContent = data.action === 'deleted' ? 'deleted' : 'rolled back';
|
|
499
|
-
} else {
|
|
500
|
-
msgSpan.className = 'msg-err';
|
|
501
|
-
msgSpan.textContent = '✗ ' + (data.error || 'failed');
|
|
502
|
-
btn.disabled = false;
|
|
503
|
-
btn.textContent = isNewFile ? 'delete (new file)' : 'rollback';
|
|
504
|
-
}
|
|
505
|
-
} catch (e) {
|
|
506
|
-
msgSpan.className = 'msg-err';
|
|
507
|
-
msgSpan.textContent = '✗ network error';
|
|
508
|
-
btn.disabled = false;
|
|
509
|
-
btn.textContent = isNewFile ? 'delete (new file)' : 'rollback';
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
async function doApprove(actionId, btn, msgSpan) {
|
|
514
|
-
btn.disabled = true;
|
|
515
|
-
btn.textContent = 'approving...';
|
|
516
|
-
try {
|
|
517
|
-
const res = await fetch(`/api/actions/${actionId}/approve`, { method: 'POST' });
|
|
518
|
-
const data = await res.json();
|
|
519
|
-
if (res.ok) {
|
|
520
|
-
msgSpan.className = 'msg-ok';
|
|
521
|
-
msgSpan.textContent = '✓ approved';
|
|
522
|
-
// Full refresh to get updated row
|
|
523
|
-
setTimeout(refresh, 300);
|
|
524
|
-
} else {
|
|
525
|
-
msgSpan.className = 'msg-err';
|
|
526
|
-
msgSpan.textContent = '✗ ' + (data.error || 'failed');
|
|
527
|
-
btn.disabled = false;
|
|
528
|
-
btn.textContent = '✅ Approve';
|
|
529
|
-
}
|
|
530
|
-
} catch (e) {
|
|
531
|
-
msgSpan.className = 'msg-err';
|
|
532
|
-
msgSpan.textContent = '✗ network error';
|
|
533
|
-
btn.disabled = false;
|
|
534
|
-
btn.textContent = '✅ Approve';
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
async function doReject(actionId, btn, rejectBtn, msgSpan) {
|
|
539
|
-
// Show inline input for reason
|
|
540
|
-
rejectBtn.disabled = true;
|
|
541
|
-
const inputEl = document.createElement('input');
|
|
542
|
-
inputEl.className = 'reject-input';
|
|
543
|
-
inputEl.type = 'text';
|
|
544
|
-
inputEl.value = 'Not approved';
|
|
545
|
-
inputEl.placeholder = 'reason...';
|
|
546
|
-
const confirmBtn = document.createElement('button');
|
|
547
|
-
confirmBtn.className = 'btn-reject';
|
|
548
|
-
confirmBtn.textContent = 'Confirm';
|
|
549
|
-
rejectBtn.replaceWith(inputEl);
|
|
550
|
-
msgSpan.parentNode.insertBefore(confirmBtn, msgSpan);
|
|
551
|
-
|
|
552
|
-
confirmBtn.addEventListener('click', async () => {
|
|
553
|
-
confirmBtn.disabled = true;
|
|
554
|
-
inputEl.disabled = true;
|
|
555
|
-
confirmBtn.textContent = 'rejecting...';
|
|
556
|
-
try {
|
|
557
|
-
const res = await fetch(`/api/actions/${actionId}/reject`, {
|
|
558
|
-
method: 'POST',
|
|
559
|
-
headers: { 'Content-Type': 'application/json' },
|
|
560
|
-
body: JSON.stringify({ reason: inputEl.value || 'Not approved' }),
|
|
561
|
-
});
|
|
562
|
-
const data = await res.json();
|
|
563
|
-
if (res.ok) {
|
|
564
|
-
msgSpan.className = 'msg-ok';
|
|
565
|
-
msgSpan.textContent = '✓ rejected';
|
|
566
|
-
setTimeout(refresh, 300);
|
|
567
|
-
} else {
|
|
568
|
-
msgSpan.className = 'msg-err';
|
|
569
|
-
msgSpan.textContent = '✗ ' + (data.error || 'failed');
|
|
570
|
-
confirmBtn.disabled = false;
|
|
571
|
-
confirmBtn.textContent = 'Confirm';
|
|
572
|
-
}
|
|
573
|
-
} catch (e) {
|
|
574
|
-
msgSpan.className = 'msg-err';
|
|
575
|
-
msgSpan.textContent = '✗ network error';
|
|
576
|
-
confirmBtn.disabled = false;
|
|
577
|
-
confirmBtn.textContent = 'Confirm';
|
|
578
|
-
}
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
function renderActions(actions) {
|
|
583
|
-
const tbody = document.getElementById('action-tbody');
|
|
584
|
-
if (!actions.length) {
|
|
585
|
-
tbody.innerHTML = '<tr><td colspan="7" class="empty">No actions yet. Connect Claude Code to the waymark MCP server and run some tools.</td></tr>';
|
|
586
|
-
return;
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// Get filter state
|
|
590
|
-
const showExecution = document.getElementById('filter-execution').checked;
|
|
591
|
-
const showObservation = document.getElementById('filter-observation').checked;
|
|
592
|
-
|
|
593
|
-
const filteredActions = actions.filter(row => {
|
|
594
|
-
const eventType = row.event_type || 'execution';
|
|
595
|
-
if (eventType === 'observation') return showObservation;
|
|
596
|
-
return showExecution;
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
const rows = filteredActions.map(row => {
|
|
600
|
-
const isWriteFile = row.tool_name === 'write_file';
|
|
601
|
-
const isNewFile = isWriteFile && !row.before_snapshot;
|
|
602
|
-
const isPending = row.decision === 'pending' && row.status === 'pending';
|
|
603
|
-
const isApproved = row.status === 'success' && row.approved_by;
|
|
604
|
-
const isRejected = row.status === 'rejected';
|
|
605
|
-
const isBlocked = row.decision === 'block';
|
|
606
|
-
const canRollback = isWriteFile && !row.rolled_back && !isBlocked && !isPending && !isApproved && row.status === 'success' && !row.approved_by;
|
|
607
|
-
const eventType = row.event_type || 'execution';
|
|
608
|
-
const isObservation = eventType === 'observation';
|
|
609
|
-
|
|
610
|
-
let actionCell;
|
|
611
|
-
if (isPending) {
|
|
612
|
-
actionCell = `<button class="btn-approve" data-id="${row.action_id}">✅ Approve</button><button class="btn-reject" data-id="${row.action_id}">❌ Reject</button><span class="action-msg"></span>`;
|
|
613
|
-
} else if (isApproved) {
|
|
614
|
-
const tip = `by ${row.approved_by}${row.approved_at ? ' at ' + row.approved_at : ''}`.replace(/"/g, '"');
|
|
615
|
-
actionCell = `<span class="status-approved" title="${tip}">✅ Approved</span>`;
|
|
616
|
-
} else if (isRejected) {
|
|
617
|
-
const tip = (row.rejected_reason || '').replace(/"/g, '"');
|
|
618
|
-
actionCell = `<span class="status-rejected" title="${tip}">❌ Rejected</span>`;
|
|
619
|
-
} else if (row.rolled_back) {
|
|
620
|
-
actionCell = `<span class="rolled-back">↩ ${isNewFile ? 'deleted' : 'rolled back'}</span>`;
|
|
621
|
-
} else if (canRollback) {
|
|
622
|
-
const btnLabel = isNewFile ? 'delete (new file)' : 'rollback';
|
|
623
|
-
actionCell = `<button class="rollback" data-id="${row.action_id}" data-newfile="${isNewFile}">${btnLabel}</button><span class="rollback-msg"></span>`;
|
|
624
|
-
} else {
|
|
625
|
-
actionCell = `<span style="color:#333">—</span>`;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
const stdoutPreview = row.tool_name === 'bash' && row.stdout
|
|
629
|
-
? `<span style="color:#666;font-size:11px">${row.stdout.slice(0, 100).replace(/\n/g, '↵')}</span>`
|
|
630
|
-
: `<span style="color:#333">—</span>`;
|
|
631
|
-
|
|
632
|
-
const rowClass = `event-${eventType}`;
|
|
633
|
-
const obsLabel = isObservation ? '<span class="observation-label">plan mode</span>' : '';
|
|
634
|
-
|
|
635
|
-
return `<tr class="${rowClass}">
|
|
636
|
-
<td style="white-space:nowrap;color:#555">${timeAgo(row.created_at)}</td>
|
|
637
|
-
<td>${toolBadge(row.tool_name)}${obsLabel}</td>
|
|
638
|
-
<td>${decisionBadge(row)}</td>
|
|
639
|
-
<td>${getTargetDisplay(row)}</td>
|
|
640
|
-
<td>${statusLabel(row.status)}${row.error_message ? `<br><span style="color:#666;font-size:11px">${row.error_message.slice(0,80)}</span>` : ''}</td>
|
|
641
|
-
<td>${stdoutPreview}</td>
|
|
642
|
-
<td style="white-space:nowrap">${actionCell}</td>
|
|
643
|
-
</tr>`;
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
if (!rows.length) {
|
|
647
|
-
tbody.innerHTML = '<tr><td colspan="7" class="empty">No actions match current filter.</td></tr>';
|
|
648
|
-
return;
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
tbody.innerHTML = rows.join('');
|
|
652
|
-
|
|
653
|
-
tbody.querySelectorAll('button.rollback').forEach(btn => {
|
|
654
|
-
btn.addEventListener('click', () => {
|
|
655
|
-
const msgSpan = btn.nextElementSibling;
|
|
656
|
-
doRollback(btn.dataset.id, btn, msgSpan, btn.dataset.newfile === 'true');
|
|
657
|
-
});
|
|
658
|
-
});
|
|
659
|
-
|
|
660
|
-
tbody.querySelectorAll('button.btn-approve').forEach(btn => {
|
|
661
|
-
btn.addEventListener('click', () => {
|
|
662
|
-
const msgSpan = btn.parentElement.querySelector('.action-msg');
|
|
663
|
-
doApprove(btn.dataset.id, btn, msgSpan);
|
|
664
|
-
});
|
|
665
|
-
});
|
|
666
|
-
|
|
667
|
-
tbody.querySelectorAll('button.btn-reject').forEach(btn => {
|
|
668
|
-
btn.addEventListener('click', () => {
|
|
669
|
-
const approveBtn = btn.previousElementSibling;
|
|
670
|
-
const msgSpan = btn.nextElementSibling;
|
|
671
|
-
approveBtn.disabled = true;
|
|
672
|
-
doReject(btn.dataset.id, approveBtn, btn, msgSpan);
|
|
673
|
-
});
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
async function refresh() {
|
|
678
|
-
try {
|
|
679
|
-
// Build query string from filter values
|
|
680
|
-
const status = document.getElementById('filter-status').value;
|
|
681
|
-
const tool = document.getElementById('filter-tool').value;
|
|
682
|
-
const search = document.getElementById('filter-search').value;
|
|
683
|
-
|
|
684
|
-
const params = new URLSearchParams();
|
|
685
|
-
if (status) params.append('status', status);
|
|
686
|
-
if (tool) params.append('tool_name', tool);
|
|
687
|
-
if (search) params.append('search', search);
|
|
688
|
-
params.append('page', '1');
|
|
689
|
-
params.append('limit', '100');
|
|
690
|
-
|
|
691
|
-
// Fetch paginated data
|
|
692
|
-
const [paginationRes, countRes] = await Promise.all([
|
|
693
|
-
fetch(`/api/actions/paginated?${params}`),
|
|
694
|
-
fetch('/api/actions?count=true'),
|
|
695
|
-
]);
|
|
696
|
-
|
|
697
|
-
const pagination = await paginationRes.json();
|
|
698
|
-
const { count } = await countRes.json();
|
|
699
|
-
|
|
700
|
-
const actions = pagination.actions || [];
|
|
701
|
-
renderActions(Array.isArray(actions) ? actions : []);
|
|
702
|
-
|
|
703
|
-
const display = status || tool ? `${actions.length} filtered` : `${actions.length} action(s)`;
|
|
704
|
-
document.getElementById('count-display').textContent = display;
|
|
705
|
-
document.getElementById('refresh-display').textContent = 'last refresh: ' + new Date().toLocaleTimeString();
|
|
706
|
-
|
|
707
|
-
const badge = document.getElementById('pending-badge');
|
|
708
|
-
badge.style.display = count > 0 ? 'inline' : 'none';
|
|
709
|
-
badge.textContent = `[${count} pending]`;
|
|
710
|
-
} catch (e) {
|
|
711
|
-
document.getElementById('count-display').textContent = 'error fetching actions';
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
// Config viewer toggle
|
|
716
|
-
const configToggle = document.getElementById('config-toggle');
|
|
717
|
-
const configContent = document.getElementById('config-content');
|
|
718
|
-
let configOpen = false;
|
|
719
|
-
|
|
720
|
-
configToggle.addEventListener('click', async () => {
|
|
721
|
-
configOpen = !configOpen;
|
|
722
|
-
configContent.style.display = configOpen ? 'block' : 'none';
|
|
723
|
-
configToggle.textContent = (configOpen ? '▼' : '▶') + ' Current Policy';
|
|
724
|
-
if (configOpen) await loadConfigView();
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
async function loadConfigView() {
|
|
728
|
-
const body = document.getElementById('config-body');
|
|
729
|
-
try {
|
|
730
|
-
const res = await fetch('/api/config');
|
|
731
|
-
const cfg = await res.json();
|
|
732
|
-
const p = cfg.policies || {};
|
|
733
|
-
|
|
734
|
-
function renderList(items, cls) {
|
|
735
|
-
if (!items || !items.length) return '<li style="color:#444">none</li>';
|
|
736
|
-
return items.map(i => {
|
|
737
|
-
if (i.startsWith('regex:')) {
|
|
738
|
-
const pat = i.slice(6);
|
|
739
|
-
return `<li class="${cls}">${pat} <em style="color:#555;font-style:italic">[pattern]</em></li>`;
|
|
740
|
-
}
|
|
741
|
-
return `<li class="${cls}">${i}</li>`;
|
|
742
|
-
}).join('');
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
body.innerHTML = `
|
|
746
|
-
<div class="policy-group">
|
|
747
|
-
<h4>Allowed Paths</h4>
|
|
748
|
-
<ul>${renderList(p.allowedPaths, 'policy-allow')}</ul>
|
|
749
|
-
</div>
|
|
750
|
-
<div class="policy-group">
|
|
751
|
-
<h4>Blocked Paths</h4>
|
|
752
|
-
<ul>${renderList(p.blockedPaths, 'policy-block')}</ul>
|
|
753
|
-
</div>
|
|
754
|
-
<div class="policy-group">
|
|
755
|
-
<h4>Blocked Commands</h4>
|
|
756
|
-
<ul>${renderList(p.blockedCommands, 'policy-block')}</ul>
|
|
757
|
-
</div>
|
|
758
|
-
<div class="policy-group">
|
|
759
|
-
<h4>Require Approval</h4>
|
|
760
|
-
<ul>${renderList(p.requireApproval, 'policy-pending')}</ul>
|
|
761
|
-
</div>
|
|
762
|
-
`;
|
|
763
|
-
} catch (e) {
|
|
764
|
-
body.textContent = 'Error loading config';
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
async function loadProjectName() {
|
|
769
|
-
try {
|
|
770
|
-
const res = await fetch('/api/project');
|
|
771
|
-
const data = await res.json();
|
|
772
|
-
if (data.projectName) {
|
|
773
|
-
document.getElementById('project-name').textContent = '— ' + data.projectName;
|
|
774
|
-
}
|
|
775
|
-
} catch (e) { /* silent — project name is cosmetic */ }
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
// Initial load + interval
|
|
779
|
-
refresh();
|
|
780
|
-
loadProjectName();
|
|
781
|
-
setInterval(refresh, 3000);
|
|
782
|
-
|
|
783
|
-
// Filter event listeners
|
|
784
|
-
document.getElementById('filter-execution').addEventListener('change', refresh);
|
|
785
|
-
document.getElementById('filter-observation').addEventListener('change', refresh);
|
|
786
|
-
|
|
787
|
-
// Phase 3: New filter listeners
|
|
788
|
-
document.getElementById('filter-status').addEventListener('change', refresh);
|
|
789
|
-
document.getElementById('filter-tool').addEventListener('change', refresh);
|
|
790
|
-
document.getElementById('filter-search').addEventListener('input', () => {
|
|
791
|
-
clearTimeout(window.filterSearchTimeout);
|
|
792
|
-
window.filterSearchTimeout = setTimeout(refresh, 500);
|
|
793
|
-
});
|
|
794
|
-
|
|
795
|
-
// Phase 2: Hub navigation sidebar
|
|
796
|
-
function loadHubNav() {
|
|
797
|
-
fetch('/api/hub/projects')
|
|
798
|
-
.then(r => r.json())
|
|
799
|
-
.then(projects => {
|
|
800
|
-
const list = document.getElementById('project-list');
|
|
801
|
-
if (!projects || projects.length === 0) {
|
|
802
|
-
list.innerHTML = '<li style="color:#555;padding:6px 4px">no projects</li>';
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
const current = document.getElementById('project-name').textContent.split('—')[1]?.trim();
|
|
807
|
-
list.innerHTML = projects.map(p => {
|
|
808
|
-
const isCurrent = current && p.projectName.includes(current);
|
|
809
|
-
const statusClass = p.status === 'running' ? 'running' : p.status === 'paused' ? 'paused' : 'stopped';
|
|
810
|
-
const activeClass = isCurrent ? ' current' : '';
|
|
811
|
-
const icon = p.status === 'running' ? '🟢' : p.status === 'paused' ? '⏸️ ' : '🔴';
|
|
812
|
-
return `<li class="project-item ${statusClass}${activeClass}" data-port="${p.port}">${icon} ${p.id}<span class="project-port">${p.port}</span></li>`;
|
|
813
|
-
}).join('');
|
|
814
|
-
|
|
815
|
-
// Add click handlers
|
|
816
|
-
list.querySelectorAll('.project-item').forEach(item => {
|
|
817
|
-
item.addEventListener('click', () => {
|
|
818
|
-
const port = item.dataset.port;
|
|
819
|
-
window.location.href = `http://localhost:${port}`;
|
|
820
|
-
});
|
|
821
|
-
});
|
|
822
|
-
})
|
|
823
|
-
.catch(() => {
|
|
824
|
-
// Hub projects not available — this is Phase 2 feature (optional)
|
|
825
|
-
document.getElementById('hub-toggle').style.display = 'none';
|
|
826
|
-
});
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
// Phase 1 & 2: Tab switching functionality
|
|
830
|
-
document.querySelectorAll('.tab-button').forEach(btn => {
|
|
831
|
-
btn.addEventListener('click', () => {
|
|
832
|
-
const tabName = btn.dataset.tab;
|
|
833
|
-
// Update active button
|
|
834
|
-
document.querySelectorAll('.tab-button').forEach(b => b.classList.remove('active'));
|
|
835
|
-
btn.classList.add('active');
|
|
836
|
-
// Update button styles
|
|
837
|
-
document.querySelectorAll('.tab-button').forEach(b => {
|
|
838
|
-
if (b.classList.contains('active')) {
|
|
839
|
-
b.style.color = '#5ab4ff';
|
|
840
|
-
b.style.borderBottomColor = '#5ab4ff';
|
|
841
|
-
b.style.fontWeight = 'bold';
|
|
842
|
-
} else {
|
|
843
|
-
b.style.color = '#888';
|
|
844
|
-
b.style.borderBottomColor = 'transparent';
|
|
845
|
-
b.style.fontWeight = 'normal';
|
|
846
|
-
}
|
|
847
|
-
});
|
|
848
|
-
// Show/hide tab content
|
|
849
|
-
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
|
850
|
-
document.getElementById(`tab-${tabName}-content`).classList.add('active');
|
|
851
|
-
// Trigger tab-specific load
|
|
852
|
-
if (tabName === 'sessions') {
|
|
853
|
-
loadSessions();
|
|
854
|
-
} else if (tabName === 'team') {
|
|
855
|
-
loadTeamMembers();
|
|
856
|
-
} else if (tabName === 'approvals') {
|
|
857
|
-
loadApprovals();
|
|
858
|
-
} else if (tabName === 'escalations') {
|
|
859
|
-
loadEscalations();
|
|
860
|
-
} else {
|
|
861
|
-
loadActions();
|
|
862
|
-
}
|
|
863
|
-
});
|
|
864
|
-
});
|
|
865
|
-
|
|
866
|
-
// Phase 1: Load sessions and render table
|
|
867
|
-
async function loadSessions() {
|
|
868
|
-
try {
|
|
869
|
-
const res = await fetch('/api/sessions');
|
|
870
|
-
const sessions = await res.json();
|
|
871
|
-
const tbody = document.getElementById('sessions-tbody');
|
|
872
|
-
|
|
873
|
-
if (sessions.length === 0) {
|
|
874
|
-
tbody.innerHTML = '<tr><td colspan="5" class="empty">No sessions yet</td></tr>';
|
|
875
|
-
return;
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
tbody.innerHTML = sessions.map(session => {
|
|
879
|
-
const sessionId = session.session_id || 'unknown';
|
|
880
|
-
const actionCount = session.action_count || 0;
|
|
881
|
-
const latest = session.latest ? timeAgo(session.latest) : 'never';
|
|
882
|
-
|
|
883
|
-
return `
|
|
884
|
-
<tr>
|
|
885
|
-
<td class="session-id" title="${sessionId}">${sessionId.substring(0, 20)}...</td>
|
|
886
|
-
<td>${actionCount}</td>
|
|
887
|
-
<td>${latest}</td>
|
|
888
|
-
<td><span class="session-status-active">active</span></td>
|
|
889
|
-
<td>
|
|
890
|
-
<button class="btn-view-session" onclick="viewSession('${sessionId}')">View</button>
|
|
891
|
-
<button class="btn-rollback-session" onclick="rollbackSession('${sessionId}')">Rollback</button>
|
|
892
|
-
</td>
|
|
893
|
-
</tr>
|
|
894
|
-
`;
|
|
895
|
-
}).join('');
|
|
896
|
-
} catch (err) {
|
|
897
|
-
document.getElementById('sessions-tbody').innerHTML = `<tr><td colspan="5" class="empty">Error loading sessions: ${err.message}</td></tr>`;
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
// Phase 1: View session details
|
|
902
|
-
async function viewSession(sessionId) {
|
|
903
|
-
try {
|
|
904
|
-
const res = await fetch(`/api/sessions/${sessionId}/actions`);
|
|
905
|
-
const data = await res.json();
|
|
906
|
-
const details = document.getElementById('session-details');
|
|
907
|
-
const content = document.getElementById('session-details-content');
|
|
908
|
-
|
|
909
|
-
const actions = data.actions || [];
|
|
910
|
-
const html = `
|
|
911
|
-
<div style="margin-bottom: 8px;">
|
|
912
|
-
<strong>Session:</strong> ${sessionId}<br>
|
|
913
|
-
<strong>Total Actions:</strong> ${actions.length}
|
|
914
|
-
</div>
|
|
915
|
-
<table style="width: 100%; border-collapse: collapse; font-size: 11px;">
|
|
916
|
-
<tr style="background: #1a1a1a;">
|
|
917
|
-
<th style="padding: 4px; text-align: left;">Time</th>
|
|
918
|
-
<th style="padding: 4px; text-align: left;">Tool</th>
|
|
919
|
-
<th style="padding: 4px; text-align: left;">Path/Command</th>
|
|
920
|
-
<th style="padding: 4px; text-align: left;">Status</th>
|
|
921
|
-
</tr>
|
|
922
|
-
${actions.map(a => `
|
|
923
|
-
<tr style="border-bottom: 1px solid #1e1e1e;">
|
|
924
|
-
<td style="padding: 4px;">${timeAgo(a.created_at)}</td>
|
|
925
|
-
<td style="padding: 4px;"><span class="badge badge-${a.tool_name}">${a.tool_name}</span></td>
|
|
926
|
-
<td style="padding: 4px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${a.target_path || '(bash)'}</td>
|
|
927
|
-
<td style="padding: 4px;"><span class="status-${a.status}">${a.status}</span></td>
|
|
928
|
-
</tr>
|
|
929
|
-
`).join('')}
|
|
930
|
-
</table>
|
|
931
|
-
`;
|
|
932
|
-
content.innerHTML = html;
|
|
933
|
-
details.style.display = 'block';
|
|
934
|
-
} catch (err) {
|
|
935
|
-
alert(`Error loading session: ${err.message}`);
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
// Phase 1: Rollback session
|
|
940
|
-
async function rollbackSession(sessionId) {
|
|
941
|
-
if (!confirm(`Rollback session ${sessionId}? This will undo all actions in the session.`)) {
|
|
942
|
-
return;
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
try {
|
|
946
|
-
const res = await fetch(`/api/sessions/${sessionId}/rollback`, { method: 'POST' });
|
|
947
|
-
const result = await res.json();
|
|
948
|
-
|
|
949
|
-
if (res.ok) {
|
|
950
|
-
alert(`✓ Session rolled back: ${result.actions_rolled_back} actions, ${result.files_restored} files restored`);
|
|
951
|
-
loadSessions();
|
|
952
|
-
} else {
|
|
953
|
-
alert(`✗ Rollback failed: ${result.error || 'Unknown error'}`);
|
|
954
|
-
}
|
|
955
|
-
} catch (err) {
|
|
956
|
-
alert(`✗ Error: ${err.message}`);
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
// Phase 2: Load team members
|
|
961
|
-
async function loadTeamMembers() {
|
|
962
|
-
try {
|
|
963
|
-
const res = await fetch('/api/team/members');
|
|
964
|
-
const members = await res.json();
|
|
965
|
-
const tbody = document.getElementById('team-tbody');
|
|
966
|
-
|
|
967
|
-
if (members.length === 0) {
|
|
968
|
-
tbody.innerHTML = '<tr><td colspan="5" class="empty">No team members added</td></tr>';
|
|
969
|
-
return;
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
tbody.innerHTML = members.map(member => {
|
|
973
|
-
const addedDate = member.added_at ? timeAgo(member.added_at) : 'unknown';
|
|
974
|
-
return `
|
|
975
|
-
<tr>
|
|
976
|
-
<td>${member.name}</td>
|
|
977
|
-
<td>${member.email}</td>
|
|
978
|
-
<td>${member.role || 'approver'}</td>
|
|
979
|
-
<td>${addedDate}</td>
|
|
980
|
-
<td>
|
|
981
|
-
<button class="btn-remove-member" onclick="removeTeamMember('${member.member_id}')">Remove</button>
|
|
982
|
-
</td>
|
|
983
|
-
</tr>
|
|
984
|
-
`;
|
|
985
|
-
}).join('');
|
|
986
|
-
} catch (err) {
|
|
987
|
-
document.getElementById('team-tbody').innerHTML = `<tr><td colspan="5" class="empty">Error loading team members: ${err.message}</td></tr>`;
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
// Phase 2: Remove team member
|
|
992
|
-
async function removeTeamMember(memberId) {
|
|
993
|
-
if (!confirm('Remove this team member?')) return;
|
|
994
|
-
|
|
995
|
-
try {
|
|
996
|
-
const res = await fetch(`/api/team/members/${memberId}`, { method: 'DELETE' });
|
|
997
|
-
const result = await res.json();
|
|
998
|
-
|
|
999
|
-
if (res.ok) {
|
|
1000
|
-
alert('✓ Team member removed');
|
|
1001
|
-
loadTeamMembers();
|
|
1002
|
-
} else {
|
|
1003
|
-
alert(`✗ Error: ${result.error}`);
|
|
1004
|
-
}
|
|
1005
|
-
} catch (err) {
|
|
1006
|
-
alert(`✗ Error: ${err.message}`);
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
// Phase 2: Add team member button
|
|
1011
|
-
document.addEventListener('DOMContentLoaded', () => {
|
|
1012
|
-
const addBtn = document.getElementById('add-team-member-btn');
|
|
1013
|
-
if (addBtn) {
|
|
1014
|
-
addBtn.addEventListener('click', async () => {
|
|
1015
|
-
const name = document.getElementById('new-member-name').value.trim();
|
|
1016
|
-
const email = document.getElementById('new-member-email').value.trim();
|
|
1017
|
-
|
|
1018
|
-
if (!name || !email) {
|
|
1019
|
-
alert('Please enter name and email');
|
|
1020
|
-
return;
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
try {
|
|
1024
|
-
const res = await fetch('/api/team/members', {
|
|
1025
|
-
method: 'POST',
|
|
1026
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1027
|
-
body: JSON.stringify({
|
|
1028
|
-
member_id: `member-${Date.now()}`,
|
|
1029
|
-
name,
|
|
1030
|
-
email,
|
|
1031
|
-
}),
|
|
1032
|
-
});
|
|
1033
|
-
|
|
1034
|
-
const result = await res.json();
|
|
1035
|
-
if (res.ok) {
|
|
1036
|
-
document.getElementById('new-member-name').value = '';
|
|
1037
|
-
document.getElementById('new-member-email').value = '';
|
|
1038
|
-
loadTeamMembers();
|
|
1039
|
-
} else {
|
|
1040
|
-
alert(`✗ Error: ${result.error}`);
|
|
1041
|
-
}
|
|
1042
|
-
} catch (err) {
|
|
1043
|
-
alert(`✗ Error: ${err.message}`);
|
|
1044
|
-
}
|
|
1045
|
-
});
|
|
1046
|
-
}
|
|
1047
|
-
});
|
|
1048
|
-
|
|
1049
|
-
// Phase 2: Load approvals and routing rules
|
|
1050
|
-
async function loadApprovals() {
|
|
1051
|
-
try {
|
|
1052
|
-
// Load routing rules
|
|
1053
|
-
const routesRes = await fetch('/api/approval-routes');
|
|
1054
|
-
const routes = await routesRes.json();
|
|
1055
|
-
const routesTbody = document.getElementById('routes-tbody');
|
|
1056
|
-
|
|
1057
|
-
if (routes.length === 0) {
|
|
1058
|
-
routesTbody.innerHTML = '<tr><td colspan="5" class="empty">No approval routes configured</td></tr>';
|
|
1059
|
-
} else {
|
|
1060
|
-
routesTbody.innerHTML = routes.map(route => {
|
|
1061
|
-
const approvers = JSON.parse(route.approver_ids || '[]');
|
|
1062
|
-
const createdDate = route.created_at ? timeAgo(route.created_at) : 'unknown';
|
|
1063
|
-
return `
|
|
1064
|
-
<tr>
|
|
1065
|
-
<td>${route.name}</td>
|
|
1066
|
-
<td>${route.condition_type || 'all_sessions'}</td>
|
|
1067
|
-
<td>${approvers.join(', ')}</td>
|
|
1068
|
-
<td>${createdDate}</td>
|
|
1069
|
-
<td>
|
|
1070
|
-
<button class="btn-delete-route" onclick="deleteApprovalRoute('${route.route_id}')">Delete</button>
|
|
1071
|
-
</td>
|
|
1072
|
-
</tr>
|
|
1073
|
-
`;
|
|
1074
|
-
}).join('');
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
// Load pending approvals
|
|
1078
|
-
const approvalsRes = await fetch('/api/approvals/pending');
|
|
1079
|
-
const approvals = await approvalsRes.json();
|
|
1080
|
-
const approvalsTbody = document.getElementById('approvals-queue-tbody');
|
|
1081
|
-
|
|
1082
|
-
if (approvals.length === 0) {
|
|
1083
|
-
approvalsTbody.innerHTML = '<tr><td colspan="6" class="empty">No pending approvals</td></tr>';
|
|
1084
|
-
} else {
|
|
1085
|
-
approvalsTbody.innerHTML = approvals.map(approval => {
|
|
1086
|
-
const triggeredDate = approval.triggered_at ? timeAgo(approval.triggered_at) : 'unknown';
|
|
1087
|
-
const approvers = JSON.parse(approval.approver_ids || '[]');
|
|
1088
|
-
const needed = approvers.length - approval.approved_count;
|
|
1089
|
-
return `
|
|
1090
|
-
<tr>
|
|
1091
|
-
<td>${approval.session_id.substring(0, 20)}...</td>
|
|
1092
|
-
<td>${approval.triggered_by}</td>
|
|
1093
|
-
<td>${approval.status}</td>
|
|
1094
|
-
<td>${needed} of ${approvers.length}</td>
|
|
1095
|
-
<td>${triggeredDate}</td>
|
|
1096
|
-
<td>
|
|
1097
|
-
<button class="btn-view-approval" onclick="viewApproval('${approval.request_id}')">View</button>
|
|
1098
|
-
</td>
|
|
1099
|
-
</tr>
|
|
1100
|
-
`;
|
|
1101
|
-
}).join('');
|
|
1102
|
-
}
|
|
1103
|
-
} catch (err) {
|
|
1104
|
-
document.getElementById('routes-tbody').innerHTML = `<tr><td colspan="5" class="empty">Error: ${err.message}</td></tr>`;
|
|
1105
|
-
document.getElementById('approvals-queue-tbody').innerHTML = `<tr><td colspan="6" class="empty">Error: ${err.message}</td></tr>`;
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
// Phase 2: Delete approval route
|
|
1110
|
-
async function deleteApprovalRoute(routeId) {
|
|
1111
|
-
if (!confirm('Delete this approval route?')) return;
|
|
1112
|
-
|
|
1113
|
-
try {
|
|
1114
|
-
const res = await fetch(`/api/approval-routes/${routeId}`, { method: 'DELETE' });
|
|
1115
|
-
const result = await res.json();
|
|
1116
|
-
|
|
1117
|
-
if (res.ok) {
|
|
1118
|
-
loadApprovals();
|
|
1119
|
-
} else {
|
|
1120
|
-
alert(`✗ Error: ${result.error}`);
|
|
1121
|
-
}
|
|
1122
|
-
} catch (err) {
|
|
1123
|
-
alert(`✗ Error: ${err.message}`);
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
// Phase 2: View approval details
|
|
1128
|
-
async function viewApproval(requestId) {
|
|
1129
|
-
try {
|
|
1130
|
-
const res = await fetch(`/api/approvals/${requestId}`);
|
|
1131
|
-
const data = await res.json();
|
|
1132
|
-
const approval = data.request;
|
|
1133
|
-
const status = data.status;
|
|
1134
|
-
|
|
1135
|
-
let details = `
|
|
1136
|
-
<div style="background: #0a0a0a; padding: 12px; border-radius: 3px; margin-top: 8px;">
|
|
1137
|
-
<h4>Approval Request Details</h4>
|
|
1138
|
-
<p><strong>Request ID:</strong> ${approval.request_id}</p>
|
|
1139
|
-
<p><strong>Session:</strong> ${approval.session_id}</p>
|
|
1140
|
-
<p><strong>Requester:</strong> ${approval.triggered_by}</p>
|
|
1141
|
-
<p><strong>Status:</strong> ${approval.status}</p>
|
|
1142
|
-
<p><strong>Approvals:</strong> ${approval.approved_count} approved, ${approval.rejected_count} rejected</p>
|
|
1143
|
-
`;
|
|
1144
|
-
|
|
1145
|
-
if (status) {
|
|
1146
|
-
details += `
|
|
1147
|
-
<h4 style="margin-top: 12px;">Approval Decisions</h4>
|
|
1148
|
-
<table style="width: 100%; font-size: 11px;">
|
|
1149
|
-
<tr style="background: #1a1a1a;">
|
|
1150
|
-
<th style="padding: 4px; text-align: left;">Approver</th>
|
|
1151
|
-
<th style="padding: 4px; text-align: left;">Decision</th>
|
|
1152
|
-
<th style="padding: 4px; text-align: left;">Reason</th>
|
|
1153
|
-
</tr>
|
|
1154
|
-
${status.decisions.map(d => `
|
|
1155
|
-
<tr style="border-bottom: 1px solid #1e1e1e;">
|
|
1156
|
-
<td style="padding: 4px;">${d.approver_id}</td>
|
|
1157
|
-
<td style="padding: 4px;"><span style="color: ${d.decision === 'approve' ? '#4caf50' : '#f44336'}">${d.decision}</span></td>
|
|
1158
|
-
<td style="padding: 4px;">${d.reason || '—'}</td>
|
|
1159
|
-
</tr>
|
|
1160
|
-
`).join('')}
|
|
1161
|
-
</table>
|
|
1162
|
-
`;
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
details += '</div>';
|
|
1166
|
-
alert(details);
|
|
1167
|
-
} catch (err) {
|
|
1168
|
-
alert(`Error loading approval: ${err.message}`);
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
// Phase 3: Load escalation rules and pending escalations
|
|
1173
|
-
async function loadEscalations() {
|
|
1174
|
-
try {
|
|
1175
|
-
const [rulesRes, pendingRes] = await Promise.all([
|
|
1176
|
-
fetch('/api/escalations/rules'),
|
|
1177
|
-
fetch('/api/escalations/pending'),
|
|
1178
|
-
]);
|
|
1179
|
-
|
|
1180
|
-
const rulesData = await rulesRes.json();
|
|
1181
|
-
const pendingData = await pendingRes.json();
|
|
1182
|
-
|
|
1183
|
-
const rules = rulesData.rules || [];
|
|
1184
|
-
const pending = pendingData.escalations || [];
|
|
1185
|
-
|
|
1186
|
-
// Populate escalation rules table
|
|
1187
|
-
const rulesTbody = document.getElementById('escalation-rules-tbody');
|
|
1188
|
-
if (rules.length === 0) {
|
|
1189
|
-
rulesTbody.innerHTML = '<tr><td colspan="5" class="empty">No escalation rules configured</td></tr>';
|
|
1190
|
-
} else {
|
|
1191
|
-
rulesTbody.innerHTML = rules.map(rule => {
|
|
1192
|
-
const createdDate = rule.created_at ? timeAgo(rule.created_at) : 'unknown';
|
|
1193
|
-
const targets = rule.escalation_targets ? JSON.parse(rule.escalation_targets).join(', ') : '—';
|
|
1194
|
-
return `
|
|
1195
|
-
<tr>
|
|
1196
|
-
<td>${rule.name}</td>
|
|
1197
|
-
<td>${rule.timeout_hours}</td>
|
|
1198
|
-
<td>${targets}</td>
|
|
1199
|
-
<td>${createdDate}</td>
|
|
1200
|
-
<td>
|
|
1201
|
-
<button class="btn-delete-escalation" onclick="deleteEscalationRule('${rule.rule_id}')">Delete</button>
|
|
1202
|
-
</td>
|
|
1203
|
-
</tr>
|
|
1204
|
-
`;
|
|
1205
|
-
}).join('');
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
// Populate pending escalations table
|
|
1209
|
-
const escalationsTbody = document.getElementById('escalation-queue-tbody');
|
|
1210
|
-
if (pending.length === 0) {
|
|
1211
|
-
escalationsTbody.innerHTML = '<tr><td colspan="5" class="empty">No pending escalations</td></tr>';
|
|
1212
|
-
} else {
|
|
1213
|
-
escalationsTbody.innerHTML = pending.map(escalation => {
|
|
1214
|
-
const deadlineDate = escalation.escalation_deadline ? timeAgo(escalation.escalation_deadline) : 'unknown';
|
|
1215
|
-
const status = escalation.status || 'pending';
|
|
1216
|
-
return `
|
|
1217
|
-
<tr>
|
|
1218
|
-
<td>${escalation.session_id.substring(0, 20)}...</td>
|
|
1219
|
-
<td>${escalation.request_id.substring(0, 20) || '—'}...</td>
|
|
1220
|
-
<td><span style="color: ${status === 'blocked' ? '#f44336' : status === 'proceeded' ? '#4caf50' : '#ffb347'}">${status}</span></td>
|
|
1221
|
-
<td>${deadlineDate}</td>
|
|
1222
|
-
<td>
|
|
1223
|
-
<button class="btn-view-escalation" onclick="viewEscalation('${escalation.request_id}')">View</button>
|
|
1224
|
-
</td>
|
|
1225
|
-
</tr>
|
|
1226
|
-
`;
|
|
1227
|
-
}).join('');
|
|
1228
|
-
}
|
|
1229
|
-
} catch (err) {
|
|
1230
|
-
document.getElementById('escalation-rules-tbody').innerHTML = `<tr><td colspan="5" class="empty">Error: ${err.message}</td></tr>`;
|
|
1231
|
-
document.getElementById('escalation-queue-tbody').innerHTML = `<tr><td colspan="5" class="empty">Error: ${err.message}</td></tr>`;
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
// Phase 3: Delete escalation rule
|
|
1236
|
-
async function deleteEscalationRule(ruleId) {
|
|
1237
|
-
if (!confirm('Delete this escalation rule?')) return;
|
|
1238
|
-
|
|
1239
|
-
try {
|
|
1240
|
-
const res = await fetch(`/api/escalations/rules/${ruleId}`, { method: 'DELETE' });
|
|
1241
|
-
const result = await res.json();
|
|
1242
|
-
|
|
1243
|
-
if (res.ok) {
|
|
1244
|
-
loadEscalations();
|
|
1245
|
-
} else {
|
|
1246
|
-
alert(`✗ Error: ${result.error}`);
|
|
1247
|
-
}
|
|
1248
|
-
} catch (err) {
|
|
1249
|
-
alert(`✗ Error: ${err.message}`);
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
// Phase 3: View escalation details
|
|
1254
|
-
async function viewEscalation(requestId) {
|
|
1255
|
-
try {
|
|
1256
|
-
const res = await fetch(`/api/escalations/${requestId}`);
|
|
1257
|
-
const data = await res.json();
|
|
1258
|
-
const escalation = data.escalation;
|
|
1259
|
-
const status = data.status;
|
|
1260
|
-
|
|
1261
|
-
let details = `
|
|
1262
|
-
<div style="background: #0a0a0a; padding: 12px; border-radius: 3px; margin-top: 8px;">
|
|
1263
|
-
<h4>Escalation Request Details</h4>
|
|
1264
|
-
<p><strong>Request ID:</strong> ${escalation.request_id}</p>
|
|
1265
|
-
<p><strong>Session:</strong> ${escalation.session_id}</p>
|
|
1266
|
-
<p><strong>Approval Request:</strong> ${escalation.approval_request_id}</p>
|
|
1267
|
-
<p><strong>Status:</strong> ${escalation.status}</p>
|
|
1268
|
-
<p><strong>Triggered:</strong> ${escalation.escalation_triggered_at || 'unknown'}</p>
|
|
1269
|
-
<p><strong>Deadline:</strong> ${escalation.escalation_deadline || 'unknown'}</p>
|
|
1270
|
-
`;
|
|
1271
|
-
|
|
1272
|
-
if (status && status.decisions) {
|
|
1273
|
-
details += `
|
|
1274
|
-
<h4 style="margin-top: 12px;">Escalation Decisions</h4>
|
|
1275
|
-
<table style="width: 100%; font-size: 11px;">
|
|
1276
|
-
<tr style="background: #1a1a1a;">
|
|
1277
|
-
<th style="padding: 4px; text-align: left;">Target</th>
|
|
1278
|
-
<th style="padding: 4px; text-align: left;">Decision</th>
|
|
1279
|
-
<th style="padding: 4px; text-align: left;">Reason</th>
|
|
1280
|
-
</tr>
|
|
1281
|
-
${status.decisions.map(d => `
|
|
1282
|
-
<tr style="border-bottom: 1px solid #1e1e1e;">
|
|
1283
|
-
<td style="padding: 4px;">${d.target_id}</td>
|
|
1284
|
-
<td style="padding: 4px;"><span style="color: ${d.decision === 'proceed' ? '#4caf50' : '#f44336'}">${d.decision}</span></td>
|
|
1285
|
-
<td style="padding: 4px;">${d.reason || '—'}</td>
|
|
1286
|
-
</tr>
|
|
1287
|
-
`).join('')}
|
|
1288
|
-
</table>
|
|
1289
|
-
`;
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
// Add decision buttons if escalation is still pending
|
|
1293
|
-
if (escalation.status === 'pending') {
|
|
1294
|
-
details += `
|
|
1295
|
-
<div style="margin-top: 12px;">
|
|
1296
|
-
<button onclick="submitEscalationDecision('${requestId}', 'proceed')" style="padding: 6px 12px; background: #2d5a2d; border: 1px solid #4caf50; color: #4caf50; cursor: pointer; margin-right: 8px; border-radius: 2px;">✅ Proceed</button>
|
|
1297
|
-
<button onclick="submitEscalationDecision('${requestId}', 'block')" style="padding: 6px 12px; background: #5a2d2d; border: 1px solid #f44336; color: #f44336; cursor: pointer; border-radius: 2px;">❌ Block</button>
|
|
1298
|
-
</div>
|
|
1299
|
-
`;
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
details += '</div>';
|
|
1303
|
-
alert(details);
|
|
1304
|
-
} catch (err) {
|
|
1305
|
-
alert(`Error loading escalation: ${err.message}`);
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
// Phase 3: Submit escalation decision (proceed or block)
|
|
1310
|
-
async function submitEscalationDecision(requestId, decision) {
|
|
1311
|
-
const reason = prompt(`Enter reason for ${decision}ing this escalation (optional):`);
|
|
1312
|
-
|
|
1313
|
-
try {
|
|
1314
|
-
const res = await fetch(`/api/escalations/${requestId}/decide`, {
|
|
1315
|
-
method: 'POST',
|
|
1316
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1317
|
-
body: JSON.stringify({ decision, reason: reason || undefined }),
|
|
1318
|
-
});
|
|
1319
|
-
|
|
1320
|
-
const result = await res.json();
|
|
1321
|
-
if (res.ok) {
|
|
1322
|
-
alert(`✓ Escalation ${decision === 'proceed' ? 'allowed' : 'blocked'} successfully`);
|
|
1323
|
-
loadEscalations();
|
|
1324
|
-
} else {
|
|
1325
|
-
alert(`✗ Error: ${result.error}`);
|
|
1326
|
-
}
|
|
1327
|
-
} catch (err) {
|
|
1328
|
-
alert(`✗ Error: ${err.message}`);
|
|
1329
|
-
}
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
// Phase 3: Create escalation rule
|
|
1333
|
-
async function createEscalationRule() {
|
|
1334
|
-
const name = document.getElementById('new-escalation-name').value;
|
|
1335
|
-
const timeout = parseInt(document.getElementById('new-escalation-timeout').value);
|
|
1336
|
-
|
|
1337
|
-
if (!name || !timeout) {
|
|
1338
|
-
alert('Please enter rule name and timeout (hours)');
|
|
1339
|
-
return;
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
try {
|
|
1343
|
-
const res = await fetch('/api/escalations/rules', {
|
|
1344
|
-
method: 'POST',
|
|
1345
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1346
|
-
body: JSON.stringify({ name, timeout_hours: timeout }),
|
|
1347
|
-
});
|
|
1348
|
-
|
|
1349
|
-
const result = await res.json();
|
|
1350
|
-
if (res.ok) {
|
|
1351
|
-
document.getElementById('new-escalation-name').value = '';
|
|
1352
|
-
document.getElementById('new-escalation-timeout').value = '';
|
|
1353
|
-
loadEscalations();
|
|
1354
|
-
} else {
|
|
1355
|
-
alert(`✗ Error: ${result.error}`);
|
|
1356
|
-
}
|
|
1357
|
-
} catch (err) {
|
|
1358
|
-
alert(`✗ Error: ${err.message}`);
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
// Phase 3: Event listener for create escalation rule button
|
|
1363
|
-
document.getElementById('create-escalation-rule-btn').addEventListener('click', createEscalationRule);
|
|
1364
|
-
|
|
1365
|
-
// Phase 4: Load remediation assessment
|
|
1366
|
-
async function loadRemediation() {
|
|
1367
|
-
try {
|
|
1368
|
-
// Placeholder: In full implementation, would analyze current session
|
|
1369
|
-
document.getElementById('risk-score').textContent = '5.0';
|
|
1370
|
-
document.getElementById('risk-level').textContent = 'MEDIUM';
|
|
1371
|
-
document.getElementById('risk-factors').innerHTML = `
|
|
1372
|
-
<div style="margin-bottom: 8px;">
|
|
1373
|
-
<strong>Risk Factors:</strong>
|
|
1374
|
-
<ul style="margin: 4px 0; padding-left: 16px; font-size: 11px;">
|
|
1375
|
-
<li>Operation Type: Medium (2.0/3.0)</li>
|
|
1376
|
-
<li>Scale: Medium (1.5/3.0)</li>
|
|
1377
|
-
<li>Error Pattern: Low (0.5/3.0)</li>
|
|
1378
|
-
<li>Time: Recent (0.5/3.0)</li>
|
|
1379
|
-
<li>System Load: Moderate (1.0/3.0)</li>
|
|
1380
|
-
</ul>
|
|
1381
|
-
</div>
|
|
1382
|
-
`;
|
|
1383
|
-
|
|
1384
|
-
// Policy compliance
|
|
1385
|
-
document.getElementById('policy-status-tbody').innerHTML = `
|
|
1386
|
-
<tr>
|
|
1387
|
-
<td>Data Protection</td>
|
|
1388
|
-
<td>data</td>
|
|
1389
|
-
<td><span style="color: #4caf50;">✓ compliant</span></td>
|
|
1390
|
-
<td>0</td>
|
|
1391
|
-
</tr>
|
|
1392
|
-
<tr>
|
|
1393
|
-
<td>Security Policy</td>
|
|
1394
|
-
<td>security</td>
|
|
1395
|
-
<td><span style="color: #4caf50;">✓ compliant</span></td>
|
|
1396
|
-
<td>0</td>
|
|
1397
|
-
</tr>
|
|
1398
|
-
`;
|
|
1399
|
-
|
|
1400
|
-
// Recommendations
|
|
1401
|
-
document.getElementById('primary-strategy').textContent = 'Partial Rollback (safe operations only)';
|
|
1402
|
-
document.getElementById('alternative-strategies').innerHTML = `
|
|
1403
|
-
<div>• Staged Rollback (phases with verification)</div>
|
|
1404
|
-
<div>• Escalation (manual expert review)</div>
|
|
1405
|
-
`;
|
|
1406
|
-
document.getElementById('estimated-safety').textContent = '75% - Good safety profile';
|
|
1407
|
-
document.getElementById('required-approvals').textContent = 'Engineering Lead';
|
|
1408
|
-
|
|
1409
|
-
// Block rules
|
|
1410
|
-
document.getElementById('block-rules-tbody').innerHTML = `
|
|
1411
|
-
<tr>
|
|
1412
|
-
<td>Production Delete Hours</td>
|
|
1413
|
-
<td>tool_name = delete_file</td>
|
|
1414
|
-
<td>BLOCK</td>
|
|
1415
|
-
<td>Cannot delete files during off-hours</td>
|
|
1416
|
-
</tr>
|
|
1417
|
-
<tr>
|
|
1418
|
-
<td>Large Batch Delete</td>
|
|
1419
|
-
<td>action_count > 20</td>
|
|
1420
|
-
<td>REQUIRE_APPROVAL</td>
|
|
1421
|
-
<td>Large batch deletion requires approval</td>
|
|
1422
|
-
</tr>
|
|
1423
|
-
`;
|
|
1424
|
-
} catch (err) {
|
|
1425
|
-
document.getElementById('risk-score').textContent = 'Error';
|
|
1426
|
-
alert(`Error loading remediation assessment: ${err.message}`);
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
// Event listener for Run Assessment button
|
|
1431
|
-
document.getElementById('run-assessment-btn').addEventListener('click', async () => {
|
|
1432
|
-
document.getElementById('run-assessment-btn').disabled = true;
|
|
1433
|
-
document.getElementById('run-assessment-btn').textContent = 'Assessing...';
|
|
1434
|
-
await loadRemediation();
|
|
1435
|
-
document.getElementById('run-assessment-btn').disabled = false;
|
|
1436
|
-
document.getElementById('run-assessment-btn').textContent = 'Run Risk Assessment';
|
|
1437
|
-
});
|
|
1438
|
-
|
|
1439
|
-
// Hub toggle button
|
|
1440
|
-
document.getElementById('hub-toggle').addEventListener('click', () => {
|
|
1441
|
-
const nav = document.getElementById('hub-nav');
|
|
1442
|
-
const content = document.getElementById('main-content');
|
|
1443
|
-
nav.classList.toggle('open');
|
|
1444
|
-
content.classList.toggle('with-nav');
|
|
1445
|
-
loadHubNav();
|
|
1446
|
-
});
|
|
1447
|
-
|
|
1448
|
-
// Load hub projects on startup (lazy)
|
|
1449
|
-
setTimeout(loadHubNav, 2000);
|
|
1450
|
-
</script>
|
|
1451
|
-
</body>
|
|
1452
|
-
</html>
|