tdoms-mcp 0.6.1
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/LICENSE +21 -0
- package/README.md +164 -0
- package/package.json +52 -0
- package/skills/tdoms-mcp-routing/SKILL.md +48 -0
- package/skills/tdoms-mcp-routing/agents/openai.yaml +4 -0
- package/src/capability-registry.js +124 -0
- package/src/client-manager.js +571 -0
- package/src/config.js +49 -0
- package/src/connection-store.js +126 -0
- package/src/index.js +79 -0
- package/src/mcp-server.js +2771 -0
- package/src/secret-store.js +109 -0
- package/src/storage-utils.js +176 -0
- package/src/tdoms-client.js +172 -0
- package/src/ui-page.js +408 -0
- package/src/web-app.js +127 -0
package/src/ui-page.js
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
export function renderPage() {
|
|
2
|
+
return `<!doctype html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<title>TD/OMS MCP Console</title>
|
|
8
|
+
<link rel="icon" href="/icons/server.svg" type="image/svg+xml">
|
|
9
|
+
<style>
|
|
10
|
+
:root {
|
|
11
|
+
color-scheme: light;
|
|
12
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
13
|
+
color: #17202a;
|
|
14
|
+
background: #f4f6f8;
|
|
15
|
+
font-synthesis: none;
|
|
16
|
+
}
|
|
17
|
+
* { box-sizing: border-box; }
|
|
18
|
+
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
|
19
|
+
button, input, select { font: inherit; letter-spacing: 0; }
|
|
20
|
+
button { cursor: pointer; }
|
|
21
|
+
button:disabled { cursor: not-allowed; opacity: .48; }
|
|
22
|
+
.shell { min-height: 100vh; }
|
|
23
|
+
.topbar { background: #162433; color: #fff; border-bottom: 3px solid #2f7d67; }
|
|
24
|
+
.topbar-inner { width: min(1180px, calc(100% - 40px)); min-height: 68px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
|
25
|
+
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
|
26
|
+
.brand-mark { width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid #536171; border-radius: 6px; background: #223445; }
|
|
27
|
+
.brand-mark img { width: 19px; height: 19px; filter: brightness(0) invert(1); }
|
|
28
|
+
.brand-name { font-size: 17px; font-weight: 700; }
|
|
29
|
+
.brand-subtitle { margin-top: 2px; color: #b9c4ce; font-size: 12px; }
|
|
30
|
+
.local-state { display: flex; align-items: center; gap: 8px; color: #d8e1e7; font-size: 12px; white-space: nowrap; }
|
|
31
|
+
.state-dot { width: 8px; height: 8px; border-radius: 50%; background: #45b58f; box-shadow: 0 0 0 3px rgba(69,181,143,.16); }
|
|
32
|
+
.nav-band { background: #fff; border-bottom: 1px solid #d9e0e6; }
|
|
33
|
+
.nav-inner { width: min(1180px, calc(100% - 40px)); margin: 0 auto; display: flex; align-items: center; justify-content: space-between; min-height: 52px; gap: 16px; }
|
|
34
|
+
.tabs { display: flex; align-self: stretch; gap: 24px; }
|
|
35
|
+
.tab { position: relative; border: 0; background: transparent; color: #556270; padding: 0 2px; font-size: 13px; font-weight: 650; }
|
|
36
|
+
.tab.active { color: #17202a; }
|
|
37
|
+
.tab.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: #2f7d67; }
|
|
38
|
+
.icon-button { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #ccd5dd; border-radius: 6px; background: #fff; }
|
|
39
|
+
.icon-button:hover { background: #f4f7f9; }
|
|
40
|
+
.icon-button img { width: 16px; height: 16px; }
|
|
41
|
+
main { width: min(1180px, calc(100% - 40px)); margin: 0 auto; padding: 24px 0 42px; }
|
|
42
|
+
.summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid #d8e0e6; border-radius: 8px; background: #fff; overflow: hidden; margin-bottom: 20px; }
|
|
43
|
+
.metric { padding: 15px 18px; border-right: 1px solid #e1e6ea; display: flex; align-items: center; gap: 12px; min-width: 0; }
|
|
44
|
+
.metric:last-child { border-right: 0; }
|
|
45
|
+
.metric-icon { width: 34px; height: 34px; border-radius: 6px; display: grid; place-items: center; background: #eef4f2; }
|
|
46
|
+
.metric-icon.blue { background: #edf2f7; }
|
|
47
|
+
.metric-icon.amber { background: #fbf3e3; }
|
|
48
|
+
.metric-icon img { width: 17px; height: 17px; }
|
|
49
|
+
.metric-value { font-size: 18px; line-height: 1; font-weight: 750; }
|
|
50
|
+
.metric-label { margin-top: 4px; color: #687582; font-size: 11px; text-transform: uppercase; font-weight: 700; }
|
|
51
|
+
.view { display: none; }
|
|
52
|
+
.view.active { display: block; }
|
|
53
|
+
.workspace { display: grid; grid-template-columns: minmax(320px, 410px) minmax(0, 1fr); gap: 20px; align-items: start; }
|
|
54
|
+
.panel { border: 1px solid #d8e0e6; border-radius: 8px; background: #fff; overflow: hidden; }
|
|
55
|
+
.panel-header { min-height: 51px; padding: 0 18px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e1e6ea; gap: 12px; }
|
|
56
|
+
.panel-title { font-size: 14px; font-weight: 720; }
|
|
57
|
+
.panel-count { color: #71808d; font-size: 12px; }
|
|
58
|
+
.panel-body { padding: 18px; }
|
|
59
|
+
form { display: grid; gap: 14px; }
|
|
60
|
+
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
61
|
+
label { display: grid; gap: 6px; color: #44515e; font-size: 12px; font-weight: 650; }
|
|
62
|
+
input, select { width: 100%; min-height: 38px; border: 1px solid #bcc7d0; border-radius: 6px; padding: 8px 10px; background: #fff; color: #17202a; outline: none; }
|
|
63
|
+
input:focus, select:focus { border-color: #2f7d67; box-shadow: 0 0 0 3px rgba(47,125,103,.12); }
|
|
64
|
+
.check-row { display: flex; align-items: center; gap: 9px; min-height: 30px; color: #33414e; }
|
|
65
|
+
.check-row input { width: 16px; min-height: 16px; margin: 0; }
|
|
66
|
+
details { border-top: 1px solid #e5e9ed; padding-top: 12px; }
|
|
67
|
+
summary { color: #455361; font-size: 12px; font-weight: 700; cursor: pointer; }
|
|
68
|
+
.details-fields { display: grid; gap: 12px; margin-top: 12px; }
|
|
69
|
+
details:not([open]) .details-fields { display: none; }
|
|
70
|
+
.form-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; }
|
|
71
|
+
.button { min-height: 36px; border: 1px solid #1f6d59; border-radius: 6px; padding: 7px 12px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; background: #2f7d67; color: #fff; font-size: 12px; font-weight: 700; white-space: nowrap; }
|
|
72
|
+
.button:hover { background: #276b58; }
|
|
73
|
+
.button.secondary { background: #fff; border-color: #bdc8d0; color: #283642; }
|
|
74
|
+
.button.secondary:hover { background: #f5f7f8; }
|
|
75
|
+
.button.danger { background: #fff; border-color: #d8b6b6; color: #9a3333; }
|
|
76
|
+
.button img { width: 15px; height: 15px; filter: brightness(0) invert(1); }
|
|
77
|
+
.button.secondary img, .button.danger img { filter: none; }
|
|
78
|
+
.empty { min-height: 215px; display: grid; place-items: center; color: #75828e; text-align: center; font-size: 13px; }
|
|
79
|
+
.list { display: grid; }
|
|
80
|
+
.connection-row, .client-row { padding: 16px 18px; border-bottom: 1px solid #e5e9ed; display: grid; gap: 10px; }
|
|
81
|
+
.connection-row:last-child, .client-row:last-child { border-bottom: 0; }
|
|
82
|
+
.row-main { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
|
|
83
|
+
.row-identity { min-width: 0; display: flex; align-items: flex-start; gap: 11px; }
|
|
84
|
+
.row-icon { width: 34px; height: 34px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 6px; background: #eef2f5; }
|
|
85
|
+
.row-icon img { width: 17px; height: 17px; }
|
|
86
|
+
.row-name { font-size: 13px; font-weight: 730; }
|
|
87
|
+
.row-description { margin-top: 3px; color: #687582; font-size: 12px; overflow-wrap: anywhere; }
|
|
88
|
+
.row-path { padding-left: 45px; color: #75818c; font: 11px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
|
|
89
|
+
.row-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
|
90
|
+
.status { display: inline-flex; align-items: center; gap: 6px; min-height: 24px; border-radius: 999px; padding: 3px 8px; font-size: 10px; font-weight: 750; text-transform: uppercase; white-space: nowrap; }
|
|
91
|
+
.status.configured { color: #17634f; background: #e8f4ef; }
|
|
92
|
+
.status.detected { color: #805f19; background: #fbf2dc; }
|
|
93
|
+
.status.not-detected { color: #687582; background: #eef1f3; }
|
|
94
|
+
.status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
|
95
|
+
.clients-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 14px; margin-bottom: 14px; }
|
|
96
|
+
.clients-toolbar-actions { display: flex; align-items: center; justify-content: flex-end; gap: 12px; flex-wrap: wrap; }
|
|
97
|
+
.section-heading { font-size: 15px; font-weight: 750; }
|
|
98
|
+
.environment-label { margin-top: 3px; color: #687582; font-size: 11px; }
|
|
99
|
+
.filter { display: flex; align-items: center; gap: 7px; color: #66737f; font-size: 12px; }
|
|
100
|
+
.filter input { width: 16px; min-height: 16px; }
|
|
101
|
+
.activity { position: fixed; right: 20px; bottom: 20px; max-width: min(420px, calc(100% - 40px)); min-height: 42px; padding: 11px 14px; display: none; align-items: center; gap: 9px; border: 1px solid #cbd5dc; border-radius: 7px; background: #172431; color: #fff; box-shadow: 0 12px 34px rgba(18,31,43,.2); font-size: 12px; z-index: 30; }
|
|
102
|
+
.activity.show { display: flex; }
|
|
103
|
+
.activity.error { background: #7e2e2e; border-color: #7e2e2e; }
|
|
104
|
+
.activity img { width: 16px; height: 16px; filter: brightness(0) invert(1); }
|
|
105
|
+
dialog { width: min(470px, calc(100% - 32px)); border: 1px solid #cdd6dd; border-radius: 8px; padding: 0; color: #17202a; box-shadow: 0 24px 70px rgba(17,30,42,.28); }
|
|
106
|
+
dialog::backdrop { background: rgba(14,25,35,.48); }
|
|
107
|
+
.dialog-header { min-height: 52px; padding: 0 18px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e1e6ea; }
|
|
108
|
+
.dialog-body { padding: 18px; color: #4f5d69; font-size: 13px; line-height: 1.55; }
|
|
109
|
+
.dialog-target { margin-top: 12px; padding: 10px 12px; border-radius: 6px; background: #f1f4f6; color: #263541; font-weight: 700; }
|
|
110
|
+
.dialog-actions { padding: 13px 18px; display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid #e1e6ea; }
|
|
111
|
+
.spin { animation: spin .8s linear infinite; }
|
|
112
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
113
|
+
@media (max-width: 820px) {
|
|
114
|
+
.topbar-inner, .nav-inner, main { width: min(100% - 28px, 1180px); }
|
|
115
|
+
.workspace { grid-template-columns: 1fr; }
|
|
116
|
+
.summary { grid-template-columns: 1fr; }
|
|
117
|
+
.metric { border-right: 0; border-bottom: 1px solid #e1e6ea; }
|
|
118
|
+
.metric:last-child { border-bottom: 0; }
|
|
119
|
+
}
|
|
120
|
+
@media (max-width: 560px) {
|
|
121
|
+
.brand-subtitle, .local-state span { display: none; }
|
|
122
|
+
.nav-inner { align-items: stretch; }
|
|
123
|
+
.tabs { gap: 16px; }
|
|
124
|
+
.field-row { grid-template-columns: 1fr; }
|
|
125
|
+
.row-main { align-items: stretch; flex-direction: column; }
|
|
126
|
+
.row-actions { justify-content: flex-start; padding-left: 45px; }
|
|
127
|
+
.row-path { padding-left: 0; }
|
|
128
|
+
.clients-toolbar, .clients-toolbar-actions { align-items: flex-start; flex-direction: column; }
|
|
129
|
+
}
|
|
130
|
+
</style>
|
|
131
|
+
</head>
|
|
132
|
+
<body>
|
|
133
|
+
<div class="shell">
|
|
134
|
+
<header class="topbar">
|
|
135
|
+
<div class="topbar-inner">
|
|
136
|
+
<div class="brand">
|
|
137
|
+
<div class="brand-mark"><img src="/icons/server.svg" alt=""></div>
|
|
138
|
+
<div><div class="brand-name">TD/OMS MCP</div><div class="brand-subtitle">Change Management Console</div></div>
|
|
139
|
+
</div>
|
|
140
|
+
<div class="local-state"><span class="state-dot"></span><span>Local service</span></div>
|
|
141
|
+
</div>
|
|
142
|
+
</header>
|
|
143
|
+
<div class="nav-band">
|
|
144
|
+
<div class="nav-inner">
|
|
145
|
+
<nav class="tabs" aria-label="Console views">
|
|
146
|
+
<button class="tab active" data-view="connections">Connections</button>
|
|
147
|
+
<button class="tab" data-view="clients">Agent setup</button>
|
|
148
|
+
</nav>
|
|
149
|
+
<button class="icon-button" id="refresh" title="Refresh"><img src="/icons/refresh-cw.svg" alt=""></button>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
<main>
|
|
153
|
+
<section class="summary" aria-label="Status summary">
|
|
154
|
+
<div class="metric"><div class="metric-icon blue"><img src="/icons/database.svg" alt=""></div><div><div class="metric-value" id="metric-connections">0</div><div class="metric-label">Connections</div></div></div>
|
|
155
|
+
<div class="metric"><div class="metric-icon"><img src="/icons/shield-check.svg" alt=""></div><div><div class="metric-value" id="metric-secure">0</div><div class="metric-label">TLS verified</div></div></div>
|
|
156
|
+
<div class="metric"><div class="metric-icon amber"><img src="/icons/bot.svg" alt=""></div><div><div class="metric-value" id="metric-clients">0</div><div class="metric-label">Agents configured</div></div></div>
|
|
157
|
+
</section>
|
|
158
|
+
|
|
159
|
+
<section class="view active" id="view-connections">
|
|
160
|
+
<div class="workspace">
|
|
161
|
+
<section class="panel">
|
|
162
|
+
<div class="panel-header"><div class="panel-title" id="form-title">Connection profile</div></div>
|
|
163
|
+
<div class="panel-body">
|
|
164
|
+
<form id="connection-form">
|
|
165
|
+
<input type="hidden" name="id">
|
|
166
|
+
<label>Profile name<input name="name" placeholder="Development TD/OMS"></label>
|
|
167
|
+
<label>Host<input name="host" placeholder="tdoms.example.com" required></label>
|
|
168
|
+
<label>Username<input name="username" autocomplete="username" required></label>
|
|
169
|
+
<label>Password<input name="password" type="password" autocomplete="current-password"></label>
|
|
170
|
+
<details>
|
|
171
|
+
<summary>Advanced settings</summary>
|
|
172
|
+
<div class="details-fields">
|
|
173
|
+
<div class="field-row">
|
|
174
|
+
<label>Protocol<select name="protocol"><option value="https">HTTPS</option><option value="http">HTTP</option></select></label>
|
|
175
|
+
<label>REST port<input name="port" value="45011" inputmode="numeric"></label>
|
|
176
|
+
</div>
|
|
177
|
+
<label>TD/OMS library<input name="library" value="OMS" required></label>
|
|
178
|
+
<label class="check-row"><input name="verifyTls" type="checkbox" checked>Verify server certificate</label>
|
|
179
|
+
<label>Custom CA certificate<input name="caFile" placeholder="C:/certificates/company-root-ca.pem"></label>
|
|
180
|
+
</div>
|
|
181
|
+
</details>
|
|
182
|
+
<div class="form-actions">
|
|
183
|
+
<button type="button" class="button secondary" id="clear-form"><img src="/icons/x.svg" alt="">Clear</button>
|
|
184
|
+
<button type="submit" class="button"><img src="/icons/save.svg" alt="">Save profile</button>
|
|
185
|
+
</div>
|
|
186
|
+
</form>
|
|
187
|
+
</div>
|
|
188
|
+
</section>
|
|
189
|
+
<section class="panel">
|
|
190
|
+
<div class="panel-header"><div class="panel-title">Saved connections</div><div class="panel-count" id="connection-count">0 profiles</div></div>
|
|
191
|
+
<div id="connections-list" class="list"><div class="empty">No connection profiles</div></div>
|
|
192
|
+
</section>
|
|
193
|
+
</div>
|
|
194
|
+
</section>
|
|
195
|
+
|
|
196
|
+
<section class="view" id="view-clients">
|
|
197
|
+
<div class="clients-toolbar">
|
|
198
|
+
<div><div class="section-heading">MCP clients</div><div class="environment-label" id="environment-label">Detecting environment</div></div>
|
|
199
|
+
<div class="clients-toolbar-actions">
|
|
200
|
+
<label class="filter"><input type="checkbox" id="show-all-clients">Show products not detected</label>
|
|
201
|
+
<button class="button" id="install-detected"><img src="/icons/plug-zap.svg" alt="">Configure detected</button>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
<section class="panel">
|
|
205
|
+
<div class="panel-header"><div class="panel-title">Agent installations</div><div class="panel-count" id="client-count">Scanning</div></div>
|
|
206
|
+
<div id="clients-list" class="list"><div class="empty">Detecting MCP clients</div></div>
|
|
207
|
+
</section>
|
|
208
|
+
</section>
|
|
209
|
+
</main>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div class="activity" id="activity"><img src="/icons/check.svg" alt=""><span></span></div>
|
|
213
|
+
<dialog id="confirm-dialog">
|
|
214
|
+
<div class="dialog-header"><strong id="dialog-title">Confirm action</strong><button class="icon-button" id="dialog-close" title="Close"><img src="/icons/x.svg" alt=""></button></div>
|
|
215
|
+
<div class="dialog-body"><div id="dialog-message"></div><div class="dialog-target" id="dialog-target"></div></div>
|
|
216
|
+
<div class="dialog-actions"><button class="button secondary" id="dialog-cancel">Cancel</button><button class="button" id="dialog-confirm"><img src="/icons/check.svg" alt="">Confirm</button></div>
|
|
217
|
+
</dialog>
|
|
218
|
+
|
|
219
|
+
<script>
|
|
220
|
+
const state = { connections: [], clients: [], pendingAction: null };
|
|
221
|
+
const form = document.querySelector("#connection-form");
|
|
222
|
+
const connectionList = document.querySelector("#connections-list");
|
|
223
|
+
const clientList = document.querySelector("#clients-list");
|
|
224
|
+
const activity = document.querySelector("#activity");
|
|
225
|
+
const dialog = document.querySelector("#confirm-dialog");
|
|
226
|
+
|
|
227
|
+
async function request(url, options) {
|
|
228
|
+
const response = await fetch(url, { headers: { "content-type": "application/json" }, ...(options || {}) });
|
|
229
|
+
const json = await response.json();
|
|
230
|
+
if (!response.ok) throw new Error(json.error || "Request failed");
|
|
231
|
+
return json;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function refresh() {
|
|
235
|
+
const icon = document.querySelector("#refresh img");
|
|
236
|
+
icon.classList.add("spin");
|
|
237
|
+
try {
|
|
238
|
+
const results = await Promise.all([request("/api/connections"), request("/api/clients")]);
|
|
239
|
+
state.connections = results[0].connections;
|
|
240
|
+
state.clients = results[1].clients;
|
|
241
|
+
renderConnections();
|
|
242
|
+
renderClients();
|
|
243
|
+
renderMetrics();
|
|
244
|
+
} catch (error) {
|
|
245
|
+
notify(error.message, true);
|
|
246
|
+
} finally {
|
|
247
|
+
icon.classList.remove("spin");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function renderMetrics() {
|
|
252
|
+
document.querySelector("#metric-connections").textContent = state.connections.length;
|
|
253
|
+
document.querySelector("#metric-secure").textContent = state.connections.filter(function (item) { return item.verifyTls !== false; }).length;
|
|
254
|
+
document.querySelector("#metric-clients").textContent = state.clients.filter(function (item) { return item.configured; }).length;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function renderConnections() {
|
|
258
|
+
document.querySelector("#connection-count").textContent = state.connections.length + (state.connections.length === 1 ? " profile" : " profiles");
|
|
259
|
+
if (!state.connections.length) {
|
|
260
|
+
connectionList.innerHTML = '<div class="empty">No connection profiles</div>';
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
connectionList.innerHTML = state.connections.map(function (item) {
|
|
264
|
+
const tls = item.verifyTls === false ? "TLS verification off" : "TLS verified";
|
|
265
|
+
return '<div class="connection-row"><div class="row-main"><div class="row-identity"><div class="row-icon"><img src="/icons/database.svg" alt=""></div><div><div class="row-name">' + escapeHtml(item.name) + '</div><div class="row-description">' + escapeHtml(item.username) + ' | ' + escapeHtml(tls) + '</div></div></div><div class="row-actions"><button class="button secondary" data-edit="' + escapeHtml(item.id) + '"><img src="/icons/pencil.svg" alt="">Edit</button><button class="button secondary" data-test="' + escapeHtml(item.id) + '"><img src="/icons/plug-zap.svg" alt="">Test</button><button class="button danger" data-delete="' + escapeHtml(item.id) + '"><img src="/icons/trash-2.svg" alt="">Delete</button></div></div><div class="row-path">' + escapeHtml(item.baseUrl) + '</div></div>';
|
|
266
|
+
}).join("");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function renderClients() {
|
|
270
|
+
const showAll = document.querySelector("#show-all-clients").checked;
|
|
271
|
+
const clients = state.clients.filter(function (item) { return showAll || item.detected; });
|
|
272
|
+
const detected = state.clients.filter(function (item) { return item.detected; }).length;
|
|
273
|
+
const pending = state.clients.filter(function (item) { return item.detected && item.installable && !item.configured; }).length;
|
|
274
|
+
const environment = state.clients[0] && state.clients[0].environment;
|
|
275
|
+
document.querySelector("#environment-label").textContent = environment ? environment.label + " | " + environment.note : "Current environment";
|
|
276
|
+
document.querySelector("#install-detected").disabled = pending === 0;
|
|
277
|
+
document.querySelector("#client-count").textContent = detected + " detected";
|
|
278
|
+
if (!clients.length) {
|
|
279
|
+
clientList.innerHTML = '<div class="empty">No supported MCP clients detected</div>';
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
clientList.innerHTML = clients.map(function (item) {
|
|
283
|
+
const action = item.configured ? "Update" : "Install";
|
|
284
|
+
const button = item.installable ? '<button class="button secondary" data-install="' + escapeHtml(item.id) + '"><img src="/icons/plug-zap.svg" alt="">' + action + '</button>' : '';
|
|
285
|
+
return '<div class="client-row"><div class="row-main"><div class="row-identity"><div class="row-icon"><img src="/icons/terminal.svg" alt=""></div><div><div class="row-name">' + escapeHtml(item.name) + '</div><div class="row-description">' + escapeHtml(item.description) + '</div></div></div><div class="row-actions"><span class="status ' + escapeHtml(item.status) + '">' + escapeHtml(item.status.replace("-", " ")) + '</span>' + button + '</div></div><div class="row-path">' + escapeHtml(item.configPath) + '</div></div>';
|
|
286
|
+
}).join("");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function resetForm() {
|
|
290
|
+
form.reset();
|
|
291
|
+
form.elements.id.value = "";
|
|
292
|
+
form.elements.port.value = "45011";
|
|
293
|
+
form.elements.library.value = "OMS";
|
|
294
|
+
form.elements.protocol.value = "https";
|
|
295
|
+
form.elements.verifyTls.checked = true;
|
|
296
|
+
document.querySelector("#form-title").textContent = "Connection profile";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function editConnection(id) {
|
|
300
|
+
const item = state.connections.find(function (entry) { return entry.id === id; });
|
|
301
|
+
if (!item) return;
|
|
302
|
+
["id", "name", "protocol", "host", "port", "library", "username", "caFile"].forEach(function (key) {
|
|
303
|
+
if (form.elements[key]) form.elements[key].value = item[key] || "";
|
|
304
|
+
});
|
|
305
|
+
form.elements.password.value = "";
|
|
306
|
+
form.elements.verifyTls.checked = item.verifyTls !== false;
|
|
307
|
+
document.querySelector("#form-title").textContent = "Edit connection";
|
|
308
|
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function openConfirmation(options) {
|
|
312
|
+
state.pendingAction = options.action;
|
|
313
|
+
document.querySelector("#dialog-title").textContent = options.title;
|
|
314
|
+
document.querySelector("#dialog-message").textContent = options.message;
|
|
315
|
+
document.querySelector("#dialog-target").textContent = options.target;
|
|
316
|
+
dialog.showModal();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function closeDialog() {
|
|
320
|
+
state.pendingAction = null;
|
|
321
|
+
dialog.close();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function notify(message, isError) {
|
|
325
|
+
activity.querySelector("span").textContent = message;
|
|
326
|
+
activity.classList.toggle("error", Boolean(isError));
|
|
327
|
+
activity.classList.add("show");
|
|
328
|
+
clearTimeout(notify.timer);
|
|
329
|
+
notify.timer = setTimeout(function () { activity.classList.remove("show"); }, 5000);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function escapeHtml(value) {
|
|
333
|
+
return String(value == null ? "" : value).replace(/[&<>"']/g, function (character) {
|
|
334
|
+
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[character];
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
form.addEventListener("submit", async function (event) {
|
|
339
|
+
event.preventDefault();
|
|
340
|
+
const data = new FormData(form);
|
|
341
|
+
const body = {
|
|
342
|
+
id: data.get("id") || undefined,
|
|
343
|
+
name: data.get("name"), protocol: data.get("protocol"), host: data.get("host"), port: data.get("port"),
|
|
344
|
+
library: data.get("library"), username: data.get("username"), password: data.get("password") || undefined,
|
|
345
|
+
caFile: data.get("caFile") || undefined, verifyTls: data.get("verifyTls") === "on"
|
|
346
|
+
};
|
|
347
|
+
try {
|
|
348
|
+
await request("/api/connections", { method: "POST", body: JSON.stringify(body) });
|
|
349
|
+
notify("Connection profile saved");
|
|
350
|
+
resetForm();
|
|
351
|
+
await refresh();
|
|
352
|
+
} catch (error) { notify(error.message, true); }
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
connectionList.addEventListener("click", function (event) {
|
|
356
|
+
const edit = event.target.closest("[data-edit]");
|
|
357
|
+
const test = event.target.closest("[data-test]");
|
|
358
|
+
const remove = event.target.closest("[data-delete]");
|
|
359
|
+
if (edit) editConnection(edit.dataset.edit);
|
|
360
|
+
if (test) runConnectionTest(test.dataset.test);
|
|
361
|
+
if (remove) {
|
|
362
|
+
const item = state.connections.find(function (entry) { return entry.id === remove.dataset.delete; });
|
|
363
|
+
openConfirmation({ title: "Delete connection", message: "This removes the local profile and its encrypted credentials.", target: item ? item.name : remove.dataset.delete, action: async function () { await request("/api/connections/" + encodeURIComponent(remove.dataset.delete), { method: "DELETE" }); notify("Connection profile deleted"); await refresh(); } });
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
async function runConnectionTest(id) {
|
|
368
|
+
try {
|
|
369
|
+
notify("Testing TD/OMS connection");
|
|
370
|
+
await request("/api/connections/" + encodeURIComponent(id) + "/login", { method: "POST", body: "{}" });
|
|
371
|
+
notify("Connection verified");
|
|
372
|
+
await refresh();
|
|
373
|
+
} catch (error) { notify(error.message, true); }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
clientList.addEventListener("click", function (event) {
|
|
377
|
+
const install = event.target.closest("[data-install]");
|
|
378
|
+
if (!install) return;
|
|
379
|
+
const item = state.clients.find(function (entry) { return entry.id === install.dataset.install; });
|
|
380
|
+
openConfirmation({ title: item.configured ? "Update MCP client" : "Install MCP client", message: "A backup is created before the client configuration changes.", target: item.name, action: async function () { notify("Configuring " + item.name); const result = await request("/api/clients/" + encodeURIComponent(item.id) + "/install", { method: "POST", body: JSON.stringify({ confirm: true }) }); notify(result.restartRequired ? item.name + " configured. Restart the client." : item.name + " configured."); await refresh(); } });
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
document.querySelectorAll(".tab").forEach(function (tab) {
|
|
384
|
+
tab.addEventListener("click", function () {
|
|
385
|
+
document.querySelectorAll(".tab").forEach(function (item) { item.classList.toggle("active", item === tab); });
|
|
386
|
+
document.querySelectorAll(".view").forEach(function (view) { view.classList.toggle("active", view.id === "view-" + tab.dataset.view); });
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
document.querySelector("#refresh").addEventListener("click", refresh);
|
|
390
|
+
document.querySelector("#clear-form").addEventListener("click", resetForm);
|
|
391
|
+
document.querySelector("#show-all-clients").addEventListener("change", renderClients);
|
|
392
|
+
document.querySelector("#install-detected").addEventListener("click", function () {
|
|
393
|
+
const pending = state.clients.filter(function (item) { return item.detected && item.installable && !item.configured; });
|
|
394
|
+
openConfirmation({ title: "Configure detected agents", message: "This adds TD/OMS only to detected agents that are not configured yet. Every existing file is backed up first.", target: pending.map(function (item) { return item.name; }).join(", "), action: async function () { notify("Configuring detected agents"); const result = await request("/api/clients/install-detected", { method: "POST", body: JSON.stringify({ confirm: true }) }); notify(result.configured + " agents configured" + (result.failed ? ", " + result.failed + " failed" : ""), Boolean(result.failed)); await refresh(); } });
|
|
395
|
+
});
|
|
396
|
+
document.querySelector("#dialog-close").addEventListener("click", closeDialog);
|
|
397
|
+
document.querySelector("#dialog-cancel").addEventListener("click", closeDialog);
|
|
398
|
+
document.querySelector("#dialog-confirm").addEventListener("click", async function () {
|
|
399
|
+
const action = state.pendingAction;
|
|
400
|
+
closeDialog();
|
|
401
|
+
if (!action) return;
|
|
402
|
+
try { await action(); } catch (error) { notify(error.message, true); }
|
|
403
|
+
});
|
|
404
|
+
refresh();
|
|
405
|
+
</script>
|
|
406
|
+
</body>
|
|
407
|
+
</html>`;
|
|
408
|
+
}
|
package/src/web-app.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { ClientManager } from "./client-manager.js";
|
|
4
|
+
import { DEFAULT_HOST, DEFAULT_PORT, dataDir } from "./config.js";
|
|
5
|
+
import { ConnectionStore } from "./connection-store.js";
|
|
6
|
+
import { TdomsClient, summarizeSystemInfo } from "./tdoms-client.js";
|
|
7
|
+
import { renderPage } from "./ui-page.js";
|
|
8
|
+
|
|
9
|
+
const ICONS_DIR = fileURLToPath(new URL("../node_modules/lucide-static/icons", import.meta.url));
|
|
10
|
+
|
|
11
|
+
export function createWebApp({ store = new ConnectionStore(), clientManager = new ClientManager() } = {}) {
|
|
12
|
+
const app = express();
|
|
13
|
+
app.use(express.json({ limit: "1mb" }));
|
|
14
|
+
app.use("/icons", express.static(ICONS_DIR, { immutable: true, maxAge: "1d" }));
|
|
15
|
+
|
|
16
|
+
app.get("/", (_req, res) => {
|
|
17
|
+
res.type("html").send(renderPage());
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
app.get("/api/health", (_req, res) => {
|
|
21
|
+
res.json({
|
|
22
|
+
ok: true,
|
|
23
|
+
dataDir: dataDir()
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
app.get("/api/connections", async (_req, res, next) => {
|
|
28
|
+
try {
|
|
29
|
+
res.json({ connections: await store.listConnections() });
|
|
30
|
+
} catch (error) {
|
|
31
|
+
next(error);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
app.get("/api/clients", async (_req, res, next) => {
|
|
36
|
+
try {
|
|
37
|
+
res.json({ clients: await clientManager.listClients() });
|
|
38
|
+
} catch (error) {
|
|
39
|
+
next(error);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
app.post("/api/clients/:id/install", async (req, res, next) => {
|
|
44
|
+
try {
|
|
45
|
+
const result = await clientManager.installClient(req.params.id, { confirm: req.body?.confirm === true });
|
|
46
|
+
res.json(result);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
next(error);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
app.post("/api/clients/install-detected", async (req, res, next) => {
|
|
53
|
+
try {
|
|
54
|
+
const result = await clientManager.installDetected({ confirm: req.body?.confirm === true });
|
|
55
|
+
res.status(result.ok ? 200 : 207).json(result);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
next(error);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
app.post("/api/connections", async (req, res, next) => {
|
|
62
|
+
try {
|
|
63
|
+
const connection = await store.upsertConnection(req.body);
|
|
64
|
+
res.json({ connection });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
next(error);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
app.delete("/api/connections/:id", async (req, res, next) => {
|
|
71
|
+
try {
|
|
72
|
+
await store.deleteConnection(req.params.id);
|
|
73
|
+
res.json({ ok: true });
|
|
74
|
+
} catch (error) {
|
|
75
|
+
next(error);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
app.post("/api/connections/:id/login", async (req, res, next) => {
|
|
80
|
+
try {
|
|
81
|
+
const connection = await store.getConnection(req.params.id, { includeSecrets: true });
|
|
82
|
+
if (!connection) {
|
|
83
|
+
res.status(404).json({ error: "Connection not found." });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const client = new TdomsClient(connection);
|
|
88
|
+
const token = await client.login(req.body?.password);
|
|
89
|
+
await store.saveToken(connection.id, token);
|
|
90
|
+
const systemInfo = await client.getSystemInfo(token);
|
|
91
|
+
await store.updateStatus(connection.id, {
|
|
92
|
+
lastStatus: "system-probe-ok",
|
|
93
|
+
lastSystemProbeAt: new Date().toISOString()
|
|
94
|
+
});
|
|
95
|
+
res.json({
|
|
96
|
+
ok: true,
|
|
97
|
+
connection: await store.getConnection(connection.id),
|
|
98
|
+
system: summarizeSystemInfo(systemInfo)
|
|
99
|
+
});
|
|
100
|
+
} catch (error) {
|
|
101
|
+
try {
|
|
102
|
+
await store.updateStatus(req.params.id, { lastStatus: "login-failed" });
|
|
103
|
+
} catch {
|
|
104
|
+
// Ignore status update failures so the useful login error is returned.
|
|
105
|
+
}
|
|
106
|
+
next(error);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
app.use((error, _req, res, _next) => {
|
|
111
|
+
res.status(500).json({
|
|
112
|
+
error: error.message || String(error)
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return app;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function startWebServer({ host = DEFAULT_HOST, port = DEFAULT_PORT, store, clientManager } = {}) {
|
|
120
|
+
const app = createWebApp({ store, clientManager });
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const server = app.listen(port, host, () => {
|
|
123
|
+
resolve({ server, host, port });
|
|
124
|
+
});
|
|
125
|
+
server.on("error", reject);
|
|
126
|
+
});
|
|
127
|
+
}
|