@alilis/k-hat 0.1.0 → 0.2.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/README.md +3 -3
- package/dist/admin.js +50 -9
- package/dist/cli.js +37 -8
- package/dist/config.js +7 -0
- package/dist/router.js +13 -5
- package/dist/selector.js +1 -1
- package/dist/server.js +13 -5
- package/dist/store.js +41 -0
- package/dist/tui-client.js +5 -0
- package/dist/tui-state.js +59 -0
- package/dist/tui.js +180 -234
- package/dist/web-ui.js +83 -72
- package/package.json +1 -1
package/dist/web-ui.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
const PAGE = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>khat 密钥管理</title><script>(function(){try{var t=localStorage.getItem('khatTheme');if(t!=='dark'&&t!=='light')t=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';document.documentElement.setAttribute('data-theme',t)}catch(e){document.documentElement.setAttribute('data-theme','light')}})();</script><style>
|
|
2
|
+
const PAGE = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title data-i18n="title">khat 密钥管理</title><script>(function(){try{var t=localStorage.getItem('khatTheme');if(t!=='dark'&&t!=='light')t=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';document.documentElement.setAttribute('data-theme',t)}catch(e){document.documentElement.setAttribute('data-theme','light')}try{var l=localStorage.getItem('khatLang');document.documentElement.lang=(l==='en')?'en':'zh-CN'}catch(e){document.documentElement.lang='zh-CN'}})();</script><style>
|
|
3
3
|
*{box-sizing:border-box}
|
|
4
4
|
:root{color-scheme:light;--bg:#f4f6fa;--surface:#ffffff;--surface-2:var(--surface-2);--border:#e5eaf1;--border-strong:#d5dee9;--text:#1c2540;--muted:#5d6a85;--faint:#93a0b5;--accent:#4f46e5;--accent-hover:#4338ca;--accent-soft:#eef2ff;--ok:#15803d;--ok-bg:#ecfdf5;--ok-border:#bcf0d2;--bad:#b91c1c;--bad-bg:#fef2f2;--bad-border:#fdd8d8;--warn:#92400e;--warn-bg:#fffbeb;--warn-border:#fbe38a;--topbar-bg:rgba(255,255,255,.88);--ring:rgba(79,70,229,.16);--shadow:0 1px 2px rgba(23,32,51,.04);--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,'Courier New',monospace}
|
|
5
5
|
[data-theme=dark]{color-scheme:dark;--bg:#0f1523;--surface:#161d2f;--surface-2:#1d2538;--border:#28324a;--border-strong:#3a4767;--text:#e4eaf5;--muted:#9aa6bd;--faint:#68748f;--accent:#818cf8;--accent-hover:#a5b4fc;--accent-soft:#232c52;--ok:#4ade80;--ok-bg:#122b20;--ok-border:#215239;--bad:#f87171;--bad-bg:#33181a;--bad-border:#5c2a2e;--warn:#fbbf24;--warn-bg:#30260f;--warn-border:#5e4a17;--topbar-bg:rgba(15,21,35,.88);--ring:rgba(129,140,248,.32);--shadow:0 1px 3px rgba(0,0,0,.35)}
|
|
@@ -19,6 +19,13 @@ main.container{padding-top:22px;padding-bottom:48px}
|
|
|
19
19
|
.alert:not(:empty){display:block}
|
|
20
20
|
.alert-error{background:var(--bad-bg);color:var(--bad);border:1px solid var(--bad-border)}
|
|
21
21
|
.alert-warn{background:var(--warn-bg);color:var(--warn);border:1px solid var(--warn-border)}
|
|
22
|
+
.toasts{position:fixed;top:16px;right:16px;z-index:100;display:flex;flex-direction:column;gap:8px;max-width:min(360px,92vw);pointer-events:none}
|
|
23
|
+
.toast{pointer-events:auto;background:var(--surface);border:1px solid var(--border-strong);border-left-width:4px;border-radius:10px;padding:10px 14px;font-size:13px;line-height:1.45;box-shadow:0 8px 24px rgba(15,23,42,.18);animation:slideIn .22s ease;word-break:break-word}
|
|
24
|
+
.toast-error{border-left-color:var(--bad);color:var(--bad)}
|
|
25
|
+
.toast-ok{border-left-color:var(--ok);color:var(--ok)}
|
|
26
|
+
.toast-warn{border-left-color:var(--warn);color:var(--warn)}
|
|
27
|
+
.toast.fade{opacity:0;transform:translateX(12px);transition:opacity .3s,transform .3s}
|
|
28
|
+
@keyframes slideIn{from{opacity:0;transform:translateX(16px)}to{opacity:1;transform:translateX(0)}}
|
|
22
29
|
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin-bottom:18px}
|
|
23
30
|
.stat{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px 16px;box-shadow:var(--shadow)}
|
|
24
31
|
.stat-label{font-size:12px;font-weight:600;color:var(--muted)}
|
|
@@ -89,81 +96,73 @@ tbody tr:hover td{background:var(--surface-2)}
|
|
|
89
96
|
#providerForm [name=baseUrl]{flex:1 1 220px;min-width:200px}
|
|
90
97
|
#keyForm [name=id]{width:170px}
|
|
91
98
|
#keyForm [name=value]{flex:1 1 220px;min-width:210px}
|
|
92
|
-
#routeForm [name=model]{width:230px;font-family:var(--mono)}
|
|
93
99
|
#providerForm>button,#keyForm>button,#routeForm>button{margin-left:auto}
|
|
94
|
-
dialog{border:none;border-radius:14px;padding:0;width:min(460px,92vw);box-shadow:0 20px 60px rgba(15,23,42,.28)}
|
|
95
|
-
dialog::backdrop{background:rgba(15,23,42,.45)}
|
|
96
|
-
.dialog-body{padding:22px 24px}
|
|
97
|
-
.dialog-title{margin:0 0 16px;font-size:15.5px;font-weight:700}
|
|
98
|
-
.field{display:flex;flex-direction:column;gap:5px;margin-bottom:12px;font-size:12.5px;font-weight:600;color:var(--muted)}
|
|
99
|
-
.field input{height:36px}
|
|
100
|
-
.field-row{display:flex;gap:12px}
|
|
101
|
-
.field-row .field{flex:1;min-width:0}
|
|
102
|
-
.field-row input{width:100%}
|
|
103
|
-
.dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}
|
|
104
100
|
@media(max-width:640px){.auto-note{display:none}.conn-badge{max-width:180px}}
|
|
105
101
|
</style></head><body>
|
|
102
|
+
<div id="toasts" class="toasts"></div>
|
|
106
103
|
<header class="topbar"><div class="container topbar-inner">
|
|
107
104
|
<div class="brand"><span class="logo" aria-hidden="true"></span><span>khat</span><span id="connection" class="conn-badge"></span></div>
|
|
108
|
-
<div class="topbar-actions"><button id="
|
|
105
|
+
<div class="topbar-actions"><span class="auto-note" data-i18n="autoRefresh">每 30 秒自动刷新</span><button id="langToggle" class="btn btn-secondary btn-icon" type="button" data-i18n-aria="toggleLang" title="中文 / English">EN</button><button id="themeToggle" class="btn btn-secondary btn-icon" type="button" data-i18n-aria="toggleTheme" title="切换深色 / 浅色模式">🌙</button><button id="refresh" class="btn btn-secondary" type="button" data-i18n="refresh">刷新</button></div>
|
|
109
106
|
</div></header>
|
|
110
107
|
<main class="container">
|
|
111
108
|
<p id="error" class="alert alert-error"></p>
|
|
112
109
|
<p id="poolWarn" class="alert alert-warn"></p>
|
|
113
110
|
<div class="stats">
|
|
114
|
-
<div class="stat"><div class="stat-label">Provider 数量</div><div class="stat-value" id="statProviders">—</div><div class="stat-sub">已配置的服务商</div></div>
|
|
115
|
-
<div class="stat"><div class="stat-label">Key 可用率</div><div class="stat-value" id="statKeys">—</div><div class="stat-sub" id="statKeysSub">—</div></div>
|
|
116
|
-
<div class="stat"><div class="stat-label">近期请求成功率</div><div class="stat-value" id="statReq">—</div><div class="stat-sub" id="statReqSub">按最近一次拉取的日志统计</div></div>
|
|
117
|
-
<div class="stat"><div class="stat-label">近期 Tokens 用量</div><div class="stat-value" id="statTokens">—</div><div class="stat-sub">输入 / 输出 合计</div></div>
|
|
111
|
+
<div class="stat"><div class="stat-label" data-i18n="statProviders">Provider 数量</div><div class="stat-value" id="statProviders">—</div><div class="stat-sub" data-i18n="statProvidersSub">已配置的服务商</div></div>
|
|
112
|
+
<div class="stat"><div class="stat-label" data-i18n="statKeys">Key 可用率</div><div class="stat-value" id="statKeys">—</div><div class="stat-sub" id="statKeysSub">—</div></div>
|
|
113
|
+
<div class="stat"><div class="stat-label" data-i18n="statReq">近期请求成功率</div><div class="stat-value" id="statReq">—</div><div class="stat-sub" id="statReqSub" data-i18n="statReqSub">按最近一次拉取的日志统计</div></div>
|
|
114
|
+
<div class="stat"><div class="stat-label" data-i18n="statTokens">近期 Tokens 用量</div><div class="stat-value" id="statTokens">—</div><div class="stat-sub" data-i18n="statTokensSub">输入 / 输出 合计</div></div>
|
|
118
115
|
</div>
|
|
119
116
|
<section class="card">
|
|
120
|
-
<div class="card-head"><div><h2 class="card-title">Provider / Key 管理</h2><p class="card-sub">维护服务商、密钥池与轮询权重;下方筛选均在本地完成,不会请求服务端。</p></div></div>
|
|
121
|
-
<form id="providerForm" class="toolbar" autocomplete="off"><input name="id" placeholder="provider id" required><input name="name" placeholder="名称"><input name="baseUrl" placeholder="https://api.example.com" required><select name="protocol"><option>openai</option><option>anthropic</option></select><button class="btn btn-primary">添加 Provider</button></form>
|
|
122
|
-
<form id="keyForm" class="toolbar" autocomplete="off"><select name="provider" required></select><input name="id" placeholder="key id" required><input name="weight" type="number" min="1" step="1" value="1" title="
|
|
123
|
-
<div class="filter-bar"><input id="keySearch" type="search" placeholder="搜索 provider 名称 / baseUrl / key id / 错误原因…" aria-label="筛选 Key"><select id="keyStatus" aria-label="状态筛选"><option value="all">全部状态</option><option value="available">仅可用</option><option value="unavailable">仅不可用</option></select><span class="match-info" id="keyMatchInfo"></span></div>
|
|
117
|
+
<div class="card-head"><div><h2 class="card-title" data-i18n="cardProviders">Provider / Key 管理</h2><p class="card-sub" data-i18n="cardProvidersSub">维护服务商、密钥池与轮询权重;下方筛选均在本地完成,不会请求服务端。</p></div></div>
|
|
118
|
+
<form id="providerForm" class="toolbar" autocomplete="off"><input name="id" placeholder="provider id" data-i18n-ph="phProviderId" required><input name="name" placeholder="名称" data-i18n-ph="phName"><input name="baseUrl" placeholder="https://api.example.com" data-i18n-ph="phBaseUrl" required><select name="protocol"><option>openai</option><option>anthropic</option></select><button class="btn btn-primary" data-i18n="btnAddProvider">添加 Provider</button></form>
|
|
119
|
+
<form id="keyForm" class="toolbar" autocomplete="off"><select name="provider" required></select><input name="id" placeholder="key id" data-i18n-ph="phKeyId" required><input name="weight" type="number" min="1" step="1" value="1" data-i18n-title="weightTitle"><input name="value" type="password" placeholder="API key 明文(加密存入密钥库)" data-i18n-ph="phKeyValue" required autocomplete="new-password"><button class="btn btn-primary" data-i18n="btnAddKey">添加 Key</button></form>
|
|
120
|
+
<div class="filter-bar"><input id="keySearch" type="search" placeholder="搜索 provider 名称 / baseUrl / key id / 错误原因…" data-i18n-ph="phSearchKey" data-i18n-aria="keyFilterAria" aria-label="筛选 Key"><select id="keyStatus" data-i18n-aria="statusFilterAria" aria-label="状态筛选"><option value="all" data-i18n="allStatus">全部状态</option><option value="available" data-i18n="onlyAvail">仅可用</option><option value="unavailable" data-i18n="onlyUnavail">仅不可用</option></select><span class="match-info" id="keyMatchInfo"></span></div>
|
|
124
121
|
<div id="providers"></div>
|
|
125
122
|
</section>
|
|
126
123
|
<section class="card">
|
|
127
|
-
<div class="card-head"><div><h2 class="card-title">Routes</h2><p class="card-sub">模型到 Provider 的转发映射规则。</p></div></div>
|
|
128
|
-
<form id="routeForm" class="toolbar" autocomplete="off"><
|
|
129
|
-
<div class="table-wrap"><table><thead><tr><th>Model</th><th>Provider</th><th></th></tr></thead><tbody id="routes"></tbody></table></div>
|
|
124
|
+
<div class="card-head"><div><h2 class="card-title" data-i18n="cardRoutes">Routes</h2><p class="card-sub" data-i18n="cardRoutesSub">模型到 Provider 的转发映射规则。</p></div></div>
|
|
125
|
+
<form id="routeForm" class="toolbar" autocomplete="off"><select name="provider" required></select><select name="models" multiple size="5" required aria-label="models"></select><button type="button" id="routeModels" class="btn btn-secondary" data-i18n="btnLoadModels">获取模型</button><button class="btn btn-primary" data-i18n="btnAddRoute">批量添加 Route</button></form>
|
|
126
|
+
<div class="table-wrap"><table><thead><tr><th data-i18n="thModel">Model</th><th data-i18n="thProvider">Provider</th><th data-i18n="thStatus">Status</th><th></th></tr></thead><tbody id="routes"></tbody></table></div>
|
|
130
127
|
</section>
|
|
131
128
|
<section class="card">
|
|
132
|
-
<div class="card-head"><div><h2 class="card-title">请求成功 / 失败趋势</h2><p class="card-sub">将最近拉取的日志按时间分桶聚合(最多 30 桶),悬停柱体可查看详情。</p></div><span class="legend"><span><i class="swatch ok"></i>成功</span><span><i class="swatch bad"></i>失败</span></span></div>
|
|
133
|
-
<div class="chart-box"><svg id="traffic" role="img" aria-label="request success/failure trend"></svg><div id="chartEmpty" class="chart-empty" hidden>暂无请求记录</div></div>
|
|
129
|
+
<div class="card-head"><div><h2 class="card-title" data-i18n="cardTraffic">请求成功 / 失败趋势</h2><p class="card-sub" data-i18n="cardTrafficSub">将最近拉取的日志按时间分桶聚合(最多 30 桶),悬停柱体可查看详情。</p></div><span class="legend"><span><i class="swatch ok"></i><span data-i18n="legendOk">成功</span></span><span><i class="swatch bad"></i><span data-i18n="legendFail">失败</span></span></span></div>
|
|
130
|
+
<div class="chart-box"><svg id="traffic" role="img" data-i18n-aria="trafficAria" aria-label="request success/failure trend"></svg><div id="chartEmpty" class="chart-empty" hidden data-i18n="chartEmpty">暂无请求记录</div></div>
|
|
134
131
|
</section>
|
|
135
132
|
<section class="card">
|
|
136
|
-
<div class="card-head"><div><h2 class="card-title">Logs</h2><p class="card-sub">最近的转发日志;关键字与结果类型为本地即时筛选,修改“条数”才会重新拉取。</p></div></div>
|
|
137
|
-
<div class="filter-bar"><input id="logFilter" type="search" placeholder="按 model / key / provider / status 筛选…"><select id="logStatus" aria-label="结果筛选"><option value="all">全部结果</option><option value="ok">仅成功</option><option value="fail">仅失败</option></select><select id="logTail" aria-label="日志条数"><option value="100" selected>最近 100 条</option><option value="300">最近 300 条</option><option value="1000">最近 1000 条</option></select><span class="match-info" id="logMatchInfo"></span></div>
|
|
138
|
-
<div class="table-wrap"><table><thead><tr><th>Time</th><th>Status</th><th>Provider</th><th>Key</th><th>Model</th><th class="num">Duration</th><th class="num">Tokens in/out</th></tr></thead><tbody id="logs"></tbody></table></div>
|
|
133
|
+
<div class="card-head"><div><h2 class="card-title" data-i18n="cardLogs">Logs</h2><p class="card-sub" data-i18n="cardLogsSub">最近的转发日志;关键字与结果类型为本地即时筛选,修改“条数”才会重新拉取。</p></div></div>
|
|
134
|
+
<div class="filter-bar"><input id="logFilter" type="search" placeholder="按 model / key / provider / status 筛选…" data-i18n-ph="phFilterLog"><select id="logStatus" data-i18n-aria="resultFilterAria" aria-label="结果筛选"><option value="all" data-i18n="allResult">全部结果</option><option value="ok" data-i18n="onlyOk">仅成功</option><option value="fail" data-i18n="onlyFail">仅失败</option></select><select id="logTail" data-i18n-aria="logCountAria" aria-label="日志条数"><option value="100" selected data-i18n="logTail100">最近 100 条</option><option value="300" data-i18n="logTail300">最近 300 条</option><option value="1000" data-i18n="logTail1000">最近 1000 条</option></select><span class="match-info" id="logMatchInfo"></span></div>
|
|
135
|
+
<div class="table-wrap"><table><thead><tr><th data-i18n="thTime">Time</th><th data-i18n="thStatus">Status</th><th data-i18n="thProvider">Provider</th><th data-i18n="thKey">Key</th><th data-i18n="thModel">Model</th><th class="num" data-i18n="thDuration">Duration</th><th class="num" data-i18n="thTokensInOut">Tokens in/out</th></tr></thead><tbody id="logs"></tbody></table></div>
|
|
139
136
|
</section>
|
|
140
137
|
</main>
|
|
141
|
-
<dialog id="keyEdit"><div class="dialog-body"><h3 class="dialog-title">编辑 Key</h3><form id="keyEditForm" autocomplete="off">
|
|
142
|
-
<label class="field">Provider<input name="provider" readonly></label>
|
|
143
|
-
<label class="field">Key id<input name="id" readonly></label>
|
|
144
|
-
<div class="field-row"><label class="field">权重<input name="weight" type="number" min="1" step="1" value="1" title="轮询权重:该 key 在 Provider 内多个 key 间被选中的相对占比"></label><label class="field">新 API key(留空保持不变)<input name="value" type="password" autocomplete="new-password" placeholder="sk-…"></label></div>
|
|
145
|
-
<footer class="dialog-actions"><button type="button" id="keyEditCancel" class="btn btn-secondary">取消</button><button class="btn btn-primary">保存</button></footer>
|
|
146
|
-
</form></div></dialog>
|
|
147
138
|
<script type="module">
|
|
148
139
|
const $=s=>document.querySelector(s), error=$('#error');
|
|
149
140
|
const NS='http://www.w3.org/2000/svg';
|
|
150
141
|
const pad2=n=>String(n).padStart(2,'0');
|
|
151
142
|
const state={status:null,logs:[],logTail:'100',keyKeyword:'',keyStatus:'all',logKeyword:'',logStatus:'all'};
|
|
152
|
-
|
|
153
|
-
|
|
143
|
+
const I18N={
|
|
144
|
+
zh:{title:'khat 密钥管理',toggleTheme:'切换深色 / 浅色模式',toggleLang:'切换语言',keyFilterAria:'筛选 Key',statusFilterAria:'状态筛选',resultFilterAria:'结果筛选',logCountAria:'日志条数',autoRefresh:'每 30 秒自动刷新',refresh:'刷新',statProviders:'Provider 数量',statProvidersSub:'已配置的服务商',statKeys:'Key 可用率',statReq:'近期请求成功率',statReqSub:'按最近一次拉取的日志统计',statTokens:'近期 Tokens 用量',statTokensSub:'输入 / 输出 合计',cardProviders:'Provider / Key 管理',cardProvidersSub:'维护服务商、密钥池与轮询权重;下方筛选均在本地完成,不会请求服务端。',phProviderId:'provider id',phName:'名称',phBaseUrl:'https://api.example.com',btnAddProvider:'添加 Provider',phKeyId:'key id',weightTitle:'轮询权重:该 key 在 Provider 内多个 key 间被选中的相对占比,默认 1',phKeyValue:'API key 明文(加密存入密钥库)',btnAddKey:'添加 Key',phSearchKey:'搜索 provider 名称 / baseUrl / key id / 错误原因…',allStatus:'全部状态',onlyAvail:'仅可用',onlyUnavail:'仅不可用',cardRoutes:'Routes',cardRoutesSub:'模型到 Provider 的转发映射规则。',phModel:'model',btnAddRoute:'批量添加 Route',btnLoadModels:'获取模型',thModel:'Model',thProvider:'Provider',cardTraffic:'请求成功 / 失败趋势',cardTrafficSub:'将最近拉取的日志按时间分桶聚合(最多 30 桶),悬停柱体可查看详情。',legendOk:'成功',legendFail:'失败',chartEmpty:'暂无请求记录',cardLogs:'Logs',cardLogsSub:'最近的转发日志;关键字与结果类型为本地即时筛选,修改“条数”才会重新拉取。',phFilterLog:'按 model / key / provider / status 筛选…',allResult:'全部结果',onlyOk:'仅成功',onlyFail:'仅失败',logTail100:'最近 100 条',logTail300:'最近 300 条',logTail1000:'最近 1000 条',thTime:'Time',thStatus:'Status',thDuration:'Duration',thTokensInOut:'Tokens in/out',thKey:'Key',thWeight:'Weight',thRequests:'Requests',thFailed:'Failed',thErrReason:'错误原因',thActions:'操作',avail:'可用',unavail:'不可用',disabled:'已停用',noKeysMatch:'没有符合筛选条件的 key',noKeysYet:'该 Provider 尚未添加 key,可在上方表单中添加',keyMatchInfo:'匹配 {k} / {t} 个 key · 显示 {p} 个 Provider',noProviderMatch:'没有匹配的 Provider 或 Key,试试调整关键字或状态筛选。',noRoutes:'暂无路由配置',noLogs:'暂无请求日志',noLogMatch:'没有匹配的日志,试试调整筛选条件。',poolWarn:'Provider {p} 没有可用的 Key,请恢复或探活后再重试。',btnDisable:'停用',btnRecover:'恢复',btnProbe:'探活',btnDelete:'删除',confirmDelKey:'删除 key {p}/{k}?',saved:'已保存',errOpenViaUi:'请通过 khat ui 打开此页面',errTicket:'票据无效或已过期',err401:'401 未授权(key 无效或被撤销)',err402:'402 余额不足',err429:'429 限速',errHttp:'HTTP {h} 错误',errOccurredAt:'发生于',trafficTip:'{t}:成功 {ok} 次 · 失败 {bad} 次',showLogs:'显示 {n} / {t} 条',statKeysSubTpl:'可用 {a} 个 · 共 {t} 个',statReqSubTpl:'成功 {ok} 次 · 失败 {fail} 次',keyCountTpl:'{n} 个 key',trafficAria:'请求成功 / 失败趋势'},
|
|
145
|
+
en:{title:'khat Key Manager',toggleTheme:'Toggle dark / light theme',toggleLang:'Switch language',keyFilterAria:'Filter keys',statusFilterAria:'Filter by status',resultFilterAria:'Filter by result',logCountAria:'Log count',autoRefresh:'Auto-refresh every 30s',refresh:'Refresh',statProviders:'Providers',statProvidersSub:'Configured services',statKeys:'Key availability',statReq:'Recent success rate',statReqSub:'From last fetched logs',statTokens:'Recent tokens',statTokensSub:'Input / Output total',cardProviders:'Provider / Key Management',cardProvidersSub:'Manage providers, key pools and weights; filtering is local only.',phProviderId:'provider id',phName:'Name',phBaseUrl:'https://api.example.com',btnAddProvider:'Add Provider',phKeyId:'key id',weightTitle:'Polling weight: relative share among keys in this provider, default 1',phKeyValue:'API key plaintext (stored encrypted)',btnAddKey:'Add Key',phSearchKey:'Search provider / baseUrl / key id / error…',allStatus:'All status',onlyAvail:'Available only',onlyUnavail:'Unavailable only',cardRoutes:'Routes',cardRoutesSub:'Model-to-Provider forwarding rules.',phModel:'model',btnAddRoute:'Batch add Routes',btnLoadModels:'Load models',thModel:'Model',thProvider:'Provider',cardTraffic:'Request success / failure trend',cardTrafficSub:'Aggregates recent logs into time buckets (max 30). Hover bars for detail.',legendOk:'Success',legendFail:'Failure',chartEmpty:'No request records',cardLogs:'Logs',cardLogsSub:'Recent forwarding logs; keyword & result filtering is local; changing count re-fetches.',phFilterLog:'Filter by model / key / provider / status…',allResult:'All results',onlyOk:'Success only',onlyFail:'Failure only',logTail100:'Last 100',logTail300:'Last 300',logTail1000:'Last 1000',thTime:'Time',thStatus:'Status',thDuration:'Duration',thTokensInOut:'Tokens in/out',thKey:'Key',thWeight:'Weight',thRequests:'Requests',thFailed:'Failed',thErrReason:'Error reason',thActions:'Actions',avail:'Available',unavail:'Unavailable',disabled:'Disabled',noKeysMatch:'No keys match the filters',noKeysYet:'No keys yet for this provider; add via the form above',keyMatchInfo:'{k} / {t} keys matched · {p} providers shown',noProviderMatch:'No matching provider or key; adjust filters.',noRoutes:'No routes configured',noLogs:'No request logs',noLogMatch:'No matching logs; adjust filters.',poolWarn:'Provider {p} has no available keys. Recover or probe a key before retrying.',btnDisable:'Disable',btnRecover:'Enable',btnProbe:'Probe',btnDelete:'Delete',confirmDelKey:'Delete key {p}/{k}?',saved:'Saved',errOpenViaUi:'Please open this page via khat ui',errTicket:'Ticket invalid or expired',err401:'401 Unauthorized (key invalid or revoked)',err402:'402 Insufficient balance',err429:'429 Rate limited',errHttp:'HTTP {h} error',errOccurredAt:'at',trafficTip:'{t}: {ok} ok · {bad} failed',showLogs:'Showing {n} / {t}',statKeysSubTpl:'{a} available · {t} total',statReqSubTpl:'{ok} ok · {fail} failed',keyCountTpl:'{n} keys',trafficAria:'request success/failure trend'}
|
|
146
|
+
};
|
|
147
|
+
let lang=(()=>{try{return localStorage.getItem('khatLang')==='en'?'en':'zh'}catch(e){return 'zh'}})();
|
|
148
|
+
function t(key){return (I18N[lang]&&I18N[lang][key])||I18N.zh[key]||key}
|
|
149
|
+
function tf(key,vars){let s=t(key);if(vars)for(const k in vars)s=s.split('{'+k+'}').join(String(vars[k]));return s}
|
|
150
|
+
function applyLang(l){lang=l;try{localStorage.setItem('khatLang',l)}catch(e){}document.documentElement.lang=l==='en'?'en':'zh-CN';document.querySelectorAll('[data-i18n]').forEach(el=>{el.textContent=t(el.dataset.i18n)});document.querySelectorAll('[data-i18n-ph]').forEach(el=>{el.placeholder=t(el.dataset.i18nPh)});document.querySelectorAll('[data-i18n-title]').forEach(el=>{el.title=t(el.dataset.i18nTitle)});document.querySelectorAll('[data-i18n-aria]').forEach(el=>{el.setAttribute('aria-label',t(el.dataset.i18nAria))});renderConnection();renderStats();renderProviders();renderRoutes();renderLogs();drawTraffic();const b=$('#langToggle');if(b)b.textContent=l==='zh'?'EN':'中'}
|
|
151
|
+
function toast(msg,type,ms){type=type||'error';ms=ms||3500;const c=$('#toasts');if(!c)return;const el=document.createElement('div');el.className='toast toast-'+type;el.textContent=msg;c.append(el);setTimeout(()=>{el.classList.add('fade');setTimeout(()=>el.remove(),300)},ms)}
|
|
152
|
+
async function api(path,init){init=init||{};const r=await fetch(path,{...init,headers:{authorization:'Bearer '+sessionStorage.khatSession,'content-type':'application/json',...(init.headers||{})}});const body=await r.json().catch(()=>({}));if(!r.ok)throw Error(body.error?.message||r.statusText);return body}
|
|
153
|
+
async function exchange(){const ticket=new URLSearchParams(location.search).get('ticket');if(sessionStorage.khatSession){if(ticket)window.history.replaceState({},'', '/_keys/ui');return}if(ticket){const r=await fetch('/_keys/ticket/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ticket})});if(!r.ok)throw Error(t('errTicket'));sessionStorage.khatSession=(await r.json()).session;window.history.replaceState({},'', '/_keys/ui')}if(!sessionStorage.khatSession)throw Error(t('errOpenViaUi'))}
|
|
154
154
|
function cell(v,cls){const e=document.createElement('td');if(cls)e.className=cls;e.textContent=v==null?'—':String(v);return e}
|
|
155
155
|
function fmtNum(n){return n>=100000?Math.round(n/1000)+'k':n>=10000?(n/1000).toFixed(1)+'k':String(n)}
|
|
156
156
|
function fmtHM(m){const d=new Date(m*60000);return pad2(d.getHours())+':'+pad2(d.getMinutes())}
|
|
157
157
|
function formatTs(ts){const d=new Date(ts);if(isNaN(d.getTime()))return String(ts);const day=d.toDateString()===new Date().toDateString()?'':(d.getMonth()+1)+'-'+d.getDate()+' ';return day+pad2(d.getHours())+':'+pad2(d.getMinutes())+':'+pad2(d.getSeconds())}
|
|
158
|
-
function errText(k){const http=k.lastError&&k.lastError.http;if(http===undefined)return '—';return (
|
|
158
|
+
function errText(k){const http=k.lastError&&k.lastError.http;if(http===undefined)return '—';if(http===401)return t('err401');if(http===402)return t('err402');if(http===429)return t('err429');return tf('errHttp',{h:http})}
|
|
159
159
|
function btn(label,className,onclick){const b=document.createElement('button');b.type='button';b.className=className;b.textContent=label;b.onclick=onclick;return b}
|
|
160
|
-
function run(fn){return async arg=>{try{await fn(arg)}catch(e){error.textContent=e.message}}}
|
|
160
|
+
function run(fn){return async arg=>{try{await fn(arg)}catch(e){error.textContent=e.message;toast(e.message)}}}
|
|
161
161
|
function statusCell(ok,text,title){const td=document.createElement('td');const sp=document.createElement('span');sp.className='pill pill-status '+(ok?'pill-ok':'pill-bad');sp.textContent=text;if(title)sp.title=title;td.append(sp);return td}
|
|
162
162
|
function syncProviderSelect(sel,providers){const ids=providers.map(p=>p.id).join(',');if(sel.dataset.ids===ids)return;sel.dataset.ids=ids;const prev=sel.value;sel.replaceChildren(...providers.map(p=>{const o=document.createElement('option');o.value=p.id;o.textContent=p.id;return o}));if(ids.split(',').includes(prev))sel.value=prev}
|
|
163
|
-
function
|
|
164
|
-
function
|
|
165
|
-
function
|
|
166
|
-
function providerHead(p,keys){const head=document.createElement('div');head.className='provider-head';const nm=document.createElement('h3');nm.className='provider-name';nm.textContent=p.name||p.id;nm.title=p.id;head.append(nm);const pp=document.createElement('span');pp.className='pill protocol-pill';pp.textContent=p.protocol;head.append(pp);const url=document.createElement('span');url.className='provider-url';url.textContent=p.baseUrl||'';url.title=p.baseUrl||'';head.append(url);const cnt=document.createElement('span');cnt.className='provider-count';cnt.textContent=keys.length+(keys.length!==p.keys.length?' / '+p.keys.length:'')+' 个 key';head.append(cnt);return head}
|
|
163
|
+
function renderConnection(){if(!state.status)return;const c=$('#connection');c.replaceChildren(Object.assign(document.createElement('i'),{className:'dot'}),document.createTextNode(' '+state.status.listen))}
|
|
164
|
+
function renderStats(){const st=state.status;if(!st)return;const keys=[];for(const p of st.providers)for(const k of p.keys)keys.push(k);const avail=keys.filter(k=>k.enabled!==false&&k.status==='available').length;$('#statProviders').textContent=String(st.providers.length);$('#statKeys').textContent=keys.length?Math.round(avail*100/keys.length)+'%':'—';$('#statKeysSub').textContent=keys.length?tf('statKeysSubTpl',{a:avail,t:keys.length}):'—';let ok=0,fail=0,tIn=0,tOut=0;for(const x of state.logs){if(Number(x.status)<400)ok++;else fail++;tIn+=x.tokensIn||0;tOut+=x.tokensOut||0}const total=ok+fail;$('#statReq').textContent=total?Math.round(ok*100/total)+'%':'—';$('#statReqSub').textContent=tf('statReqSubTpl',{ok:ok,fail:fail});$('#statTokens').textContent=total?fmtNum(tIn)+' / '+fmtNum(tOut):'—'}
|
|
165
|
+
function providerHead(p,keys){const head=document.createElement('div');head.className='provider-head';const nm=document.createElement('h3');nm.className='provider-name';nm.textContent=p.name||p.id;nm.title=p.id;head.append(nm);const pp=document.createElement('span');pp.className='pill protocol-pill';pp.textContent=p.protocol;head.append(pp);const url=document.createElement('span');url.className='provider-url';url.textContent=p.baseUrl||'';url.title=p.baseUrl||'';head.append(url);const cnt=document.createElement('span');cnt.className='provider-count';cnt.textContent=tf('keyCountTpl',{n:keys.length+(keys.length!==p.keys.length?(' / '+p.keys.length):'')});head.append(cnt);return head}
|
|
167
166
|
function renderProviders(){
|
|
168
167
|
const box=$('#providers');box.replaceChildren();
|
|
169
168
|
const st=state.status;if(!st)return;
|
|
@@ -177,11 +176,13 @@ function renderProviders(){
|
|
|
177
176
|
if(p.keys.length&&!keys.length)continue;
|
|
178
177
|
if(!p.keys.length&&!hitMeta)continue;
|
|
179
178
|
shownP++;shownK+=keys.length;
|
|
179
|
+
const hasAvailableKey=p.keys.some(k=>k.enabled!==false&&k.status==='available');
|
|
180
180
|
const blk=document.createElement('div');blk.className='provider-block';
|
|
181
181
|
blk.append(providerHead(p,keys));
|
|
182
|
+
if(p.keys.length&&!hasAvailableKey){const warn=document.createElement('p');warn.className='alert alert-warn';warn.textContent=tf('poolWarn',{p:p.name||p.id});blk.append(warn)}
|
|
182
183
|
const tw=document.createElement('div');tw.className='table-wrap';
|
|
183
184
|
const table=document.createElement('table');
|
|
184
|
-
table.innerHTML='<thead><tr><th>
|
|
185
|
+
table.innerHTML='<thead><tr><th>'+t('thKey')+'</th><th>'+t('thWeight')+'</th><th>'+t('thStatus')+'</th><th class="num">'+t('thRequests')+'</th><th class="num">'+t('thFailed')+'</th><th class="num">'+t('thTokensInOut')+'</th><th>'+t('thErrReason')+'</th><th>'+t('thActions')+'</th></tr></thead>';
|
|
185
186
|
const tb=document.createElement('tbody');
|
|
186
187
|
for(const k of keys){
|
|
187
188
|
const tr=document.createElement('tr');
|
|
@@ -190,40 +191,39 @@ function renderProviders(){
|
|
|
190
191
|
if(k.secret){const sk=document.createElement('span');sk.className='key-secret';sk.textContent=k.secret;kt.append(sk)}
|
|
191
192
|
tr.append(kt);
|
|
192
193
|
tr.append(cell(k.weight,'num'));
|
|
193
|
-
tr.append(statusCell(k.status==='available',k.status==='available'?'
|
|
194
|
+
tr.append(statusCell(k.enabled!==false&&k.status==='available',k.enabled===false?t('disabled'):(k.status==='available'?t('avail'):t('unavail')),k.lastError?errText(k)+(k.lastError.at?' · '+t('errOccurredAt')+' '+k.lastError.at:''):''));
|
|
194
195
|
tr.append(cell(k.counters.requests||0,'num'),cell(k.counters.failed||0,'num'),cell((k.counters.tokensIn||0)+' / '+(k.counters.tokensOut||0),'num'));
|
|
195
|
-
const et=document.createElement('td');et.textContent=errText(k);et.className=k.lastError?'err-text':'err-text none';if(k.lastError)et.title='
|
|
196
|
+
const et=document.createElement('td');et.textContent=errText(k);et.className=k.lastError?'err-text':'err-text none';if(k.lastError)et.title=t('errOccurredAt')+' '+k.lastError.at;
|
|
196
197
|
tr.append(et);
|
|
197
198
|
const ac=document.createElement('td');
|
|
198
199
|
const rowA=document.createElement('div');rowA.className='row-actions';
|
|
199
|
-
const wInput=document.createElement('input');wInput.type='number';wInput.min='1';wInput.step='1';wInput.value=k.weight;wInput.title='
|
|
200
|
-
wInput.onchange=run(async()=>{await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id),{method:'PUT',body:JSON.stringify({weight:Number(wInput.value)})});load()});
|
|
200
|
+
const wInput=document.createElement('input');wInput.type='number';wInput.min='1';wInput.step='1';wInput.value=k.weight;wInput.title=t('weightTitle');
|
|
201
|
+
wInput.onchange=run(async()=>{await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id),{method:'PUT',body:JSON.stringify({weight:Number(wInput.value)})});toast(t('saved'),'ok');load()});
|
|
201
202
|
rowA.append(wInput);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
rowA.append(btn('编辑','btn btn-secondary btn-sm',()=>openKeyEdit(p.id,k.id,k.weight)));
|
|
208
|
-
rowA.append(btn('删除','btn btn-danger btn-sm',run(async()=>{if(confirm('删除 key '+p.id+'/'+k.id+'?')){await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id),{method:'DELETE'});load()}})));
|
|
203
|
+
rowA.append(btn(k.enabled!==false?t('btnDisable'):t('btnRecover'),'btn btn-secondary btn-sm',run(async()=>{await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id)+'/'+(k.enabled!==false?'disable':'enable'),{method:'POST'});toast(t('saved'),'ok');load()})));
|
|
204
|
+
rowA.append(btn(t('btnProbe'),'btn btn-secondary btn-sm',run(async()=>{await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id)+'/probe',{method:'POST'});toast(t('saved'),'ok');load()})));
|
|
205
|
+
|
|
206
|
+
rowA.append(btn(t('btnDelete'),'btn btn-danger btn-sm',run(async()=>{if(confirm(tf('confirmDelKey',{p:p.id,k:k.id}))){await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id),{method:'DELETE'});toast(t('saved'),'ok');load()}})));
|
|
209
207
|
ac.append(rowA);tr.append(ac);
|
|
210
208
|
tb.append(tr);
|
|
211
209
|
}
|
|
212
|
-
if(!keys.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=8;ed.textContent=p.keys.length?'
|
|
210
|
+
if(!keys.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=8;ed.textContent=p.keys.length?t('noKeysMatch'):t('noKeysYet');er.append(ed);tb.append(er)}
|
|
213
211
|
table.append(tb);tw.append(table);blk.append(tw);box.append(blk);
|
|
214
212
|
}
|
|
215
|
-
$('#keyMatchInfo').textContent='
|
|
216
|
-
if(shownP===0){const em=document.createElement('div');em.className='empty-state';em.textContent='
|
|
213
|
+
$('#keyMatchInfo').textContent=tf('keyMatchInfo',{k:shownK,t:totalK,p:shownP});
|
|
214
|
+
if(shownP===0){const em=document.createElement('div');em.className='empty-state';em.textContent=t('noProviderMatch');box.append(em)}
|
|
217
215
|
}
|
|
218
216
|
function renderRoutes(){
|
|
219
217
|
const tb=$('#routes');tb.replaceChildren();
|
|
220
218
|
const rs=state.status?state.status.routes:[];
|
|
221
|
-
if(!rs.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=
|
|
219
|
+
if(!rs.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=4;ed.textContent=t('noRoutes');er.append(ed);tb.append(er);return}
|
|
222
220
|
for(const rt of rs){
|
|
223
221
|
const tr=document.createElement('tr');
|
|
224
|
-
tr.append(cell(rt.model,'mono-cell'),cell(rt.provider));
|
|
222
|
+
tr.append(cell(rt.model,'mono-cell'),cell(rt.provider),statusCell(rt.enabled!==false,rt.enabled!==false?t('avail'):t('unavail')));
|
|
225
223
|
const d=document.createElement('td');
|
|
226
|
-
|
|
224
|
+
const toggle=rt.enabled!==false?'disable':'enable';
|
|
225
|
+
d.append(btn(rt.enabled!==false?t('btnDisable'):t('btnRecover'),'btn btn-secondary btn-sm',run(async()=>{await api('/_keys/routes/'+toggle+'?model='+encodeURIComponent(rt.model),{method:'POST'});toast(t('saved'),'ok');load()})));
|
|
226
|
+
d.append(btn(t('btnDelete'),'btn btn-danger btn-sm',run(async()=>{await api('/_keys/routes?model='+encodeURIComponent(rt.model),{method:'DELETE'});toast(t('saved'),'ok');load()})));
|
|
227
227
|
tr.append(d);tb.append(tr);
|
|
228
228
|
}
|
|
229
229
|
}
|
|
@@ -236,8 +236,8 @@ function renderLogs(){
|
|
|
236
236
|
if(!kw)return true;
|
|
237
237
|
return [x.model,x.key,x.provider,x.status].some(v=>String(v).toLowerCase().includes(kw));
|
|
238
238
|
});
|
|
239
|
-
$('#logMatchInfo').textContent='
|
|
240
|
-
if(!rows.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=7;ed.textContent=state.logs.length?'
|
|
239
|
+
$('#logMatchInfo').textContent=tf('showLogs',{n:rows.length,t:state.logs.length});
|
|
240
|
+
if(!rows.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=7;ed.textContent=state.logs.length?t('noLogMatch'):t('noLogs');er.append(ed);tb.append(er);return}
|
|
241
241
|
for(const e of rows){
|
|
242
242
|
const r=document.createElement('tr');
|
|
243
243
|
r.append(cell(formatTs(e.ts),'mono-cell'));
|
|
@@ -253,7 +253,7 @@ function drawTraffic(){
|
|
|
253
253
|
const svg=$('#traffic');svg.replaceChildren();
|
|
254
254
|
const emptyBox=$('#chartEmpty');
|
|
255
255
|
const times=[];
|
|
256
|
-
for(const x of state.logs){const
|
|
256
|
+
for(const x of state.logs){const t0=new Date(x.ts).getTime();if(Number.isFinite(t0))times.push({m:Math.floor(t0/60000),ok:Number(x.status)<400})}
|
|
257
257
|
if(!times.length){emptyBox.hidden=false;return}
|
|
258
258
|
emptyBox.hidden=true;
|
|
259
259
|
let minM=Infinity,maxM=-Infinity;
|
|
@@ -272,12 +272,22 @@ function drawTraffic(){
|
|
|
272
272
|
if(!b)continue;
|
|
273
273
|
const total=b.ok+b.bad,h=total/peak*(axisY-topPad),badH=b.bad/total*h,rx=Math.min(3,bw/3);
|
|
274
274
|
const g=document.createElementNS(NS,'g');
|
|
275
|
-
const ti=document.createElementNS(NS,'title');ti.textContent=fmtHM(startM)
|
|
275
|
+
const ti=document.createElementNS(NS,'title');ti.textContent=tf('trafficTip',{t:fmtHM(startM),ok:b.ok,bad:b.bad});g.append(ti);
|
|
276
276
|
if(b.ok)g.append(mk('rect',{x:(cx-bw/2).toFixed(1),y:(axisY-h+badH).toFixed(1),width:bw.toFixed(1),height:(h-badH).toFixed(1),rx:rx,fill:'#10b981'}));
|
|
277
277
|
if(b.bad)g.append(mk('rect',{x:(cx-bw/2).toFixed(1),y:(axisY-h).toFixed(1),width:bw.toFixed(1),height:badH.toFixed(1),rx:rx,fill:'#ef4444'}));
|
|
278
278
|
svg.append(g);
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
|
+
async function loadModels(){
|
|
282
|
+
const provider=$('#routeForm [name=provider]').value;
|
|
283
|
+
const select=$('#routeForm [name=models]');
|
|
284
|
+
select.replaceChildren();
|
|
285
|
+
if(!provider)return;
|
|
286
|
+
try{
|
|
287
|
+
const r=await api('/_keys/providers/'+encodeURIComponent(provider)+'/models');
|
|
288
|
+
for(const model of r.models||[]){const o=document.createElement('option');o.value=model;o.textContent=model;select.append(o)}
|
|
289
|
+
}catch(e){error.textContent=e.message;toast(e.message)}
|
|
290
|
+
}
|
|
281
291
|
async function load(){
|
|
282
292
|
try{
|
|
283
293
|
await exchange();
|
|
@@ -292,16 +302,17 @@ async function load(){
|
|
|
292
302
|
renderRoutes();
|
|
293
303
|
renderLogs();
|
|
294
304
|
drawTraffic();
|
|
295
|
-
}catch(e){error.textContent=e.message}
|
|
305
|
+
}catch(e){error.textContent=e.message;toast(e.message)}
|
|
296
306
|
}
|
|
297
|
-
function applyTheme(
|
|
307
|
+
function applyTheme(theme){document.documentElement.setAttribute('data-theme',theme);try{localStorage.setItem('khatTheme',theme)}catch(e){}const b=$('#themeToggle');b.textContent=theme==='dark'?'☀️':'🌙';b.setAttribute('aria-label',t('toggleTheme'))}
|
|
298
308
|
$('#themeToggle').onclick=()=>applyTheme(document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark');
|
|
309
|
+
$('#langToggle').onclick=()=>applyLang(lang==='zh'?'en':'zh');
|
|
299
310
|
applyTheme(document.documentElement.getAttribute('data-theme')||'light');
|
|
300
|
-
|
|
301
|
-
$('#
|
|
302
|
-
$('#
|
|
303
|
-
$('#
|
|
304
|
-
$('#
|
|
311
|
+
applyLang(lang);
|
|
312
|
+
$('#routeModels').onclick=()=>loadModels();
|
|
313
|
+
$('#providerForm').onsubmit=run(async e=>{e.preventDefault();const x=Object.fromEntries(new FormData(e.target));await api('/_keys/providers',{method:'POST',body:JSON.stringify(x)});e.target.reset();toast(t('saved'),'ok');load()});
|
|
314
|
+
$('#keyForm').onsubmit=run(async e=>{e.preventDefault();const x=Object.fromEntries(new FormData(e.target));await api('/_keys/providers/'+encodeURIComponent(x.provider)+'/keys',{method:'POST',body:JSON.stringify({id:x.id,value:x.value,weight:Number(x.weight)||1})});e.target.reset();toast(t('saved'),'ok');load()});
|
|
315
|
+
$('#routeForm').onsubmit=run(async e=>{e.preventDefault();const provider=e.target.provider.value;const models=[...e.target.models.selectedOptions].map(o=>o.value);for(const model of models)await api('/_keys/routes',{method:'POST',body:JSON.stringify({model,provider})});e.target.reset();toast(t('saved'),'ok');load()});
|
|
305
316
|
$('#keySearch').oninput=e=>{state.keyKeyword=e.target.value;renderProviders()};
|
|
306
317
|
$('#keyStatus').onchange=e=>{state.keyStatus=e.target.value;renderProviders()};
|
|
307
318
|
$('#logFilter').oninput=e=>{state.logKeyword=e.target.value;renderLogs()};
|