influx-local-cli 0.1.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/LICENSE +21 -0
- package/README.md +265 -0
- package/bin/influx-local.js +5 -0
- package/package.json +42 -0
- package/src/cli.js +1092 -0
- package/src/config.js +349 -0
- package/src/manager.js +1794 -0
- package/src/versions.js +270 -0
- package/src/web.js +387 -0
- package/web/app.js +816 -0
- package/web/index.html +136 -0
- package/web/style.css +208 -0
package/web/app.js
ADDED
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/* influx-local dashboard — vanilla JS, no dependencies. */
|
|
4
|
+
|
|
5
|
+
const $ = (sel) => document.querySelector(sel);
|
|
6
|
+
const esc = (s) =>
|
|
7
|
+
String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
8
|
+
|
|
9
|
+
let token = localStorage.getItem('influx-local-token') || '';
|
|
10
|
+
let selected = null;
|
|
11
|
+
let current = null; // latest detail of the selected instance
|
|
12
|
+
let opBusy = false;
|
|
13
|
+
let lastOpDone = null;
|
|
14
|
+
let lastInstances = [];
|
|
15
|
+
let catalog = { versions: [], flavors: [] };
|
|
16
|
+
let baseDir = '~/.influx-local/instances';
|
|
17
|
+
|
|
18
|
+
async function api(path, opts = {}) {
|
|
19
|
+
const send = (tok) => {
|
|
20
|
+
const headers = { ...(opts.headers || {}) };
|
|
21
|
+
if (opts.body) headers['Content-Type'] = 'application/json';
|
|
22
|
+
if (tok) headers['x-influx-local-token'] = tok;
|
|
23
|
+
return fetch(path, { ...opts, headers });
|
|
24
|
+
};
|
|
25
|
+
let res = await send(token);
|
|
26
|
+
if (res.status === 401) {
|
|
27
|
+
if (token) {
|
|
28
|
+
localStorage.removeItem('influx-local-token');
|
|
29
|
+
localStorage.removeItem('influx-local-token-asked');
|
|
30
|
+
token = '';
|
|
31
|
+
}
|
|
32
|
+
if (!localStorage.getItem('influx-local-token-asked')) {
|
|
33
|
+
localStorage.setItem('influx-local-token-asked', '1');
|
|
34
|
+
const t = window.prompt('This dashboard needs the token it was started with (--token):');
|
|
35
|
+
if (t) {
|
|
36
|
+
token = t.trim();
|
|
37
|
+
localStorage.setItem('influx-local-token', token);
|
|
38
|
+
res = await send(token);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (res.status === 401) {
|
|
43
|
+
localStorage.removeItem('influx-local-token-asked');
|
|
44
|
+
throw new Error('wrong token — reload and enter the token passed to "influx-local web --token"');
|
|
45
|
+
}
|
|
46
|
+
const data = await res.json().catch(() => ({}));
|
|
47
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
48
|
+
if (data && typeof data.error === 'string' && data.error) throw new Error(data.error);
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function chip(state) {
|
|
53
|
+
const cls = state === 'running' ? 'running' : state === 'port-conflict' || state === 'stale-pid' ? 'conflict' : 'stopped';
|
|
54
|
+
return `<span class="chip ${cls}">${esc(state)}</span>`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- polling ----------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
async function pollState() {
|
|
60
|
+
try {
|
|
61
|
+
const state = await api('/api/state');
|
|
62
|
+
opBusy = !!(state.op && state.op.running);
|
|
63
|
+
lastInstances = state.instances || [];
|
|
64
|
+
if (state.baseDir) baseDir = state.baseDir;
|
|
65
|
+
renderList(state.instances, state.op);
|
|
66
|
+
renderOp(state.op);
|
|
67
|
+
if (selected && state.op && state.op.finishedAt !== lastOpDone && !state.op.running) {
|
|
68
|
+
lastOpDone = state.op.finishedAt;
|
|
69
|
+
loadDetail(selected); // refresh after any finished op
|
|
70
|
+
}
|
|
71
|
+
if (!selected) showOverview();
|
|
72
|
+
} catch (err) {
|
|
73
|
+
renderOp({ error: err.message });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// --- sidebar ----------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
function renderList(instances, op) {
|
|
80
|
+
const list = $('#instance-list');
|
|
81
|
+
list.innerHTML = '';
|
|
82
|
+
$('#empty-hint').classList.toggle('hidden', instances.length > 0);
|
|
83
|
+
for (const inst of instances) {
|
|
84
|
+
const li = document.createElement('li');
|
|
85
|
+
const busy = op && op.running && op.instance === inst.name;
|
|
86
|
+
li.className = selected === inst.name ? 'active' : '';
|
|
87
|
+
const runBtn = (a, title, label) =>
|
|
88
|
+
`<button class="rowbtn" data-name="${esc(inst.name)}" data-rowact="${a}" title="${title}" ${busy ? 'disabled' : ''}>${label}</button>`;
|
|
89
|
+
li.innerHTML = `
|
|
90
|
+
<div class="row-top">
|
|
91
|
+
<span class="inst-name" title="Click for details">${esc(inst.name)}</span>
|
|
92
|
+
${busy ? '<span class="chip op">op</span>' : chip(inst.state)}
|
|
93
|
+
</div>
|
|
94
|
+
<div class="row-bottom">
|
|
95
|
+
<span class="inst-port">${esc(inst.version)} · ${esc(inst.host)}:${inst.port}</span>
|
|
96
|
+
<span class="inst-actions">
|
|
97
|
+
${runBtn('start', 'Start instance', '▶')}
|
|
98
|
+
${runBtn('stop', 'Stop instance', '⏹')}
|
|
99
|
+
${runBtn('config', 'Edit config', '⚙')}
|
|
100
|
+
${runBtn('destroy', 'Delete instance', '✕')}
|
|
101
|
+
</span>
|
|
102
|
+
</div>`;
|
|
103
|
+
li.dataset.name = inst.name;
|
|
104
|
+
list.appendChild(li);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function onRowActionDelegate(e) {
|
|
109
|
+
const btn = e.target.closest('[data-rowact]');
|
|
110
|
+
if (btn) {
|
|
111
|
+
e.stopPropagation();
|
|
112
|
+
onRowAction(btn.dataset.name, btn.dataset.rowact);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const li = e.target.closest('#instance-list li');
|
|
116
|
+
if (li && li.dataset.name) {
|
|
117
|
+
e.stopPropagation();
|
|
118
|
+
select(li.dataset.name);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function onRowAction(name, action) {
|
|
123
|
+
if (action === 'start' || action === 'stop') {
|
|
124
|
+
if (opBusy) return;
|
|
125
|
+
try {
|
|
126
|
+
await api('/api/actions', { method: 'POST', body: JSON.stringify({ instance: name, action, opts: {} }) });
|
|
127
|
+
renderOp({ running: true, kind: action, instance: name, lines: [] });
|
|
128
|
+
pollState();
|
|
129
|
+
} catch (err) {
|
|
130
|
+
alert(err.message);
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (action === 'config') {
|
|
135
|
+
selected = name;
|
|
136
|
+
await loadDetail(name).catch(() => {});
|
|
137
|
+
openConfigModal();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (action === 'destroy') {
|
|
141
|
+
selected = name;
|
|
142
|
+
await loadDetail(name).catch(() => {});
|
|
143
|
+
openDestroyModal();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function select(name) {
|
|
149
|
+
selected = name;
|
|
150
|
+
const el = document.getElementById('detail');
|
|
151
|
+
if (el && el.dataset) delete el.dataset.name;
|
|
152
|
+
renderList(lastInstances, null);
|
|
153
|
+
loadDetail(name);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- detail pane ------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
let detailSeq = 0;
|
|
159
|
+
async function loadDetail(name) {
|
|
160
|
+
const seq = ++detailSeq;
|
|
161
|
+
const body = $('#detail-body');
|
|
162
|
+
body.innerHTML = '<p class="detail-empty">Loading…</p>';
|
|
163
|
+
try {
|
|
164
|
+
const data = await api(`/api/instances/${encodeURIComponent(name)}`);
|
|
165
|
+
if (seq !== detailSeq) return; // a newer request won
|
|
166
|
+
current = data;
|
|
167
|
+
const el = document.getElementById('detail');
|
|
168
|
+
if (el) el.dataset.name = data.name;
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (seq !== detailSeq) return;
|
|
171
|
+
body.innerHTML = `<p class="danger-text">${esc(err.message)}</p>`;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
renderDetail();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function cards(list) {
|
|
178
|
+
return list
|
|
179
|
+
.filter(([, v]) => v != null && v !== '')
|
|
180
|
+
.map(([k, v]) => `<div class="card"><div class="k">${esc(k)}</div><div class="v">${esc(v)}</div></div>`)
|
|
181
|
+
.join('');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function qbtn(action, label, opts = '') {
|
|
185
|
+
return `<button data-act="${action}" ${opts} class="small ${action === 'setup' ? 'primary' : ''}">${label}</button>`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function quickRow(kind, text, btn) {
|
|
189
|
+
const icon = kind === 'ok' ? '✓' : kind === 'bad' ? '✗' : kind === 'next' ? '→' : '○';
|
|
190
|
+
return `<div class="q-row"><span class="q-icon ${kind}">${icon}</span><span class="q-text">${esc(text)}</span>${btn || ''}</div>`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function renderDetail() {
|
|
194
|
+
const d = current;
|
|
195
|
+
if (!d) return;
|
|
196
|
+
const body = $('#detail-body');
|
|
197
|
+
const running = d.running;
|
|
198
|
+
const caps = d.capabilities || {};
|
|
199
|
+
|
|
200
|
+
const rows = [];
|
|
201
|
+
if (running) {
|
|
202
|
+
rows.push(quickRow('ok', `server running (pid ${d.pid}) — reporting ${d.runningVersion || '?'}`, `${qbtn('restart', 'Restart')} ${qbtn('stop', 'Stop')}`));
|
|
203
|
+
} else if (d.state === 'port-conflict') {
|
|
204
|
+
rows.push(quickRow('bad', `port ${d.port} is held by a process this tool does not manage`, ''));
|
|
205
|
+
} else if (d.state === 'stale-pid') {
|
|
206
|
+
rows.push(quickRow('bad', 'stale pid file — Start cleans it up automatically', qbtn('start', 'Start')));
|
|
207
|
+
} else {
|
|
208
|
+
rows.push(quickRow('next', 'server stopped', qbtn('start', 'Start')));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (d.credsPresent) {
|
|
212
|
+
rows.push(quickRow('ok', `creds.env present (0600) — ${d.credsFields.join(', ') || 'no fields'}`));
|
|
213
|
+
} else {
|
|
214
|
+
rows.push(quickRow(running ? 'bad' : 'next', 'no credentials yet — credentials are created by setup', running ? '' : qbtn('setup', 'Setup')));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (running) {
|
|
218
|
+
const list = d.measurements;
|
|
219
|
+
rows.push(
|
|
220
|
+
quickRow(
|
|
221
|
+
list && list.length ? 'ok' : 'none',
|
|
222
|
+
list == null ? 'measurements unavailable' : list.length ? `measurements: ${list.join(', ')}` : 'no measurements yet',
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
if (d.flavor === 'v3') {
|
|
226
|
+
rows.push(quickRow('none', 'InfluxDB 3 Core cannot rotate its admin token in place (the server refuses a second "_admin" token) — recreate the instance to rotate'));
|
|
227
|
+
} else {
|
|
228
|
+
rows.push(quickRow('none', 'rotate rewrites creds.env with fresh secrets and verifies them against the running server', qbtn('rotate', 'Rotate secrets')));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const uris = d.uris || {};
|
|
233
|
+
const uriRows = [
|
|
234
|
+
['Endpoint', uris.endpoint],
|
|
235
|
+
['App DSN', uris.appUri],
|
|
236
|
+
['Admin DSN', uris.adminUri],
|
|
237
|
+
['Public DSN', uris.publicAppUri],
|
|
238
|
+
]
|
|
239
|
+
.filter(([, v]) => v)
|
|
240
|
+
.map(
|
|
241
|
+
([label, value]) => `
|
|
242
|
+
<div class="uri-row">
|
|
243
|
+
<span style="min-width:90px;color:var(--muted)">${esc(label)}</span>
|
|
244
|
+
<span class="u" data-secret>${esc(value)}</span>
|
|
245
|
+
<button class="small" data-copy="${esc(value)}">copy</button>
|
|
246
|
+
</div>`,
|
|
247
|
+
)
|
|
248
|
+
.join('');
|
|
249
|
+
|
|
250
|
+
body.innerHTML = `
|
|
251
|
+
<h2>${esc(d.name)} <span class="chip role">${esc(d.flavorLabel)}</span></h2>
|
|
252
|
+
<div class="cards">
|
|
253
|
+
${cards([
|
|
254
|
+
['State', d.state],
|
|
255
|
+
['Pinned version', d.version],
|
|
256
|
+
['Running version', d.runningVersion],
|
|
257
|
+
['Endpoint', d.endpoint],
|
|
258
|
+
[d.flavor === 'v2' ? 'Org / bucket' : 'Database', d.flavor === 'v2' ? `${d.org} / ${d.database}` : d.database],
|
|
259
|
+
['Measurement', d.measurement],
|
|
260
|
+
['Admin user', d.adminUser],
|
|
261
|
+
['App user', d.appUser],
|
|
262
|
+
['PID', d.pid],
|
|
263
|
+
['Data dir', d.dataDir],
|
|
264
|
+
])}
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<div class="section">
|
|
268
|
+
<h3>Status</h3>
|
|
269
|
+
<div class="quick-rows">${rows.join('')}</div>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<div class="section">
|
|
273
|
+
<h3>Actions</h3>
|
|
274
|
+
<div class="btn-row">
|
|
275
|
+
<button data-act="setup" class="primary small">Setup / repair</button>
|
|
276
|
+
<button data-act="start" class="small">Start</button>
|
|
277
|
+
<button data-act="stop" class="small">Stop</button>
|
|
278
|
+
<button data-act="restart" class="small">Restart</button>
|
|
279
|
+
<button data-act="install" class="small">Install toolchain</button>
|
|
280
|
+
${d.flavor === 'v3' ? '' : '<button data-act="rotate" class="small">Rotate secrets</button>'}
|
|
281
|
+
<button data-act="config" class="small">Edit config</button>
|
|
282
|
+
<button data-act="rename" class="small">Rename</button>
|
|
283
|
+
<button data-act="clone" class="small">Clone</button>
|
|
284
|
+
<button data-act="secrets" class="small">Show secrets</button>
|
|
285
|
+
<button data-act="logs" class="small">View logs</button>
|
|
286
|
+
<button data-act="destroy" class="small danger">Destroy</button>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
<div class="section">
|
|
291
|
+
<h3>Connection</h3>
|
|
292
|
+
${uriRows || '<p class="hint" style="margin:0">Start the instance to see its DSNs.</p>'}
|
|
293
|
+
<p class="hint" style="margin:8px 0 0">Secrets are masked; use “Show secrets” to reveal them (they are read from <code>creds.env</code>).</p>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<div class="section">
|
|
297
|
+
<h3>Users ${d.flavor === 'v3' ? '(tokens)' : ''}</h3>
|
|
298
|
+
<div id="users-body" class="hint">Loading…</div>
|
|
299
|
+
<div class="btn-row" style="margin-top:10px">
|
|
300
|
+
${caps.users ? '<button data-act="user-add" class="small">+ Add user</button>' : '<span class="hint">This line has no user management.</span>'}
|
|
301
|
+
</div>
|
|
302
|
+
</div>`;
|
|
303
|
+
|
|
304
|
+
body.querySelectorAll('[data-act]').forEach((el) => (el.onclick = () => onDetailAction(el.dataset.act)));
|
|
305
|
+
body.querySelectorAll('[data-copy]').forEach(
|
|
306
|
+
(el) =>
|
|
307
|
+
(el.onclick = () => {
|
|
308
|
+
navigator.clipboard.writeText(el.dataset.copy).then(
|
|
309
|
+
() => (el.textContent = 'copied'),
|
|
310
|
+
() => (el.textContent = 'copy failed'),
|
|
311
|
+
);
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
loadUsers(d.name);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function loadUsers(name) {
|
|
318
|
+
const box = $('#users-body');
|
|
319
|
+
if (!box) return;
|
|
320
|
+
try {
|
|
321
|
+
const data = await api(`/api/instances/${encodeURIComponent(name)}/users`);
|
|
322
|
+
if (!data.users || data.users.length === 0) {
|
|
323
|
+
box.textContent = 'No users reported.';
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const rows = data.users
|
|
327
|
+
.map((u) => {
|
|
328
|
+
const roles = (u.roles || []).map((r) => `<span class="chip role">${esc(r)}</span>`).join('');
|
|
329
|
+
const managed = u.builtin ? '<span class="chip builtin">managed</span>' : '';
|
|
330
|
+
const actions =
|
|
331
|
+
u.builtin && !u.user.includes('token')
|
|
332
|
+
? ''
|
|
333
|
+
: `<span class="u-actions">
|
|
334
|
+
<button class="rowbtn small" data-useact="user-password" data-user="${esc(u.user)}">password</button>
|
|
335
|
+
<button class="rowbtn small" data-useact="user-rm" data-user="${esc(u.user)}">delete</button>
|
|
336
|
+
</span>`;
|
|
337
|
+
return `<div class="user-row">
|
|
338
|
+
<span class="u-name">${esc(u.user)}</span>
|
|
339
|
+
<span class="u-db">@${esc(u.db || '—')}</span>
|
|
340
|
+
<span class="u-roles">${roles}${managed}</span>
|
|
341
|
+
${actions}
|
|
342
|
+
</div>`;
|
|
343
|
+
})
|
|
344
|
+
.join('');
|
|
345
|
+
const err = data.error ? `<p class="danger-text" style="margin:6px 0 0">${esc(data.error)}</p>` : '';
|
|
346
|
+
box.innerHTML = rows + err;
|
|
347
|
+
box.querySelectorAll('[data-useact]').forEach(
|
|
348
|
+
(el) => (el.onclick = () => onUserAction(el.dataset.useact, el.dataset.user)),
|
|
349
|
+
);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
box.innerHTML = `<p class="danger-text" style="margin:0">${esc(err.message)}</p>`;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function onUserAction(kind, username) {
|
|
356
|
+
if (kind === 'user-password') return openUserPasswordModal(username);
|
|
357
|
+
if (kind === 'user-rm') return runAction('user-rm', { username });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// --- operations -------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
async function runAction(action, opts = {}) {
|
|
363
|
+
if (!current) return;
|
|
364
|
+
if (opBusy) return alert('another operation is running');
|
|
365
|
+
try {
|
|
366
|
+
await api('/api/actions', {
|
|
367
|
+
method: 'POST',
|
|
368
|
+
body: JSON.stringify({ instance: current.name, action, opts }),
|
|
369
|
+
});
|
|
370
|
+
renderOp({ running: true, kind: action, instance: current.name, lines: [] });
|
|
371
|
+
pollState();
|
|
372
|
+
} catch (err) {
|
|
373
|
+
alert(err.message);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function onDetailAction(act) {
|
|
378
|
+
if (!current) return;
|
|
379
|
+
switch (act) {
|
|
380
|
+
case 'setup':
|
|
381
|
+
case 'start':
|
|
382
|
+
case 'stop':
|
|
383
|
+
case 'restart':
|
|
384
|
+
case 'rotate':
|
|
385
|
+
case 'install':
|
|
386
|
+
return runAction(act);
|
|
387
|
+
case 'config':
|
|
388
|
+
return openConfigModal();
|
|
389
|
+
case 'rename':
|
|
390
|
+
return openRenameModal();
|
|
391
|
+
case 'clone':
|
|
392
|
+
return openCloneModal();
|
|
393
|
+
case 'secrets':
|
|
394
|
+
return openSecrets();
|
|
395
|
+
case 'logs':
|
|
396
|
+
return openLogs();
|
|
397
|
+
case 'destroy':
|
|
398
|
+
return openDestroyModal();
|
|
399
|
+
case 'user-add':
|
|
400
|
+
return openUserAddModal();
|
|
401
|
+
default:
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// --- operation log ----------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
function renderOp(op) {
|
|
409
|
+
const pre = $('#op-console');
|
|
410
|
+
const label = $('#op-state');
|
|
411
|
+
if (!op || (!op.running && !(op.lines || []).length && !op.error)) {
|
|
412
|
+
label.textContent = '';
|
|
413
|
+
label.className = 'op-state';
|
|
414
|
+
pre.textContent = 'No operation running.';
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const outLines = [...(op.lines || [])];
|
|
418
|
+
if (op.error) outLines.push(`✗ Error: ${op.error}`);
|
|
419
|
+
pre.textContent = outLines.length ? outLines.join('\n') : '…';
|
|
420
|
+
pre.scrollTop = pre.scrollHeight;
|
|
421
|
+
if (op.running) {
|
|
422
|
+
label.textContent = `running: ${op.kind} on ${op.instance}`;
|
|
423
|
+
label.className = 'op-state running';
|
|
424
|
+
} else if (op.error) {
|
|
425
|
+
label.textContent = `failed: ${op.kind} on ${op.instance}`;
|
|
426
|
+
label.className = 'op-state error';
|
|
427
|
+
} else {
|
|
428
|
+
label.textContent = `done: ${op.kind} on ${op.instance}`;
|
|
429
|
+
label.className = 'op-state done';
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// --- overview ---------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
function showOverview() {
|
|
436
|
+
const body = $('#detail-body');
|
|
437
|
+
const insts = lastInstances;
|
|
438
|
+
const runningCount = insts.filter((i) => i.running).length;
|
|
439
|
+
const head = `
|
|
440
|
+
<h2>Overview</h2>
|
|
441
|
+
<div class="cards">
|
|
442
|
+
${cards([
|
|
443
|
+
['Instances', String(insts.length)],
|
|
444
|
+
['Running', `${runningCount} / ${insts.length}`],
|
|
445
|
+
['Base dir', baseDir],
|
|
446
|
+
])}
|
|
447
|
+
</div>`;
|
|
448
|
+
if (insts.length === 0) {
|
|
449
|
+
body.innerHTML = `${head}
|
|
450
|
+
<div class="section">
|
|
451
|
+
<h3>Get started</h3>
|
|
452
|
+
<p class="hint" style="margin-bottom:10px">No instances yet. Create one and pick the InfluxDB version (1.x InfluxQL, 2.x Flux or 3.x Core) — setup then installs that exact build through mise and provisions it.</p>
|
|
453
|
+
<div class="btn-row"><button id="ovr-new" class="primary">+ New instance</button></div>
|
|
454
|
+
</div>`;
|
|
455
|
+
const b = $('#ovr-new');
|
|
456
|
+
if (b) b.onclick = () => openNewModal();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const rows = insts
|
|
460
|
+
.map((inst) => {
|
|
461
|
+
const runBtn = (a, title, label) =>
|
|
462
|
+
`<button class="rowbtn" data-name="${esc(inst.name)}" data-rowact="${a}" title="${title}">${label}</button>`;
|
|
463
|
+
return `<div class="ov-row">
|
|
464
|
+
<span class="inst-name" data-open="${esc(inst.name)}">${esc(inst.name)}</span>
|
|
465
|
+
<span class="ov-meta">${esc(inst.version)} · ${esc(inst.host)}:${inst.port}</span>
|
|
466
|
+
${chip(inst.state)}
|
|
467
|
+
<span class="ov-actions">${runBtn('start', 'Start', '▶')} ${runBtn('stop', 'Stop', '⏹')} ${runBtn('config', 'Config', '⚙')} ${runBtn('destroy', 'Delete', '✕')}</span>
|
|
468
|
+
</div>`;
|
|
469
|
+
})
|
|
470
|
+
.join('');
|
|
471
|
+
body.innerHTML = `${head}
|
|
472
|
+
<div class="section"><h3>Instances — click a name for details</h3><div class="ov-list">${rows}</div></div>
|
|
473
|
+
<div class="section">
|
|
474
|
+
<h3>Notes</h3>
|
|
475
|
+
<p class="hint" style="margin:0">• Pin a different InfluxDB version per instance; changing the line (1.x/2.x/3.x) needs a fresh instance. · <b>Setup / repair</b> fixes a broken auth state without losing data. · 1.x keeps an admin user + a database-scoped app user; 2.x keeps an operator user + a bucket-scoped app token; 3.x keeps a single admin token.</p>
|
|
476
|
+
</div>`;
|
|
477
|
+
body.querySelectorAll('[data-open]').forEach((el) => (el.onclick = () => select(el.dataset.open)));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// --- modals -----------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
function closeModal(id) {
|
|
483
|
+
const el = document.getElementById(id);
|
|
484
|
+
if (el && el.open) el.close();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function openViewer(title, html) {
|
|
488
|
+
$('#viewer-title').textContent = title;
|
|
489
|
+
$('#viewer-body').innerHTML = html;
|
|
490
|
+
const dlg = $('#modal-viewer');
|
|
491
|
+
if (!dlg.open) dlg.showModal();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function openSecrets() {
|
|
495
|
+
try {
|
|
496
|
+
const data = await api(`/api/instances/${encodeURIComponent(current.name)}/secrets`);
|
|
497
|
+
const creds = Object.entries(data.creds)
|
|
498
|
+
.map(
|
|
499
|
+
([k, v]) =>
|
|
500
|
+
`<div class="uri-row"><span style="min-width:120px;color:var(--muted)">${esc(k)}</span><span class="u">${esc(v)}</span></div>`,
|
|
501
|
+
)
|
|
502
|
+
.join('');
|
|
503
|
+
const uris = Object.entries(data.uris || {})
|
|
504
|
+
.filter(([k]) => k !== 'endpoint' && k !== 'masked')
|
|
505
|
+
.map(
|
|
506
|
+
([k, v]) =>
|
|
507
|
+
`<div class="uri-row"><span style="min-width:120px;color:var(--muted)">${esc(k)}</span><span class="u">${esc(v)}</span></div>`,
|
|
508
|
+
)
|
|
509
|
+
.join('');
|
|
510
|
+
openViewer(
|
|
511
|
+
`Secrets of ${current.name}`,
|
|
512
|
+
`${creds}${uris ? `<hr style="border:0;border-top:1px solid var(--border);margin:10px 0">${uris}` : ''}
|
|
513
|
+
<p class="hint">Read from <code>creds.env</code> (mode 0600).</p>`,
|
|
514
|
+
);
|
|
515
|
+
} catch (err) {
|
|
516
|
+
alert(err.message);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function openLogs() {
|
|
521
|
+
try {
|
|
522
|
+
const data = await api(`/api/instances/${encodeURIComponent(current.name)}/logs?lines=300`);
|
|
523
|
+
openViewer(
|
|
524
|
+
`Logs of ${current.name}`,
|
|
525
|
+
data.missing
|
|
526
|
+
? '<p class="hint">No log file yet — the server has never been started.</p>'
|
|
527
|
+
: `<pre class="console" style="height:340px">${esc(data.text)}</pre><p class="hint">${esc(data.logPath)}</p>`,
|
|
528
|
+
);
|
|
529
|
+
} catch (err) {
|
|
530
|
+
alert(err.message);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function openSimpleModal({ title, fields, single, onSubmit }) {
|
|
535
|
+
$('#simple-title').textContent = title;
|
|
536
|
+
const wrap = $('#simple-fields');
|
|
537
|
+
wrap.className = single ? 'single' : '';
|
|
538
|
+
wrap.innerHTML = fields
|
|
539
|
+
.map((f) => {
|
|
540
|
+
if (f.type === 'html') return f.html;
|
|
541
|
+
const label = f.label ? `<label>${esc(f.label)}${f.input || `<input name="${esc(f.name)}" value="${esc(f.value == null ? '' : f.value)}" ${f.type ? `type="${esc(f.type)}"` : ''} placeholder="${esc(f.placeholder || '')}" ${f.attrs || ''} />`}</label>` : '';
|
|
542
|
+
return label;
|
|
543
|
+
})
|
|
544
|
+
.join('');
|
|
545
|
+
const form = $('#form-simple');
|
|
546
|
+
form.onsubmit = async (e) => {
|
|
547
|
+
if (e.submitter && e.submitter.value === 'cancel') return;
|
|
548
|
+
const values = {};
|
|
549
|
+
for (const f of fields) {
|
|
550
|
+
if (!f.name) continue;
|
|
551
|
+
const el = wrap.querySelector(`[name="${f.name}"]`);
|
|
552
|
+
if (el) values[f.name] = el.value;
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
await onSubmit(values);
|
|
556
|
+
closeModal('modal-simple');
|
|
557
|
+
} catch (err) {
|
|
558
|
+
alert(err.message);
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
const dlg = $('#modal-simple');
|
|
562
|
+
if (!dlg.open) dlg.showModal();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function openRenameModal() {
|
|
566
|
+
openSimpleModal({
|
|
567
|
+
title: `Rename ${current.name} (must be stopped)`,
|
|
568
|
+
single: true,
|
|
569
|
+
fields: [{ name: 'newName', label: 'New name', placeholder: 'new_name' }],
|
|
570
|
+
onSubmit: async (v) => {
|
|
571
|
+
await api(`/api/instances/${encodeURIComponent(current.name)}/rename`, {
|
|
572
|
+
method: 'POST',
|
|
573
|
+
body: JSON.stringify({ newName: v.newName }),
|
|
574
|
+
});
|
|
575
|
+
selected = v.newName;
|
|
576
|
+
await pollState();
|
|
577
|
+
await loadDetail(v.newName);
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function openCloneModal() {
|
|
583
|
+
openSimpleModal({
|
|
584
|
+
title: `Clone ${current.name}`,
|
|
585
|
+
fields: [
|
|
586
|
+
{ name: 'name', label: 'Clone name', placeholder: 'copy_of_' + current.name },
|
|
587
|
+
{ name: 'port', label: 'Port (blank = next free)', type: 'number' },
|
|
588
|
+
],
|
|
589
|
+
onSubmit: async (v) => {
|
|
590
|
+
await api(`/api/instances/${encodeURIComponent(current.name)}/clone`, {
|
|
591
|
+
method: 'POST',
|
|
592
|
+
body: JSON.stringify({ name: v.name, port: v.port }),
|
|
593
|
+
});
|
|
594
|
+
await pollState();
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function openUserPasswordModal(username) {
|
|
600
|
+
openSimpleModal({
|
|
601
|
+
title: `Set password for ${username}`,
|
|
602
|
+
single: true,
|
|
603
|
+
fields: [{ name: 'password', label: 'New password (min 6 chars)', type: 'password' }],
|
|
604
|
+
onSubmit: (v) => runAction('user-password', { username, password: v.password }),
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function openUserAddModal() {
|
|
609
|
+
const v1 = current.flavor === 'v1';
|
|
610
|
+
openSimpleModal({
|
|
611
|
+
title: `Add user to ${current.name}`,
|
|
612
|
+
fields: [
|
|
613
|
+
{ name: 'username', label: 'Username' },
|
|
614
|
+
{ name: 'password', label: 'Password (min 6 chars)', type: 'password' },
|
|
615
|
+
...(v1
|
|
616
|
+
? [
|
|
617
|
+
{ name: 'db', label: 'Target (admin or ' + current.database + ')', value: current.database },
|
|
618
|
+
{ name: 'privileges', label: 'Privileges (read,write,all)', value: 'all' },
|
|
619
|
+
]
|
|
620
|
+
: []),
|
|
621
|
+
],
|
|
622
|
+
onSubmit: (v) =>
|
|
623
|
+
runAction('user-add', {
|
|
624
|
+
username: v.username,
|
|
625
|
+
password: v.password,
|
|
626
|
+
db: v.db,
|
|
627
|
+
privileges: v.privileges ? v.privileges.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
|
|
628
|
+
}),
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function openConfigModal() {
|
|
633
|
+
const c = current;
|
|
634
|
+
const flavors = new Map(catalog.flavors.map((f) => [f.id, f]));
|
|
635
|
+
const options = catalog.versions
|
|
636
|
+
.map(
|
|
637
|
+
(v) =>
|
|
638
|
+
`<option value="${esc(v.version)}" ${v.version === c.version ? 'selected' : ''}>${esc(v.version)} — ${esc(v.label)}${v.default ? ' (default)' : ''}</option>`,
|
|
639
|
+
)
|
|
640
|
+
.join('');
|
|
641
|
+
const caps = c.capabilities || {};
|
|
642
|
+
openSimpleModal({
|
|
643
|
+
title: `Edit config — ${c.name}`,
|
|
644
|
+
fields: [
|
|
645
|
+
{ name: 'version', label: 'InfluxDB version (pin)', input: `<select name="version">${options}</select>` },
|
|
646
|
+
{ name: 'port', label: 'Port', type: 'number', value: c.config.port },
|
|
647
|
+
{ name: 'host', label: 'Host / bind IP', value: c.config.host },
|
|
648
|
+
{ name: 'database', label: c.flavor === 'v2' ? 'Bucket' : 'Database', value: c.config.database },
|
|
649
|
+
...(caps.measurement ? [{ name: 'measurement', label: 'Sample measurement', value: c.config.measurement }] : []),
|
|
650
|
+
...(c.flavor === 'v2' ? [{ name: 'org', label: 'Organization', value: c.config.org }] : []),
|
|
651
|
+
...(c.flavor !== 'v3' ? [{ name: 'adminUser', label: 'Admin user', value: c.config.adminUser }] : []),
|
|
652
|
+
...(c.flavor === 'v1' ? [{ name: 'appUser', label: 'App user', value: c.config.appUser }] : []),
|
|
653
|
+
{ name: 'publicHost', label: 'Public host (for public DSNs)', value: c.config.publicHost, placeholder: '(none)' },
|
|
654
|
+
{ type: 'html', html: `<p class="hint">Changing the line (1.x/2.x/3.x) requires an instance without data. Restart after saving.</p>` },
|
|
655
|
+
],
|
|
656
|
+
onSubmit: async (v) => {
|
|
657
|
+
await api(`/api/instances/${encodeURIComponent(c.name)}/config`, {
|
|
658
|
+
method: 'PUT',
|
|
659
|
+
body: JSON.stringify(v),
|
|
660
|
+
});
|
|
661
|
+
await loadDetail(c.name).catch(() => {});
|
|
662
|
+
await pollState();
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function openDestroyModal() {
|
|
668
|
+
$('#destroy-instance').textContent = current.name;
|
|
669
|
+
const input = $('#destroy-confirm');
|
|
670
|
+
const go = $('#btn-destroy-go');
|
|
671
|
+
input.value = '';
|
|
672
|
+
go.disabled = true;
|
|
673
|
+
input.oninput = () => (go.disabled = input.value !== current.name);
|
|
674
|
+
go.onclick = async () => {
|
|
675
|
+
closeModal('modal-destroy');
|
|
676
|
+
if (opBusy) return alert('another operation is running');
|
|
677
|
+
try {
|
|
678
|
+
await api(`/api/instances/${encodeURIComponent(current.name)}/destroy`, { method: 'DELETE' });
|
|
679
|
+
selected = null;
|
|
680
|
+
current = null;
|
|
681
|
+
await pollState();
|
|
682
|
+
showOverview();
|
|
683
|
+
} catch (err) {
|
|
684
|
+
alert(err.message);
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
const dlg = $('#modal-destroy');
|
|
688
|
+
if (!dlg.open) dlg.showModal();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// --- new instance modal -----------------------------------------------------
|
|
692
|
+
|
|
693
|
+
function renderVersionSelect() {
|
|
694
|
+
const select = $('#new-version');
|
|
695
|
+
const groups = new Map();
|
|
696
|
+
for (const v of catalog.versions) {
|
|
697
|
+
if (!groups.has(v.flavor)) groups.set(v.flavor, []);
|
|
698
|
+
groups.get(v.flavor).push(v);
|
|
699
|
+
}
|
|
700
|
+
select.innerHTML = [...groups.entries()]
|
|
701
|
+
.map(
|
|
702
|
+
([flavorId, list]) =>
|
|
703
|
+
`<optgroup label="${esc(list[0].label)}">${list
|
|
704
|
+
.map((v) => `<option value="${esc(v.version)}" ${v.default ? 'selected' : ''}>${esc(v.version)}${v.default ? ' — default' : ''}</option>`)
|
|
705
|
+
.join('')}</optgroup>`,
|
|
706
|
+
)
|
|
707
|
+
.join('');
|
|
708
|
+
onVersionChange();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function versionEntry(version) {
|
|
712
|
+
return catalog.versions.find((v) => v.version === version) || null;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function onVersionChange() {
|
|
716
|
+
const entry = versionEntry($('#new-version').value);
|
|
717
|
+
const hint = $('#new-version-hint');
|
|
718
|
+
if (!entry) {
|
|
719
|
+
hint.textContent = '';
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
const flavor = catalog.flavors.find((f) => f.id === entry.flavor) || {};
|
|
723
|
+
hint.innerHTML = `${esc(entry.label)} — auth: <b>${esc(entry.auth)}</b>, default port ${esc(entry.defaultPort)}. ` +
|
|
724
|
+
(entry.flavor === 'v1'
|
|
725
|
+
? 'Admin user with ALL PRIVILEGES + app user scoped to the database.'
|
|
726
|
+
: entry.flavor === 'v2'
|
|
727
|
+
? 'Operator user + all-access token, plus a bucket-scoped app token.'
|
|
728
|
+
: 'A single admin token (no per-database scoping).');
|
|
729
|
+
$('#new-port').value = entry.defaultPort;
|
|
730
|
+
const caps = flavor.capabilities || {};
|
|
731
|
+
$('#new-measurement-wrap').classList.toggle('hidden', !caps.measurement);
|
|
732
|
+
$('#new-org-wrap').classList.toggle('hidden', entry.flavor !== 'v2');
|
|
733
|
+
$('#new-adminuser-wrap').classList.toggle('hidden', entry.flavor === 'v3');
|
|
734
|
+
$('#new-appuser-wrap').classList.toggle('hidden', entry.flavor !== 'v1');
|
|
735
|
+
$('#new-adminpw-wrap').classList.toggle('hidden', entry.flavor === 'v3');
|
|
736
|
+
$('#new-apppw-wrap').classList.toggle('hidden', entry.flavor !== 'v1');
|
|
737
|
+
$('#new-creds-hint').innerHTML =
|
|
738
|
+
entry.flavor === 'v3'
|
|
739
|
+
? 'InfluxDB 3 Core has no users — the server mints an admin token during <code>setup</code>.'
|
|
740
|
+
: 'Leave a password empty to auto-generate it (stored in <code>creds.env</code>, mode 0600).';
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function openNewModal() {
|
|
744
|
+
const form = $('#form-new');
|
|
745
|
+
form.reset();
|
|
746
|
+
renderVersionSelect();
|
|
747
|
+
const dlg = $('#modal-new');
|
|
748
|
+
if (!dlg.open) dlg.showModal();
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// --- wiring -----------------------------------------------------------------
|
|
752
|
+
|
|
753
|
+
function ensureConsoleOpen() {
|
|
754
|
+
const wrap = document.querySelector('.console-wrap');
|
|
755
|
+
if (wrap && wrap.classList.contains('collapsed')) {
|
|
756
|
+
localStorage.removeItem('influx-local-console-collapsed');
|
|
757
|
+
wrap.classList.remove('collapsed');
|
|
758
|
+
$('#btn-console-toggle').textContent = '−';
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function wire() {
|
|
763
|
+
$('#btn-refresh').onclick = () => pollState();
|
|
764
|
+
$('#btn-new').onclick = () => openNewModal();
|
|
765
|
+
$('#new-version').onchange = () => onVersionChange();
|
|
766
|
+
$('#instance-list').addEventListener('click', onRowActionDelegate);
|
|
767
|
+
$('#btn-console-toggle').onclick = () => {
|
|
768
|
+
const wrap = document.querySelector('.console-wrap');
|
|
769
|
+
const collapsed = wrap.classList.toggle('collapsed');
|
|
770
|
+
$('#btn-console-toggle').textContent = collapsed ? '+' : '−';
|
|
771
|
+
localStorage.setItem('influx-local-console-collapsed', collapsed ? '1' : '');
|
|
772
|
+
};
|
|
773
|
+
if (localStorage.getItem('influx-local-console-collapsed')) {
|
|
774
|
+
document.querySelector('.console-wrap').classList.add('collapsed');
|
|
775
|
+
$('#btn-console-toggle').textContent = '+';
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
$('#btn-simple-cancel').onclick = () => closeModal('modal-simple');
|
|
779
|
+
$('#btn-new-cancel').onclick = () => closeModal('modal-new');
|
|
780
|
+
$('#btn-viewer-close').onclick = () => closeModal('modal-viewer');
|
|
781
|
+
$('#btn-destroy-cancel').onclick = () => closeModal('modal-destroy');
|
|
782
|
+
|
|
783
|
+
$('#form-new').addEventListener('submit', async (e) => {
|
|
784
|
+
e.preventDefault();
|
|
785
|
+
if (e.submitter && e.submitter.value === 'cancel') return closeModal('modal-new');
|
|
786
|
+
const form = new FormData($('#form-new'));
|
|
787
|
+
const body = Object.fromEntries(form.entries());
|
|
788
|
+
try {
|
|
789
|
+
await api('/api/instances', { method: 'POST', body: JSON.stringify(body) });
|
|
790
|
+
closeModal('modal-new');
|
|
791
|
+
await pollState();
|
|
792
|
+
await select(body.name);
|
|
793
|
+
} catch (err) {
|
|
794
|
+
alert(err.message);
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
document.querySelectorAll('[data-eye]').forEach(
|
|
799
|
+
(btn) =>
|
|
800
|
+
(btn.onclick = () => {
|
|
801
|
+
const input = $('#form-new').querySelector(`[name="${btn.dataset.eye}"]`);
|
|
802
|
+
if (input) input.type = input.type === 'password' ? 'text' : 'password';
|
|
803
|
+
}),
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
(async function main() {
|
|
808
|
+
wire();
|
|
809
|
+
try {
|
|
810
|
+
catalog = await api('/api/versions');
|
|
811
|
+
} catch (err) {
|
|
812
|
+
console.error('failed to load the version catalog', err);
|
|
813
|
+
}
|
|
814
|
+
await pollState();
|
|
815
|
+
setInterval(pollState, 2000);
|
|
816
|
+
})();
|