File size: 13,257 Bytes
c74679b
 
 
 
 
 
 
 
 
 
 
 
 
 
9c1527e
 
 
c74679b
 
 
 
 
 
2f1c18e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c74679b
 
 
2f1c18e
c74679b
 
 
2f1c18e
 
c74679b
9c1527e
 
 
 
c74679b
 
2f1c18e
c74679b
 
2f1c18e
c74679b
2f1c18e
c74679b
 
2f1c18e
c74679b
2f1c18e
c74679b
2f1c18e
 
 
 
c74679b
 
 
2f1c18e
c74679b
2f1c18e
 
 
 
c74679b
 
 
 
 
2f1c18e
c74679b
 
2f1c18e
c74679b
 
 
 
 
 
 
 
 
 
 
2f1c18e
c74679b
 
 
 
 
2f1c18e
c74679b
 
 
 
 
 
2f1c18e
 
c74679b
 
 
 
 
 
 
 
 
 
 
2f1c18e
c74679b
 
 
 
2f1c18e
c74679b
2f1c18e
c74679b
 
 
2f1c18e
 
c74679b
 
 
 
 
 
 
 
 
2f1c18e
c74679b
2f1c18e
 
 
 
 
 
 
 
 
 
 
 
c74679b
 
2f1c18e
 
 
 
c74679b
 
2f1c18e
 
 
 
 
c74679b
 
 
 
 
 
 
 
 
 
 
2f1c18e
c74679b
 
 
 
 
 
9c1527e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c74679b
 
2f1c18e
c74679b
 
 
 
 
 
 
 
 
 
2f1c18e
c74679b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2f1c18e
c74679b
 
2f1c18e
 
 
 
c74679b
 
 
 
 
 
 
 
 
 
2f1c18e
c74679b
 
 
 
 
 
 
 
9c1527e
 
 
c74679b
 
 
 
 
 
 
 
 
 
2f1c18e
c74679b
 
 
 
 
 
 
 
 
 
 
2f1c18e
c74679b
 
 
2f1c18e
c74679b
 
2f1c18e
c74679b
 
 
2f1c18e
c74679b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2f1c18e
c74679b
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
const PANEL_ATTR = "data-panel";
const sidebarButtons = document.querySelectorAll(".sidebar-btn");
const panels = document.querySelectorAll(`.glass-panel[${PANEL_ATTR}]`);
const loginOverlay = document.getElementById("login-overlay");
const loginBtn = document.getElementById("login-btn");
const loginStatus = document.getElementById("login-status");
const overviewMetrics = document.getElementById("overview-metrics");
const recentChecks = document.getElementById("recent-checks");
const modelTable = document.getElementById("model-table");
const keyTable = document.getElementById("key-table");
const healthGrid = document.getElementById("health-grid");
const modelCount = document.getElementById("model-count");
const modelHealthy = document.getElementById("model-healthy");
const settingsStatus = document.getElementById("settings-status");
const testAllModelsBtn = document.getElementById("test-all-models");
const testAllKeysBtn = document.getElementById("test-all-keys");
const runHealthcheckBtn = document.getElementById("run-healthcheck");

const state = {
  token: sessionStorage.getItem("nim_token"),
  panel: "overview",
};

const STATUS_LABELS = {
  healthy: "正常",
  degraded: "波动",
  down: "异常",
  unknown: "未巡检",
};

const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
  year: "numeric",
  month: "2-digit",
  day: "2-digit",
  hour: "2-digit",
  minute: "2-digit",
});

function formatDateTime(value) {
  if (!value) return "--";
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) return "--";
  return dateTimeFormatter.format(date);
}

function showPanel(name) {
  panels.forEach((panel) => panel.classList.toggle("hidden", panel.getAttribute(PANEL_ATTR) !== name));
  sidebarButtons.forEach((button) => button.classList.toggle("active", button.dataset.panel === name));
  state.panel = name;
}

sidebarButtons.forEach((button) => button.addEventListener("click", () => showPanel(button.dataset.panel)));

async function apiRequest(endpoint, opts = {}) {
  const headers = { "Content-Type": "application/json", Accept: "application/json" };
  if (state.token) headers.Authorization = `Bearer ${state.token}`;
  const response = await fetch(`/admin/api/${endpoint}`, {
    ...opts,
    headers: { ...headers, ...(opts.headers || {}) },
  });
  if (!response.ok) {
    const payload = await response.json().catch(() => ({}));
    throw new Error(payload.message || payload.detail || payload.error?.message || "请求失败");
  }
  return response.json();
}

function metricCard(label, value, detail = "") {
  const div = document.createElement("div");
  div.className = "metric-card";
  div.innerHTML = `<h3>${label}</h3><strong>${value}</strong><p>${detail}</p>`;
  return div;
}

function pill(status) {
  const normalized = status || "unknown";
  return `<span class="pill ${normalized}">${STATUS_LABELS[normalized] || normalized}</span>`;
}

async function renderOverview() {
  const payload = await apiRequest("overview");
  const totals = payload.totals || {};
  overviewMetrics.innerHTML = "";
  overviewMetrics.appendChild(metricCard("启用模型", totals.enabled_models ?? "--", `总数 ${totals.total_models ?? "--"}`));
  overviewMetrics.appendChild(metricCard("启用 Key", totals.enabled_keys ?? "--", `总数 ${totals.total_keys ?? "--"}`));
  overviewMetrics.appendChild(metricCard("代理请求", totals.total_requests ?? "--", `成功 ${totals.total_success ?? 0}`));
  overviewMetrics.appendChild(metricCard("失败次数", totals.total_failures ?? "--", "累计转发失败或上游返回错误"));

  recentChecks.innerHTML = "";
  (payload.recent_checks || []).forEach((check) => {
    const row = document.createElement("tr");
    row.innerHTML = `
      <td>${formatDateTime(check.time)}</td>
      <td>${check.model}</td>
      <td>${pill(check.status)}</td>
      <td>${check.latency ? `${check.latency} ms` : "--"}</td>
    `;
    recentChecks.appendChild(row);
  });
}

async function renderModels() {
  const payload = await apiRequest("models");
  const items = payload.items || [];
  modelCount.textContent = items.length;
  modelHealthy.textContent = items.filter((item) => item.status === "healthy").length;
  modelTable.innerHTML = "";

  items.forEach((item) => {
    const row = document.createElement("tr");
    row.innerHTML = `
      <td>
        <strong>${item.display_name || item.model_id}</strong><br />
        <span class="status-text mono">${item.model_id}</span>
      </td>
      <td>${pill(item.status)}</td>
      <td>${item.request_count}</td>
      <td>${item.healthcheck_success_count}/${item.healthcheck_count}</td>
      <td>
        <div class="inline-actions">
          <button class="secondary-btn" data-action="test-model" data-id="${item.model_id}">测试</button>
          <button class="secondary-btn danger-btn" data-action="remove-model" data-id="${item.model_id}">删除</button>
        </div>
      </td>
    `;
    modelTable.appendChild(row);
  });
}

async function renderKeys() {
  const payload = await apiRequest("keys");
  const items = payload.items || [];
  keyTable.innerHTML = "";

  items.forEach((item) => {
    const row = document.createElement("tr");
    row.innerHTML = `
      <td>${item.label}</td>
      <td class="mono">${item.masked_key}</td>
      <td>${item.request_count}</td>
      <td>${formatDateTime(item.last_tested)}</td>
      <td>${pill(item.status)}</td>
      <td>
        <div class="inline-actions">
          <button class="secondary-btn" data-action="test-key" data-id="${item.name}">测试</button>
          <button class="secondary-btn danger-btn" data-action="remove-key" data-id="${item.name}">删除</button>
        </div>
      </td>
    `;
    keyTable.appendChild(row);
  });
}

async function renderHealth() {
  const payload = await apiRequest("healthchecks");
  const items = payload.items || [];
  healthGrid.innerHTML = "";

  if (items.length === 0) {
    const empty = document.createElement("div");
    empty.className = "empty-card";
    empty.textContent = "暂无巡检记录,执行一次巡检后这里会显示最新结果。";
    healthGrid.appendChild(empty);
    return;
  }

  items.slice(0, 12).forEach((item) => {
    const card = document.createElement("article");
    card.className = "health-record";
    card.innerHTML = `
      <div class="toolbar-row">
        <div>
          <h4>${item.model}</h4>
          <span class="status-text mono">${item.model_id}</span>
        </div>
        ${pill(item.status)}
      </div>
      <p class="status-text">${item.detail || "暂无详情"}</p>
      <div class="record-meta">
        <span>Key: ${item.api_key || "未记录"}</span>
        <span>时延: ${item.latency ? `${item.latency} ms` : "--"}</span>
        <span>时间: ${formatDateTime(item.checked_at)}</span>
      </div>
    `;
    healthGrid.appendChild(card);
  });
}

async function renderSettings() {
  const payload = await apiRequest("settings");
  document.getElementById("healthcheck-enabled").checked = Boolean(payload.healthcheck_enabled);
  document.getElementById("healthcheck-interval").value = payload.healthcheck_interval_minutes || 60;
  document.getElementById("public-history-hours").value = payload.public_history_hours || 48;
  document.getElementById("healthcheck-prompt").value = payload.healthcheck_prompt || "请只回复 OK。";
}

async function loadAll() {
  await Promise.all([renderOverview(), renderModels(), renderKeys(), renderHealth(), renderSettings()]);
}

async function runAllModelChecks() {
  const payload = await apiRequest("healthchecks/run", { method: "POST", body: JSON.stringify({}) });
  const items = payload.items || [];
  const success = items.filter((item) => item.status === "healthy").length;
  alert(`已完成全部模型巡检,共 ${items.length} 个模型,其中 ${success} 个正常。`);
  showPanel("health");
  await loadAll();
}

async function runAllKeyChecks() {
  const payload = await apiRequest("keys/test-all", { method: "POST", body: JSON.stringify({}) });
  const items = payload.items || [];
  const success = items.filter((item) => item.status === "healthy").length;
  alert(`已完成全部 Key 测试,共 ${items.length} 个 Key,其中 ${success} 个正常。`);
  showPanel("keys");
  await loadAll();
}

async function testModel(modelId) {
  const payload = await apiRequest(`models/${encodeURIComponent(modelId)}/test`, { method: "POST", body: JSON.stringify({}) });
  alert(`${payload.display_name || payload.model} 当前状态:${STATUS_LABELS[payload.status] || payload.status}`);
  await loadAll();
}

async function removeModel(modelId) {
  await apiRequest("models/remove", { method: "POST", body: JSON.stringify({ value: modelId }) });
  await loadAll();
}

async function testKey(keyName) {
  const payload = await apiRequest("keys/test", { method: "POST", body: JSON.stringify({ value: keyName }) });
  alert(`Key ${payload.api_key} 当前状态:${STATUS_LABELS[payload.status] || payload.status}`);
  await loadAll();
}

async function removeKey(keyName) {
  await apiRequest("keys/remove", { method: "POST", body: JSON.stringify({ value: keyName }) });
  await loadAll();
}

modelTable.addEventListener("click", (event) => {
  const button = event.target.closest("button[data-action]");
  if (!button) return;
  if (button.dataset.action === "test-model") testModel(button.dataset.id);
  if (button.dataset.action === "remove-model") removeModel(button.dataset.id);
});

keyTable.addEventListener("click", (event) => {
  const button = event.target.closest("button[data-action]");
  if (!button) return;
  if (button.dataset.action === "test-key") testKey(button.dataset.id);
  if (button.dataset.action === "remove-key") removeKey(button.dataset.id);
});

document.getElementById("model-add")?.addEventListener("click", async () => {
  const modelId = document.getElementById("model-id").value.trim();
  const displayName = document.getElementById("model-display-name").value.trim();
  const description = document.getElementById("model-description").value.trim();
  if (!modelId) {
    alert("请先填写模型 ID。");
    return;
  }
  await apiRequest("models", {
    method: "POST",
    body: JSON.stringify({ model_id: modelId, display_name: displayName || modelId, description }),
  });
  document.getElementById("model-id").value = "";
  document.getElementById("model-display-name").value = "";
  document.getElementById("model-description").value = "";
  await renderModels();
});

document.getElementById("key-add")?.addEventListener("click", async () => {
  const name = document.getElementById("key-label").value.trim();
  const apiKey = document.getElementById("key-value").value.trim();
  if (!name || !apiKey) {
    alert("请填写 Key 名称和内容。");
    return;
  }
  await apiRequest("keys", { method: "POST", body: JSON.stringify({ name, api_key: apiKey }) });
  document.getElementById("key-label").value = "";
  document.getElementById("key-value").value = "";
  await renderKeys();
});

testAllModelsBtn?.addEventListener("click", runAllModelChecks);
testAllKeysBtn?.addEventListener("click", runAllKeyChecks);
runHealthcheckBtn?.addEventListener("click", runAllModelChecks);

document.getElementById("settings-save")?.addEventListener("click", async () => {
  try {
    const payload = {
      healthcheck_enabled: document.getElementById("healthcheck-enabled").checked,
      healthcheck_interval_minutes: Number(document.getElementById("healthcheck-interval").value || 60),
      public_history_hours: Number(document.getElementById("public-history-hours").value || 48),
      healthcheck_prompt: document.getElementById("healthcheck-prompt").value.trim(),
    };
    await apiRequest("settings", { method: "PUT", body: JSON.stringify(payload) });
    settingsStatus.textContent = "设置已保存。";
    await loadAll();
  } catch (error) {
    settingsStatus.textContent = error.message;
  }
});

document.getElementById("refresh-now")?.addEventListener("click", loadAll);

loginBtn.addEventListener("click", async () => {
  const password = document.getElementById("admin-password").value.trim();
  if (!password) {
    loginStatus.textContent = "请输入后台密码。";
    return;
  }
  try {
    loginStatus.textContent = "正在验证身份...";
    const response = await fetch("/admin/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json", Accept: "application/json" },
      body: JSON.stringify({ password }),
    });
    const payload = await response.json().catch(() => ({}));
    if (!response.ok) throw new Error(payload.detail || payload.message || "登录失败");
    state.token = payload.access_token || payload.token;
    sessionStorage.setItem("nim_token", state.token);
    loginOverlay.classList.add("hidden");
    await loadAll();
  } catch (error) {
    loginStatus.textContent = error.message;
  }
});

window.addEventListener("DOMContentLoaded", async () => {
  showPanel(state.panel);
  if (!state.token) return;
  loginOverlay.classList.add("hidden");
  try {
    await loadAll();
    setInterval(loadAll, 90 * 1000);
  } catch (_error) {
    sessionStorage.removeItem("nim_token");
    loginOverlay.classList.remove("hidden");
  }
});