@elinpf/dsh-ops-access-hub 0.2.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/README.md +53 -0
- package/README.zh.md +52 -0
- package/lib/cli.d.ts +16 -0
- package/lib/cli.js +142 -0
- package/lib/crypto.d.ts +38 -0
- package/lib/crypto.js +82 -0
- package/lib/import.d.ts +50 -0
- package/lib/import.js +123 -0
- package/lib/index.d.ts +20 -0
- package/lib/index.js +17 -0
- package/lib/server.d.ts +39 -0
- package/lib/server.js +303 -0
- package/lib/store.d.ts +141 -0
- package/lib/store.js +229 -0
- package/lib/web.d.ts +13 -0
- package/lib/web.js +245 -0
- package/package.json +38 -0
package/lib/web.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's built-in web UI: a single static HTML page with inline vanilla
|
|
3
|
+
* JS/CSS — no build chain, no external CDN. Served at `GET /` without auth;
|
|
4
|
+
* the page itself prompts for a Bearer token (kept in localStorage) and all
|
|
5
|
+
* data fetches carry it, so no secret material is embedded in the HTML.
|
|
6
|
+
*
|
|
7
|
+
* UI copy is Chinese per repo convention for operator-facing surfaces.
|
|
8
|
+
* NOTE: this string must not contain backticks or `${` sequences — the
|
|
9
|
+
* inline JS uses string concatenation instead of template literals.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
export declare const WEB_UI_HTML = "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>dsh-ops-access-hub</title>\n<style>\n :root { color-scheme: light dark; }\n * { box-sizing: border-box; }\n body { font-family: system-ui, -apple-system, \"Segoe UI\", sans-serif; margin: 0; background: #0f1419; color: #d8dee6; }\n header { display: flex; gap: 8px; align-items: center; padding: 12px 16px; background: #161d26; border-bottom: 1px solid #2a3441; flex-wrap: wrap; }\n header h1 { font-size: 16px; margin: 0 12px 0 0; }\n input, select, textarea, button { font: inherit; border-radius: 4px; border: 1px solid #3a4655; background: #0f1419; color: inherit; padding: 6px 8px; }\n button { cursor: pointer; background: #1f2937; }\n button:hover { background: #2a3646; }\n button.danger { border-color: #7f2d2d; color: #f0a0a0; }\n main { padding: 16px; max-width: 1100px; margin: 0 auto; }\n table { width: 100%; border-collapse: collapse; margin-top: 12px; }\n th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #2a3441; vertical-align: top; }\n th { color: #8b98a9; font-weight: 600; font-size: 13px; }\n .badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; margin-right: 4px; border: 1px solid #3a4655; }\n .badge.verified { border-color: #2f7d4f; color: #7fd6a4; }\n .badge.mismatch { border-color: #a33; color: #f0a0a0; }\n .badge.unverifiable { border-color: #8a6d1d; color: #e6c96a; }\n .muted { color: #8b98a9; }\n #msg { margin: 8px 0; min-height: 20px; color: #f0a0a0; white-space: pre-wrap; }\n #msg.ok { color: #7fd6a4; }\n dialog { background: #161d26; color: inherit; border: 1px solid #3a4655; border-radius: 8px; padding: 16px; width: min(640px, 92vw); }\n dialog label { display: block; margin: 8px 0 2px; font-size: 13px; color: #8b98a9; }\n dialog input, dialog select, dialog textarea { width: 100%; }\n dialog textarea { min-height: 160px; font-family: ui-monospace, monospace; font-size: 13px; }\n .row { display: flex; gap: 8px; }\n .row > div { flex: 1; }\n section { margin-top: 24px; }\n h2 { font-size: 15px; }\n</style>\n</head>\n<body>\n<header>\n <h1>dsh-ops-access-hub</h1>\n <input id=\"token\" type=\"password\" placeholder=\"Bearer token\" size=\"36\">\n <button id=\"saveToken\">\u4FDD\u5B58 token</button>\n <button id=\"refresh\">\u5237\u65B0</button>\n <button id=\"newEntry\">\u65B0\u5EFA\u6761\u76EE</button>\n <button id=\"showAudit\">\u5BA1\u8BA1\u8BB0\u5F55</button>\n <span id=\"whoami\" class=\"muted\"></span>\n</header>\n<main>\n <div id=\"msg\"></div>\n <section>\n <h2>\u6761\u76EE</h2>\n <table>\n <thead><tr><th>kind / name</th><th>\u663E\u793A\u540D</th><th>\u63CF\u8FF0</th><th>\u73AF\u5883</th><th>tier</th><th>\u66F4\u65B0\u65F6\u95F4</th><th>\u64CD\u4F5C</th></tr></thead>\n <tbody id=\"entries\"></tbody>\n </table>\n </section>\n <section id=\"auditSection\" style=\"display:none\">\n <h2>\u5BA1\u8BA1\u8BB0\u5F55(\u6700\u8FD1 100 \u6761)</h2>\n <table>\n <thead><tr><th>\u65F6\u95F4</th><th>\u89D2\u8272</th><th>\u52A8\u4F5C</th><th>\u6761\u76EE</th><th>tier</th></tr></thead>\n <tbody id=\"audit\"></tbody>\n </table>\n </section>\n</main>\n<dialog id=\"editor\">\n <h2 id=\"editorTitle\">\u7F16\u8F91\u6761\u76EE</h2>\n <div class=\"row\">\n <div><label>kind</label><input id=\"fKind\" placeholder=\"k8s\"></div>\n <div><label>profile \u540D</label><input id=\"fName\" placeholder=\"prod\"></div>\n <div><label>tier</label><select id=\"fTier\"><option value=\"ro\">ro</option><option value=\"rw\">rw</option></select></div>\n </div>\n <div class=\"row\">\n <div><label>\u663E\u793A\u540D(envelope.name)</label><input id=\"fDisp\"></div>\n <div><label>\u73AF\u5883(envelope.environment)</label><input id=\"fEnv\"></div>\n </div>\n <label>\u63CF\u8FF0(envelope.description)</label><input id=\"fDesc\">\n <label>\u5B57\u6BB5(JSON object,\u503C\u4E3A\u5B57\u6BB5\u5185\u5BB9)</label>\n <textarea id=\"fFields\" spellcheck=\"false\"></textarea>\n <div id=\"formErr\" style=\"color:#f0a0a0;min-height:18px;margin-top:4px\"></div>\n <div style=\"margin-top:12px;text-align:right\">\n <button id=\"cancelEdit\">\u53D6\u6D88</button>\n <button id=\"saveEntry\">\u4FDD\u5B58</button>\n </div>\n</dialog>\n<script>\n(function () {\n var token = localStorage.getItem('hubToken') || '';\n var tokenInput = document.getElementById('token');\n tokenInput.value = token;\n var msg = document.getElementById('msg');\n var entriesBody = document.getElementById('entries');\n var auditSection = document.getElementById('auditSection');\n var auditBody = document.getElementById('audit');\n var editor = document.getElementById('editor');\n var formErr = document.getElementById('formErr');\n var entriesCache = [];\n\n function esc(s) {\n return String(s == null ? '' : s).replace(/[&<>\"']/g, function (c) {\n return { '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[c];\n });\n }\n function say(text, ok) {\n msg.textContent = text || '';\n msg.className = ok ? 'ok' : '';\n }\n function api(path, opts) {\n opts = opts || {};\n opts.headers = { 'Authorization': 'Bearer ' + token };\n if (opts.body) opts.headers['Content-Type'] = 'application/json';\n return fetch(path, opts).then(function (res) {\n return res.json().then(function (body) { return { status: res.status, body: body }; });\n });\n }\n function probeBadge(tierInfo) {\n if (!tierInfo) return '';\n var p = tierInfo.probe;\n if (!p) return '<span class=\"badge\">\u65E0 probe</span>';\n return '<span class=\"badge ' + esc(p.status) + '\" title=\"' + esc(p.detail || '') + ' ' + esc(p.probedAt) + '\">' + esc(p.status) + '</span>';\n }\n function tierCell(e, tier) {\n var info = e.tiers && e.tiers[tier];\n if (!info) return '<span class=\"muted\">' + tier + ': \u2014</span><br>';\n return tier + ': ' + probeBadge(info) +\n ' <button data-edit=\"' + esc(e.kind) + '|' + esc(e.name) + '|' + tier + '\">\u7F16\u8F91</button>' +\n ' <button class=\"danger\" data-del=\"' + esc(e.kind) + '|' + esc(e.name) + '|' + tier + '\">\u5220\u9664</button><br>';\n }\n function render() {\n entriesBody.innerHTML = entriesCache.map(function (e) {\n var env = e.envelope || {};\n return '<tr><td><b>' + esc(e.kind) + '</b> / ' + esc(e.name) + '</td><td>' + esc(env.name || '') +\n '</td><td>' + esc(env.description || '') + '</td><td>' + esc(env.environment || '') +\n '</td><td>' + tierCell(e, 'ro') + tierCell(e, 'rw') +\n '</td><td class=\"muted\">' + esc(e.updatedAt || '') + '</td><td></td></tr>';\n }).join('') || '<tr><td colspan=\"7\" class=\"muted\">(\u7A7A)</td></tr>';\n }\n function load() {\n api('/entries').then(function (r) {\n if (r.status !== 200) { say('\u52A0\u8F7D\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n entriesCache = r.body;\n render();\n say('');\n }).catch(function (err) { say('\u8BF7\u6C42\u5931\u8D25:' + err.message); });\n }\n document.getElementById('saveToken').onclick = function () {\n token = tokenInput.value.trim();\n localStorage.setItem('hubToken', token);\n say('token \u5DF2\u4FDD\u5B58', true);\n load();\n };\n document.getElementById('refresh').onclick = load;\n entriesBody.addEventListener('click', function (ev) {\n var t = ev.target;\n if (!(t instanceof HTMLElement)) return;\n var edit = t.getAttribute('data-edit');\n var del = t.getAttribute('data-del');\n if (edit) { var p = edit.split('|'); openEditor(p[0], p[1], p[2]); }\n if (del) {\n var q = del.split('|');\n if (!confirm('\u786E\u8BA4\u5220\u9664 ' + q[0] + '/' + q[1] + ' \u7684 ' + q[2] + ' tier?')) return;\n api('/entries/' + encodeURIComponent(q[0]) + '/' + encodeURIComponent(q[1]) + '/' + q[2], { method: 'DELETE' })\n .then(function (r) {\n if (r.status !== 200) { say('\u5220\u9664\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n say('\u5DF2\u5220\u9664', true); load();\n });\n }\n });\n function openEditor(kind, name, tier) {\n document.getElementById('fKind').value = kind || '';\n document.getElementById('fName').value = name || '';\n document.getElementById('fTier').value = tier || 'ro';\n document.getElementById('fKind').disabled = !!kind;\n document.getElementById('fName').disabled = !!name;\n document.getElementById('fTier').disabled = !!tier;\n formErr.textContent = '';\n var env = {};\n for (var i = 0; i < entriesCache.length; i++) {\n if (entriesCache[i].kind === kind && entriesCache[i].name === name) env = entriesCache[i].envelope || {};\n }\n document.getElementById('fDisp').value = env.name || '';\n document.getElementById('fDesc').value = env.description || '';\n document.getElementById('fEnv').value = env.environment || '';\n var fieldsBox = document.getElementById('fFields');\n fieldsBox.value = '{}';\n if (kind && name && tier) {\n api('/entries/' + encodeURIComponent(kind) + '/' + encodeURIComponent(name) + '/' + tier).then(function (r) {\n if (r.status === 200) fieldsBox.value = JSON.stringify(r.body.fields || {}, null, 2);\n });\n }\n editor.showModal();\n }\n document.getElementById('newEntry').onclick = function () { openEditor('', '', ''); };\n document.getElementById('cancelEdit').onclick = function () { editor.close(); };\n document.getElementById('saveEntry').onclick = function () {\n var kind = document.getElementById('fKind').value.trim();\n var name = document.getElementById('fName').value.trim();\n var tier = document.getElementById('fTier').value;\n var fields;\n try { fields = JSON.parse(document.getElementById('fFields').value); }\n catch (err) { formErr.textContent = '\u5B57\u6BB5 JSON \u89E3\u6790\u5931\u8D25:' + err.message; return; }\n if (!fields || typeof fields !== 'object' || Array.isArray(fields)) { formErr.textContent = '\u5B57\u6BB5\u5FC5\u987B\u662F JSON object'; return; }\n var envelope = {};\n var disp = document.getElementById('fDisp').value.trim();\n var desc = document.getElementById('fDesc').value.trim();\n var envv = document.getElementById('fEnv').value.trim();\n if (disp) envelope.name = disp;\n if (desc) envelope.description = desc;\n if (envv) envelope.environment = envv;\n api('/entries/' + encodeURIComponent(kind) + '/' + encodeURIComponent(name) + '/' + tier, {\n method: 'PUT',\n body: JSON.stringify({ fields: fields, envelope: envelope }),\n }).then(function (r) {\n if (r.status !== 200) { formErr.textContent = '\u4FDD\u5B58\u5931\u8D25:' + (r.body && r.body.error || r.status); return; }\n editor.close(); say('\u5DF2\u4FDD\u5B58', true); load();\n });\n };\n document.getElementById('showAudit').onclick = function () {\n auditSection.style.display = auditSection.style.display === 'none' ? '' : 'none';\n if (auditSection.style.display === 'none') return;\n api('/audit?limit=100').then(function (r) {\n if (r.status !== 200) { say('\u5BA1\u8BA1\u52A0\u8F7D\u5931\u8D25:' + (r.body && r.body.error || r.status)); return; }\n auditBody.innerHTML = r.body.map(function (a) {\n return '<tr><td class=\"muted\">' + esc(a.ts) + '</td><td>' + esc(a.role) + '</td><td>' + esc(a.action) +\n '</td><td>' + esc(a.kind) + ' / ' + esc(a.name) + '</td><td>' + esc(a.tier) + '</td></tr>';\n }).join('') || '<tr><td colspan=\"5\" class=\"muted\">(\u7A7A)</td></tr>';\n });\n };\n load();\n})();\n</script>\n</body>\n</html>\n";
|
package/lib/web.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hub's built-in web UI: a single static HTML page with inline vanilla
|
|
3
|
+
* JS/CSS — no build chain, no external CDN. Served at `GET /` without auth;
|
|
4
|
+
* the page itself prompts for a Bearer token (kept in localStorage) and all
|
|
5
|
+
* data fetches carry it, so no secret material is embedded in the HTML.
|
|
6
|
+
*
|
|
7
|
+
* UI copy is Chinese per repo convention for operator-facing surfaces.
|
|
8
|
+
* NOTE: this string must not contain backticks or `${` sequences — the
|
|
9
|
+
* inline JS uses string concatenation instead of template literals.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
export const WEB_UI_HTML = `<!DOCTYPE html>
|
|
14
|
+
<html lang="zh-CN">
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="utf-8">
|
|
17
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
18
|
+
<title>dsh-ops-access-hub</title>
|
|
19
|
+
<style>
|
|
20
|
+
:root { color-scheme: light dark; }
|
|
21
|
+
* { box-sizing: border-box; }
|
|
22
|
+
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0; background: #0f1419; color: #d8dee6; }
|
|
23
|
+
header { display: flex; gap: 8px; align-items: center; padding: 12px 16px; background: #161d26; border-bottom: 1px solid #2a3441; flex-wrap: wrap; }
|
|
24
|
+
header h1 { font-size: 16px; margin: 0 12px 0 0; }
|
|
25
|
+
input, select, textarea, button { font: inherit; border-radius: 4px; border: 1px solid #3a4655; background: #0f1419; color: inherit; padding: 6px 8px; }
|
|
26
|
+
button { cursor: pointer; background: #1f2937; }
|
|
27
|
+
button:hover { background: #2a3646; }
|
|
28
|
+
button.danger { border-color: #7f2d2d; color: #f0a0a0; }
|
|
29
|
+
main { padding: 16px; max-width: 1100px; margin: 0 auto; }
|
|
30
|
+
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
|
31
|
+
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #2a3441; vertical-align: top; }
|
|
32
|
+
th { color: #8b98a9; font-weight: 600; font-size: 13px; }
|
|
33
|
+
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; margin-right: 4px; border: 1px solid #3a4655; }
|
|
34
|
+
.badge.verified { border-color: #2f7d4f; color: #7fd6a4; }
|
|
35
|
+
.badge.mismatch { border-color: #a33; color: #f0a0a0; }
|
|
36
|
+
.badge.unverifiable { border-color: #8a6d1d; color: #e6c96a; }
|
|
37
|
+
.muted { color: #8b98a9; }
|
|
38
|
+
#msg { margin: 8px 0; min-height: 20px; color: #f0a0a0; white-space: pre-wrap; }
|
|
39
|
+
#msg.ok { color: #7fd6a4; }
|
|
40
|
+
dialog { background: #161d26; color: inherit; border: 1px solid #3a4655; border-radius: 8px; padding: 16px; width: min(640px, 92vw); }
|
|
41
|
+
dialog label { display: block; margin: 8px 0 2px; font-size: 13px; color: #8b98a9; }
|
|
42
|
+
dialog input, dialog select, dialog textarea { width: 100%; }
|
|
43
|
+
dialog textarea { min-height: 160px; font-family: ui-monospace, monospace; font-size: 13px; }
|
|
44
|
+
.row { display: flex; gap: 8px; }
|
|
45
|
+
.row > div { flex: 1; }
|
|
46
|
+
section { margin-top: 24px; }
|
|
47
|
+
h2 { font-size: 15px; }
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<header>
|
|
52
|
+
<h1>dsh-ops-access-hub</h1>
|
|
53
|
+
<input id="token" type="password" placeholder="Bearer token" size="36">
|
|
54
|
+
<button id="saveToken">保存 token</button>
|
|
55
|
+
<button id="refresh">刷新</button>
|
|
56
|
+
<button id="newEntry">新建条目</button>
|
|
57
|
+
<button id="showAudit">审计记录</button>
|
|
58
|
+
<span id="whoami" class="muted"></span>
|
|
59
|
+
</header>
|
|
60
|
+
<main>
|
|
61
|
+
<div id="msg"></div>
|
|
62
|
+
<section>
|
|
63
|
+
<h2>条目</h2>
|
|
64
|
+
<table>
|
|
65
|
+
<thead><tr><th>kind / name</th><th>显示名</th><th>描述</th><th>环境</th><th>tier</th><th>更新时间</th><th>操作</th></tr></thead>
|
|
66
|
+
<tbody id="entries"></tbody>
|
|
67
|
+
</table>
|
|
68
|
+
</section>
|
|
69
|
+
<section id="auditSection" style="display:none">
|
|
70
|
+
<h2>审计记录(最近 100 条)</h2>
|
|
71
|
+
<table>
|
|
72
|
+
<thead><tr><th>时间</th><th>角色</th><th>动作</th><th>条目</th><th>tier</th></tr></thead>
|
|
73
|
+
<tbody id="audit"></tbody>
|
|
74
|
+
</table>
|
|
75
|
+
</section>
|
|
76
|
+
</main>
|
|
77
|
+
<dialog id="editor">
|
|
78
|
+
<h2 id="editorTitle">编辑条目</h2>
|
|
79
|
+
<div class="row">
|
|
80
|
+
<div><label>kind</label><input id="fKind" placeholder="k8s"></div>
|
|
81
|
+
<div><label>profile 名</label><input id="fName" placeholder="prod"></div>
|
|
82
|
+
<div><label>tier</label><select id="fTier"><option value="ro">ro</option><option value="rw">rw</option></select></div>
|
|
83
|
+
</div>
|
|
84
|
+
<div class="row">
|
|
85
|
+
<div><label>显示名(envelope.name)</label><input id="fDisp"></div>
|
|
86
|
+
<div><label>环境(envelope.environment)</label><input id="fEnv"></div>
|
|
87
|
+
</div>
|
|
88
|
+
<label>描述(envelope.description)</label><input id="fDesc">
|
|
89
|
+
<label>字段(JSON object,值为字段内容)</label>
|
|
90
|
+
<textarea id="fFields" spellcheck="false"></textarea>
|
|
91
|
+
<div id="formErr" style="color:#f0a0a0;min-height:18px;margin-top:4px"></div>
|
|
92
|
+
<div style="margin-top:12px;text-align:right">
|
|
93
|
+
<button id="cancelEdit">取消</button>
|
|
94
|
+
<button id="saveEntry">保存</button>
|
|
95
|
+
</div>
|
|
96
|
+
</dialog>
|
|
97
|
+
<script>
|
|
98
|
+
(function () {
|
|
99
|
+
var token = localStorage.getItem('hubToken') || '';
|
|
100
|
+
var tokenInput = document.getElementById('token');
|
|
101
|
+
tokenInput.value = token;
|
|
102
|
+
var msg = document.getElementById('msg');
|
|
103
|
+
var entriesBody = document.getElementById('entries');
|
|
104
|
+
var auditSection = document.getElementById('auditSection');
|
|
105
|
+
var auditBody = document.getElementById('audit');
|
|
106
|
+
var editor = document.getElementById('editor');
|
|
107
|
+
var formErr = document.getElementById('formErr');
|
|
108
|
+
var entriesCache = [];
|
|
109
|
+
|
|
110
|
+
function esc(s) {
|
|
111
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
|
112
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function say(text, ok) {
|
|
116
|
+
msg.textContent = text || '';
|
|
117
|
+
msg.className = ok ? 'ok' : '';
|
|
118
|
+
}
|
|
119
|
+
function api(path, opts) {
|
|
120
|
+
opts = opts || {};
|
|
121
|
+
opts.headers = { 'Authorization': 'Bearer ' + token };
|
|
122
|
+
if (opts.body) opts.headers['Content-Type'] = 'application/json';
|
|
123
|
+
return fetch(path, opts).then(function (res) {
|
|
124
|
+
return res.json().then(function (body) { return { status: res.status, body: body }; });
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
function probeBadge(tierInfo) {
|
|
128
|
+
if (!tierInfo) return '';
|
|
129
|
+
var p = tierInfo.probe;
|
|
130
|
+
if (!p) return '<span class="badge">无 probe</span>';
|
|
131
|
+
return '<span class="badge ' + esc(p.status) + '" title="' + esc(p.detail || '') + ' ' + esc(p.probedAt) + '">' + esc(p.status) + '</span>';
|
|
132
|
+
}
|
|
133
|
+
function tierCell(e, tier) {
|
|
134
|
+
var info = e.tiers && e.tiers[tier];
|
|
135
|
+
if (!info) return '<span class="muted">' + tier + ': —</span><br>';
|
|
136
|
+
return tier + ': ' + probeBadge(info) +
|
|
137
|
+
' <button data-edit="' + esc(e.kind) + '|' + esc(e.name) + '|' + tier + '">编辑</button>' +
|
|
138
|
+
' <button class="danger" data-del="' + esc(e.kind) + '|' + esc(e.name) + '|' + tier + '">删除</button><br>';
|
|
139
|
+
}
|
|
140
|
+
function render() {
|
|
141
|
+
entriesBody.innerHTML = entriesCache.map(function (e) {
|
|
142
|
+
var env = e.envelope || {};
|
|
143
|
+
return '<tr><td><b>' + esc(e.kind) + '</b> / ' + esc(e.name) + '</td><td>' + esc(env.name || '') +
|
|
144
|
+
'</td><td>' + esc(env.description || '') + '</td><td>' + esc(env.environment || '') +
|
|
145
|
+
'</td><td>' + tierCell(e, 'ro') + tierCell(e, 'rw') +
|
|
146
|
+
'</td><td class="muted">' + esc(e.updatedAt || '') + '</td><td></td></tr>';
|
|
147
|
+
}).join('') || '<tr><td colspan="7" class="muted">(空)</td></tr>';
|
|
148
|
+
}
|
|
149
|
+
function load() {
|
|
150
|
+
api('/entries').then(function (r) {
|
|
151
|
+
if (r.status !== 200) { say('加载失败:' + (r.body && r.body.error || r.status)); return; }
|
|
152
|
+
entriesCache = r.body;
|
|
153
|
+
render();
|
|
154
|
+
say('');
|
|
155
|
+
}).catch(function (err) { say('请求失败:' + err.message); });
|
|
156
|
+
}
|
|
157
|
+
document.getElementById('saveToken').onclick = function () {
|
|
158
|
+
token = tokenInput.value.trim();
|
|
159
|
+
localStorage.setItem('hubToken', token);
|
|
160
|
+
say('token 已保存', true);
|
|
161
|
+
load();
|
|
162
|
+
};
|
|
163
|
+
document.getElementById('refresh').onclick = load;
|
|
164
|
+
entriesBody.addEventListener('click', function (ev) {
|
|
165
|
+
var t = ev.target;
|
|
166
|
+
if (!(t instanceof HTMLElement)) return;
|
|
167
|
+
var edit = t.getAttribute('data-edit');
|
|
168
|
+
var del = t.getAttribute('data-del');
|
|
169
|
+
if (edit) { var p = edit.split('|'); openEditor(p[0], p[1], p[2]); }
|
|
170
|
+
if (del) {
|
|
171
|
+
var q = del.split('|');
|
|
172
|
+
if (!confirm('确认删除 ' + q[0] + '/' + q[1] + ' 的 ' + q[2] + ' tier?')) return;
|
|
173
|
+
api('/entries/' + encodeURIComponent(q[0]) + '/' + encodeURIComponent(q[1]) + '/' + q[2], { method: 'DELETE' })
|
|
174
|
+
.then(function (r) {
|
|
175
|
+
if (r.status !== 200) { say('删除失败:' + (r.body && r.body.error || r.status)); return; }
|
|
176
|
+
say('已删除', true); load();
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
function openEditor(kind, name, tier) {
|
|
181
|
+
document.getElementById('fKind').value = kind || '';
|
|
182
|
+
document.getElementById('fName').value = name || '';
|
|
183
|
+
document.getElementById('fTier').value = tier || 'ro';
|
|
184
|
+
document.getElementById('fKind').disabled = !!kind;
|
|
185
|
+
document.getElementById('fName').disabled = !!name;
|
|
186
|
+
document.getElementById('fTier').disabled = !!tier;
|
|
187
|
+
formErr.textContent = '';
|
|
188
|
+
var env = {};
|
|
189
|
+
for (var i = 0; i < entriesCache.length; i++) {
|
|
190
|
+
if (entriesCache[i].kind === kind && entriesCache[i].name === name) env = entriesCache[i].envelope || {};
|
|
191
|
+
}
|
|
192
|
+
document.getElementById('fDisp').value = env.name || '';
|
|
193
|
+
document.getElementById('fDesc').value = env.description || '';
|
|
194
|
+
document.getElementById('fEnv').value = env.environment || '';
|
|
195
|
+
var fieldsBox = document.getElementById('fFields');
|
|
196
|
+
fieldsBox.value = '{}';
|
|
197
|
+
if (kind && name && tier) {
|
|
198
|
+
api('/entries/' + encodeURIComponent(kind) + '/' + encodeURIComponent(name) + '/' + tier).then(function (r) {
|
|
199
|
+
if (r.status === 200) fieldsBox.value = JSON.stringify(r.body.fields || {}, null, 2);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
editor.showModal();
|
|
203
|
+
}
|
|
204
|
+
document.getElementById('newEntry').onclick = function () { openEditor('', '', ''); };
|
|
205
|
+
document.getElementById('cancelEdit').onclick = function () { editor.close(); };
|
|
206
|
+
document.getElementById('saveEntry').onclick = function () {
|
|
207
|
+
var kind = document.getElementById('fKind').value.trim();
|
|
208
|
+
var name = document.getElementById('fName').value.trim();
|
|
209
|
+
var tier = document.getElementById('fTier').value;
|
|
210
|
+
var fields;
|
|
211
|
+
try { fields = JSON.parse(document.getElementById('fFields').value); }
|
|
212
|
+
catch (err) { formErr.textContent = '字段 JSON 解析失败:' + err.message; return; }
|
|
213
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields)) { formErr.textContent = '字段必须是 JSON object'; return; }
|
|
214
|
+
var envelope = {};
|
|
215
|
+
var disp = document.getElementById('fDisp').value.trim();
|
|
216
|
+
var desc = document.getElementById('fDesc').value.trim();
|
|
217
|
+
var envv = document.getElementById('fEnv').value.trim();
|
|
218
|
+
if (disp) envelope.name = disp;
|
|
219
|
+
if (desc) envelope.description = desc;
|
|
220
|
+
if (envv) envelope.environment = envv;
|
|
221
|
+
api('/entries/' + encodeURIComponent(kind) + '/' + encodeURIComponent(name) + '/' + tier, {
|
|
222
|
+
method: 'PUT',
|
|
223
|
+
body: JSON.stringify({ fields: fields, envelope: envelope }),
|
|
224
|
+
}).then(function (r) {
|
|
225
|
+
if (r.status !== 200) { formErr.textContent = '保存失败:' + (r.body && r.body.error || r.status); return; }
|
|
226
|
+
editor.close(); say('已保存', true); load();
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
document.getElementById('showAudit').onclick = function () {
|
|
230
|
+
auditSection.style.display = auditSection.style.display === 'none' ? '' : 'none';
|
|
231
|
+
if (auditSection.style.display === 'none') return;
|
|
232
|
+
api('/audit?limit=100').then(function (r) {
|
|
233
|
+
if (r.status !== 200) { say('审计加载失败:' + (r.body && r.body.error || r.status)); return; }
|
|
234
|
+
auditBody.innerHTML = r.body.map(function (a) {
|
|
235
|
+
return '<tr><td class="muted">' + esc(a.ts) + '</td><td>' + esc(a.role) + '</td><td>' + esc(a.action) +
|
|
236
|
+
'</td><td>' + esc(a.kind) + ' / ' + esc(a.name) + '</td><td>' + esc(a.tier) + '</td></tr>';
|
|
237
|
+
}).join('') || '<tr><td colspan="5" class="muted">(空)</td></tr>';
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
load();
|
|
241
|
+
})();
|
|
242
|
+
</script>
|
|
243
|
+
</body>
|
|
244
|
+
</html>
|
|
245
|
+
`;
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elinpf/dsh-ops-access-hub",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Standalone credential hub for the dsh ops suite: encrypted-at-rest storage, token-authenticated REST API, minimal web UI, and a YAML registry importer. Not a dsh plugin.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"dsh-ops-access-hub": "lib/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"lib"
|
|
13
|
+
],
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"yaml": "^2.7.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.0.0",
|
|
19
|
+
"typescript": "^5.4.0",
|
|
20
|
+
"vitest": "^4.1.11"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./lib/index.d.ts",
|
|
26
|
+
"default": "./lib/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"test": "vitest run"
|
|
37
|
+
}
|
|
38
|
+
}
|