feat(accounts): add provider account admin view
This commit is contained in:
@@ -374,6 +374,7 @@
|
||||
<a href="/portal/admin/">管理首页</a>
|
||||
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
|
||||
<a href="/portal/admin/route-health.html">Route 健康视图</a>
|
||||
<a href="/portal/admin/accounts.html">帐号资产</a>
|
||||
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
|
||||
<a href="/portal/admin/batch-import.html" class="is-current">导入供应商帐号</a>
|
||||
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
|
||||
|
||||
931
deploy/tksea-portal/admin/accounts.html
Normal file
931
deploy/tksea-portal/admin/accounts.html
Normal file
@@ -0,0 +1,931 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Provider Accounts Admin</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f3ede4;
|
||||
--panel: rgba(255, 252, 246, 0.94);
|
||||
--ink: #201b17;
|
||||
--muted: #685d54;
|
||||
--line: rgba(32, 27, 23, 0.12);
|
||||
--accent: #0b6bcb;
|
||||
--accent-soft: rgba(11, 107, 203, 0.12);
|
||||
--success: #126b43;
|
||||
--success-soft: rgba(18, 107, 67, 0.1);
|
||||
--warn: #9b6215;
|
||||
--warn-soft: rgba(155, 98, 21, 0.12);
|
||||
--danger: #b23131;
|
||||
--danger-soft: rgba(178, 49, 49, 0.1);
|
||||
--shadow: 0 26px 72px rgba(47, 38, 29, 0.1);
|
||||
--radius: 24px;
|
||||
--radius-sm: 16px;
|
||||
--font-sans: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
|
||||
--font-mono: "IBM Plex Mono", "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(11, 107, 203, 0.16), transparent 26rem),
|
||||
radial-gradient(circle at bottom right, rgba(18, 107, 67, 0.12), transparent 24rem),
|
||||
var(--bg);
|
||||
}
|
||||
a { color: inherit; }
|
||||
code, pre {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
.shell {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 34px 20px 64px;
|
||||
}
|
||||
.topnav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.topnav a {
|
||||
text-decoration: none;
|
||||
padding: 10px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.78);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
transition: transform 120ms ease, background 120ms ease;
|
||||
}
|
||||
.topnav a:hover { transform: translateY(-1px); background: #fff; }
|
||||
.topnav a.is-current {
|
||||
background: var(--ink);
|
||||
border-color: var(--ink);
|
||||
color: #fff;
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.8fr;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.hero-card, .panel {
|
||||
padding: 26px;
|
||||
}
|
||||
.hero-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -4rem;
|
||||
bottom: -4rem;
|
||||
width: 18rem;
|
||||
height: 18rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, rgba(11, 107, 203, 0.18), rgba(18, 107, 67, 0.06));
|
||||
filter: blur(10px);
|
||||
}
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
h1 {
|
||||
margin: 18px 0 10px;
|
||||
font-size: clamp(32px, 4vw, 46px);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.hero-copy, .panel-desc {
|
||||
color: var(--muted);
|
||||
line-height: 1.75;
|
||||
font-size: 15px;
|
||||
}
|
||||
.hero-points {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.hero-points li {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.78);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.metrics {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
.metric {
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
.metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.metric-value {
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
word-break: break-word;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 440px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.field-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.field-grid.two {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.field-grid.three {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
input, select, textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
font: inherit;
|
||||
color: var(--ink);
|
||||
background: #fff;
|
||||
}
|
||||
textarea {
|
||||
min-height: 84px;
|
||||
resize: vertical;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
button {
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
padding: 12px 18px;
|
||||
font: inherit;
|
||||
font-weight: 800;
|
||||
transition: transform 120ms ease, opacity 120ms ease, background 120ms ease;
|
||||
}
|
||||
button:hover { transform: translateY(-1px); }
|
||||
button:disabled { cursor: not-allowed; opacity: 0.6; transform: none; }
|
||||
.primary { background: var(--ink); color: #fff; }
|
||||
.secondary { background: var(--accent-soft); color: var(--accent); }
|
||||
.ghost {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
.danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(178, 49, 49, 0.2);
|
||||
}
|
||||
.statusbar {
|
||||
margin-top: 16px;
|
||||
min-height: 54px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.catalog {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.row-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,0.86);
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.row-card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(11, 107, 203, 0.24);
|
||||
box-shadow: 0 16px 36px rgba(47, 38, 29, 0.08);
|
||||
}
|
||||
.row-card.is-selected {
|
||||
border-color: rgba(11, 107, 203, 0.34);
|
||||
background: linear-gradient(180deg, rgba(11, 107, 203, 0.08), rgba(255,255,255,0.96));
|
||||
}
|
||||
.row-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.row-title {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
word-break: break-word;
|
||||
}
|
||||
.badge-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.badge.active {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.badge.disabled {
|
||||
background: rgba(120, 113, 108, 0.12);
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge.deprecated {
|
||||
background: var(--warn-soft);
|
||||
color: var(--warn);
|
||||
}
|
||||
.badge.broken {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
.meta-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.detail-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255,255,255,0.84);
|
||||
padding: 16px;
|
||||
}
|
||||
.detail-card strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.detail-card span, .detail-card code {
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
.empty {
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
border: 1px dashed var(--line);
|
||||
color: var(--muted);
|
||||
background: rgba(255,255,255,0.58);
|
||||
}
|
||||
.raw-json {
|
||||
margin-top: 16px;
|
||||
background: #161311;
|
||||
color: #f6efe8;
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
min-height: 180px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.hero, .layout, .field-grid.two, .field-grid.three, .detail-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<nav class="topnav" aria-label="Admin Navigation">
|
||||
<a href="/portal/admin/">管理首页</a>
|
||||
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
|
||||
<a href="/portal/admin/route-health.html">Route 健康视图</a>
|
||||
<a href="/portal/admin/accounts.html" class="is-current">帐号资产</a>
|
||||
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
|
||||
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
|
||||
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
|
||||
</nav>
|
||||
|
||||
<section class="hero">
|
||||
<article class="card hero-card">
|
||||
<div class="eyebrow">Provider Accounts</div>
|
||||
<h1>把导入结果升级成可读、可筛选、可启停的帐号资产库存</h1>
|
||||
<p class="hero-copy">
|
||||
这页直接消费 <code>/api/provider-accounts</code> 与三个启停动作,把每条供应商帐号摊开到
|
||||
<code>provider / logical_group / route / shadow_group / shadow_host</code> 维度。
|
||||
当前首版明确只修改插件 SQLite 里的帐号资产状态,不假装已经联动修改宿主 account 记录。
|
||||
</p>
|
||||
<ul class="hero-points">
|
||||
<li>默认 API Base:<code>/portal-admin-api</code></li>
|
||||
<li>列表会先做一次 provider_accounts 回填</li>
|
||||
<li>人工 disabled / deprecated 不会被列表刷新刷回 active</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<aside class="card metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-label">API Root</div>
|
||||
<div class="metric-value" id="metric-api-root">-</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Accounts</div>
|
||||
<div class="metric-value" id="metric-total">0</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Active / Disabled</div>
|
||||
<div class="metric-value" id="metric-live">0 / 0</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Deprecated / Broken</div>
|
||||
<div class="metric-value" id="metric-dead">0 / 0</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="layout">
|
||||
<div class="stack">
|
||||
<article class="card panel">
|
||||
<h2>连接与过滤</h2>
|
||||
<p class="panel-desc">
|
||||
这页默认优先走管理员 session,也保留 Bearer token 兜底。过滤只影响读取列表,不会修改帐号状态。
|
||||
</p>
|
||||
<div class="field-grid">
|
||||
<label>
|
||||
API Base
|
||||
<input id="api-base" type="text" value="/portal-admin-api">
|
||||
</label>
|
||||
<label>
|
||||
Bearer Admin Token(可选)
|
||||
<input id="admin-token" type="password" placeholder="未启用 session 时可填">
|
||||
</label>
|
||||
</div>
|
||||
<div class="field-grid two" style="margin-top:12px;">
|
||||
<label>
|
||||
管理员用户名
|
||||
<input id="admin-username" type="text" placeholder="portal-admin">
|
||||
</label>
|
||||
<label>
|
||||
管理员密码
|
||||
<input id="admin-password" type="password" placeholder="请输入当前实例管理员密码">
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="secondary" id="admin-login-btn" type="button">管理员登录</button>
|
||||
<button class="ghost" id="admin-logout-btn" type="button">退出会话</button>
|
||||
<button class="ghost" id="save-config-btn" type="button">保存本地配置</button>
|
||||
<button class="ghost" id="refresh-btn" type="button">刷新帐号库存</button>
|
||||
</div>
|
||||
<div class="statusbar" id="session-status">正在检查管理员会话…</div>
|
||||
|
||||
<div class="field-grid three" style="margin-top:18px;">
|
||||
<label>
|
||||
host_id
|
||||
<input id="filter-host-id" type="text" placeholder="例如 remote43">
|
||||
</label>
|
||||
<label>
|
||||
provider_id
|
||||
<input id="filter-provider-id" type="text" placeholder="例如 gpt-asxs-shadow-lab">
|
||||
</label>
|
||||
<label>
|
||||
logical_group_id
|
||||
<input id="filter-logical-group-id" type="text" placeholder="例如 gpt-shared">
|
||||
</label>
|
||||
</div>
|
||||
<div class="field-grid three" style="margin-top:12px;">
|
||||
<label>
|
||||
route_id
|
||||
<input id="filter-route-id" type="text" placeholder="例如 asxs-primary">
|
||||
</label>
|
||||
<label>
|
||||
shadow_group_id
|
||||
<input id="filter-shadow-group-id" type="text" placeholder="例如 9">
|
||||
</label>
|
||||
<label>
|
||||
account_status
|
||||
<select id="filter-status">
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">active</option>
|
||||
<option value="disabled">disabled</option>
|
||||
<option value="deprecated">deprecated</option>
|
||||
<option value="broken">broken</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field-grid two" style="margin-top:12px;">
|
||||
<label>
|
||||
搜索
|
||||
<input id="filter-query" type="text" placeholder="provider / logical_group / host_account / fingerprint">
|
||||
</label>
|
||||
<label>
|
||||
limit
|
||||
<input id="filter-limit" type="number" min="1" max="500" value="200">
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="primary" id="apply-filters-btn" type="button">应用过滤</button>
|
||||
<button class="ghost" id="clear-filters-btn" type="button">清空过滤</button>
|
||||
</div>
|
||||
<div class="statusbar" id="table-status">帐号库存结果会显示在这里。</div>
|
||||
</article>
|
||||
|
||||
<article class="card panel">
|
||||
<h2>帐号资产清单</h2>
|
||||
<p class="panel-desc">
|
||||
选中一条帐号后,右侧会展示完整归属和当前启停操作。未补齐 route 的帐号不会被隐藏,而是明确显示为“未归属”。
|
||||
</p>
|
||||
<div class="catalog" id="accounts-catalog">
|
||||
<div class="empty">还没有帐号库存数据。</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<article class="card panel">
|
||||
<h2>帐号归属详情</h2>
|
||||
<p class="panel-desc">
|
||||
这里回答三个问题:这条帐号属于谁、挂到哪条 route、当前是人工停用还是自动探测异常。所有启停动作都只改插件库存状态。
|
||||
</p>
|
||||
<p class="panel-desc">
|
||||
当前显式使用的动作接口是:
|
||||
<code>/api/provider-accounts/{account_id}/enable</code>、
|
||||
<code>/api/provider-accounts/{account_id}/disable</code>、
|
||||
<code>/api/provider-accounts/{account_id}/retire</code>。
|
||||
</p>
|
||||
<div class="field-grid" style="margin-top:12px;">
|
||||
<label>
|
||||
状态变更原因
|
||||
<textarea id="action-reason" placeholder="例如 manual_disable / quota_pause / provider_rotation"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="secondary" id="enable-btn" type="button" disabled>启用帐号</button>
|
||||
<button class="ghost" id="disable-btn" type="button" disabled>停用帐号</button>
|
||||
<button class="danger" id="retire-btn" type="button" disabled>标记退役</button>
|
||||
</div>
|
||||
<div class="statusbar" id="action-status">请选择左侧一条帐号记录。</div>
|
||||
|
||||
<div id="detail-empty" class="empty" style="margin-top:16px;">选择左侧一条帐号后,这里会显示 route / shadow group / logical group 归属详情。</div>
|
||||
<div id="detail-panel" hidden>
|
||||
<div class="detail-grid" id="detail-grid"></div>
|
||||
<pre class="raw-json" id="detail-json">{}</pre>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const storageKey = "sub2api-provider-accounts-admin";
|
||||
const state = {
|
||||
accounts: [],
|
||||
selectedAccountID: 0,
|
||||
};
|
||||
|
||||
const apiBaseInput = document.getElementById("api-base");
|
||||
const adminTokenInput = document.getElementById("admin-token");
|
||||
const adminUsernameInput = document.getElementById("admin-username");
|
||||
const adminPasswordInput = document.getElementById("admin-password");
|
||||
const hostFilterInput = document.getElementById("filter-host-id");
|
||||
const providerFilterInput = document.getElementById("filter-provider-id");
|
||||
const logicalGroupFilterInput = document.getElementById("filter-logical-group-id");
|
||||
const routeFilterInput = document.getElementById("filter-route-id");
|
||||
const shadowGroupFilterInput = document.getElementById("filter-shadow-group-id");
|
||||
const statusFilterInput = document.getElementById("filter-status");
|
||||
const queryFilterInput = document.getElementById("filter-query");
|
||||
const limitFilterInput = document.getElementById("filter-limit");
|
||||
const actionReasonInput = document.getElementById("action-reason");
|
||||
|
||||
const sessionStatus = document.getElementById("session-status");
|
||||
const tableStatus = document.getElementById("table-status");
|
||||
const actionStatus = document.getElementById("action-status");
|
||||
const accountsCatalog = document.getElementById("accounts-catalog");
|
||||
const detailEmpty = document.getElementById("detail-empty");
|
||||
const detailPanel = document.getElementById("detail-panel");
|
||||
const detailGrid = document.getElementById("detail-grid");
|
||||
const detailJSON = document.getElementById("detail-json");
|
||||
|
||||
const metricApiRoot = document.getElementById("metric-api-root");
|
||||
const metricTotal = document.getElementById("metric-total");
|
||||
const metricLive = document.getElementById("metric-live");
|
||||
const metricDead = document.getElementById("metric-dead");
|
||||
|
||||
const enableButton = document.getElementById("enable-btn");
|
||||
const disableButton = document.getElementById("disable-btn");
|
||||
const retireButton = document.getElementById("retire-btn");
|
||||
|
||||
function readConfig() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(storageKey) || "{}");
|
||||
} catch (error) {
|
||||
console.warn("failed to parse config", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfig() {
|
||||
const payload = {
|
||||
apiBase: apiBaseInput.value.trim(),
|
||||
adminToken: adminTokenInput.value,
|
||||
adminUsername: adminUsernameInput.value.trim(),
|
||||
hostID: hostFilterInput.value.trim(),
|
||||
providerID: providerFilterInput.value.trim(),
|
||||
logicalGroupID: logicalGroupFilterInput.value.trim(),
|
||||
routeID: routeFilterInput.value.trim(),
|
||||
shadowGroupID: shadowGroupFilterInput.value.trim(),
|
||||
accountStatus: statusFilterInput.value,
|
||||
query: queryFilterInput.value.trim(),
|
||||
limit: limitFilterInput.value.trim(),
|
||||
};
|
||||
localStorage.setItem(storageKey, JSON.stringify(payload));
|
||||
setStatus(tableStatus, "已保存本地配置。");
|
||||
}
|
||||
|
||||
function hydrateConfig() {
|
||||
const config = readConfig();
|
||||
apiBaseInput.value = config.apiBase || "/portal-admin-api";
|
||||
adminTokenInput.value = config.adminToken || "";
|
||||
adminUsernameInput.value = config.adminUsername || "";
|
||||
hostFilterInput.value = config.hostID || "";
|
||||
providerFilterInput.value = config.providerID || "";
|
||||
logicalGroupFilterInput.value = config.logicalGroupID || "";
|
||||
routeFilterInput.value = config.routeID || "";
|
||||
shadowGroupFilterInput.value = config.shadowGroupID || "";
|
||||
statusFilterInput.value = config.accountStatus || "";
|
||||
queryFilterInput.value = config.query || "";
|
||||
limitFilterInput.value = config.limit || "200";
|
||||
}
|
||||
|
||||
function apiBase() {
|
||||
return (apiBaseInput.value.trim() || "/portal-admin-api").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function authHeaders() {
|
||||
const token = adminTokenInput.value.trim();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function requestJSON(path, options = {}) {
|
||||
const response = await fetch(`${apiBase()}${path}`, {
|
||||
credentials: "include",
|
||||
...options,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = text ? JSON.parse(text) : {};
|
||||
if (!response.ok) {
|
||||
const message = payload?.error?.message || payload?.message || response.statusText || "request failed";
|
||||
throw new Error(`${response.status} ${message}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function refreshSession() {
|
||||
metricApiRoot.textContent = apiBase();
|
||||
try {
|
||||
const payload = await requestJSON("/api/admin/session", { headers: authHeaders() });
|
||||
if (payload.authenticated) {
|
||||
setStatus(sessionStatus, `管理员会话已建立:${payload.username || "unknown"}(session)`, "success");
|
||||
} else if (payload.login_enabled) {
|
||||
setStatus(sessionStatus, `当前未登录。可用管理员用户名 ${payload.username || "admin"} 建立 session,或继续使用 Bearer token。`, "warn");
|
||||
} else {
|
||||
setStatus(sessionStatus, "当前实例未启用管理员登录,只能使用 Bearer token。", "warn");
|
||||
}
|
||||
} catch (error) {
|
||||
setStatus(sessionStatus, `检查管理员会话失败:${error.message}`, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
async function loginSession() {
|
||||
const username = adminUsernameInput.value.trim();
|
||||
const password = adminPasswordInput.value;
|
||||
if (!username || !password) {
|
||||
setStatus(sessionStatus, "请先输入管理员用户名和密码。", "warn");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await requestJSON("/api/admin/session/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
setStatus(sessionStatus, `管理员会话已建立:${payload.username || username}`, "success");
|
||||
} catch (error) {
|
||||
setStatus(sessionStatus, `管理员登录失败:${error.message}`, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
async function logoutSession() {
|
||||
try {
|
||||
await requestJSON("/api/admin/session/logout", {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
setStatus(sessionStatus, "管理员会话已退出。", "warn");
|
||||
} catch (error) {
|
||||
setStatus(sessionStatus, `退出会话失败:${error.message}`, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function buildListQuery() {
|
||||
const params = new URLSearchParams();
|
||||
if (hostFilterInput.value.trim()) params.set("host_id", hostFilterInput.value.trim());
|
||||
if (providerFilterInput.value.trim()) params.set("provider_id", providerFilterInput.value.trim());
|
||||
if (logicalGroupFilterInput.value.trim()) params.set("logical_group_id", logicalGroupFilterInput.value.trim());
|
||||
if (routeFilterInput.value.trim()) params.set("route_id", routeFilterInput.value.trim());
|
||||
if (shadowGroupFilterInput.value.trim()) params.set("shadow_group_id", shadowGroupFilterInput.value.trim());
|
||||
if (statusFilterInput.value) params.set("account_status", statusFilterInput.value);
|
||||
if (queryFilterInput.value.trim()) params.set("q", queryFilterInput.value.trim());
|
||||
if (limitFilterInput.value.trim()) params.set("limit", limitFilterInput.value.trim());
|
||||
const query = params.toString();
|
||||
return query ? `/api/provider-accounts?${query}` : "/api/provider-accounts";
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
setStatus(tableStatus, "正在读取 provider_accounts…");
|
||||
try {
|
||||
const payload = await requestJSON(buildListQuery(), { headers: authHeaders() });
|
||||
state.accounts = Array.isArray(payload.provider_accounts) ? payload.provider_accounts : [];
|
||||
if (!state.accounts.some((item) => item.id === state.selectedAccountID)) {
|
||||
state.selectedAccountID = state.accounts[0]?.id || 0;
|
||||
}
|
||||
renderMetrics();
|
||||
renderCatalog();
|
||||
renderDetail();
|
||||
setStatus(tableStatus, `已加载 ${state.accounts.length} 条帐号资产记录。`, "success");
|
||||
} catch (error) {
|
||||
state.accounts = [];
|
||||
state.selectedAccountID = 0;
|
||||
renderMetrics();
|
||||
renderCatalog();
|
||||
renderDetail();
|
||||
setStatus(tableStatus, `读取帐号资产失败:${error.message}`, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function renderMetrics() {
|
||||
metricApiRoot.textContent = apiBase();
|
||||
metricTotal.textContent = String(state.accounts.length);
|
||||
const counts = { active: 0, disabled: 0, deprecated: 0, broken: 0 };
|
||||
state.accounts.forEach((account) => {
|
||||
if (Object.prototype.hasOwnProperty.call(counts, account.account_status)) {
|
||||
counts[account.account_status] += 1;
|
||||
}
|
||||
});
|
||||
metricLive.textContent = `${counts.active} / ${counts.disabled}`;
|
||||
metricDead.textContent = `${counts.deprecated} / ${counts.broken}`;
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
if (status === "active") return "active";
|
||||
if (status === "disabled") return "disabled";
|
||||
if (status === "deprecated") return "deprecated";
|
||||
return "broken";
|
||||
}
|
||||
|
||||
function renderCatalog() {
|
||||
if (!state.accounts.length) {
|
||||
accountsCatalog.innerHTML = '<div class="empty">还没有匹配到帐号资产记录。</div>';
|
||||
return;
|
||||
}
|
||||
accountsCatalog.innerHTML = "";
|
||||
state.accounts.forEach((account) => {
|
||||
const card = document.createElement("button");
|
||||
card.type = "button";
|
||||
card.className = `row-card${account.id === state.selectedAccountID ? " is-selected" : ""}`;
|
||||
card.innerHTML = `
|
||||
<div class="row-heading">
|
||||
<div>
|
||||
<div class="row-title">${escapeHTML(account.account_name || account.host_account_id)}</div>
|
||||
<div class="meta-list">
|
||||
<span>provider: <code>${escapeHTML(account.provider_id)}</code></span>
|
||||
<span>host_account_id: <code>${escapeHTML(account.host_account_id)}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="badge ${statusClass(account.account_status)}">${escapeHTML(account.account_status)}</span>
|
||||
</div>
|
||||
<div class="badge-row">
|
||||
<span class="badge ${statusClass(account.account_status)}">${escapeHTML(account.logical_group_id || "未归属 logical_group")}</span>
|
||||
<span class="badge ${statusClass(account.account_status)}">${escapeHTML(account.route_id || "未归属 route")}</span>
|
||||
<span class="badge ${statusClass(account.account_status)}">shadow_group: ${escapeHTML(account.shadow_group_id || "-")}</span>
|
||||
</div>
|
||||
<div class="meta-list">
|
||||
<span>route_name: <code>${escapeHTML(account.route_name || "-")}</code></span>
|
||||
<span>shadow_host_id: <code>${escapeHTML(account.shadow_host_id || account.host_id || "-")}</code></span>
|
||||
<span>last_probe_status: <code>${escapeHTML(account.last_probe_status || "-")}</code></span>
|
||||
</div>
|
||||
`;
|
||||
card.addEventListener("click", () => {
|
||||
state.selectedAccountID = account.id;
|
||||
renderCatalog();
|
||||
renderDetail();
|
||||
});
|
||||
accountsCatalog.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||||
const hasSelection = Boolean(account);
|
||||
detailEmpty.hidden = hasSelection;
|
||||
detailPanel.hidden = !hasSelection;
|
||||
enableButton.disabled = !hasSelection;
|
||||
disableButton.disabled = !hasSelection;
|
||||
retireButton.disabled = !hasSelection;
|
||||
if (!account) {
|
||||
detailGrid.innerHTML = "";
|
||||
detailJSON.textContent = "{}";
|
||||
setStatus(actionStatus, "请选择左侧一条帐号记录。");
|
||||
return;
|
||||
}
|
||||
const cards = [
|
||||
["帐号主键", String(account.id)],
|
||||
["provider_id", account.provider_id],
|
||||
["provider_name", account.provider_name || "-"],
|
||||
["host_id", account.host_id],
|
||||
["host_base_url", account.host_base_url || "-"],
|
||||
["logical_group_id", account.logical_group_id || "未归属"],
|
||||
["route_id", account.route_id || "未归属"],
|
||||
["route_name", account.route_name || "-"],
|
||||
["shadow_group_id", account.shadow_group_id || "-"],
|
||||
["shadow_host_id", account.shadow_host_id || "-"],
|
||||
["upstream_base_url_hint", account.upstream_base_url_hint || "-"],
|
||||
["host_account_id", account.host_account_id],
|
||||
["key_fingerprint", account.key_fingerprint],
|
||||
["account_status", account.account_status],
|
||||
["last_probe_status", account.last_probe_status || "-"],
|
||||
["last_probe_at", account.last_probe_at || "-"],
|
||||
["disabled_reason", account.disabled_reason || "-"],
|
||||
["updated_at", account.updated_at || "-"],
|
||||
];
|
||||
detailGrid.innerHTML = cards.map(([label, value]) => `
|
||||
<div class="detail-card">
|
||||
<strong>${escapeHTML(label)}</strong>
|
||||
<code>${escapeHTML(value)}</code>
|
||||
</div>
|
||||
`).join("");
|
||||
detailJSON.textContent = JSON.stringify(account, null, 2);
|
||||
setStatus(actionStatus, `当前选中帐号 #${account.id},操作只会修改插件 provider_accounts 库存状态。`);
|
||||
}
|
||||
|
||||
async function updateAccountStatus(action) {
|
||||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||||
if (!account) {
|
||||
setStatus(actionStatus, "请先选择一条帐号记录。", "warn");
|
||||
return;
|
||||
}
|
||||
const reason = actionReasonInput.value.trim();
|
||||
if ((action === "disable" || action === "retire") && !reason) {
|
||||
setStatus(actionStatus, "停用或退役请填写原因,避免后续看不懂为什么改状态。", "warn");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await requestJSON(`/api/provider-accounts/${encodeURIComponent(account.id)}/${action}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(reason ? { reason } : {}),
|
||||
});
|
||||
const updated = payload.provider_account;
|
||||
setStatus(actionStatus, `帐号 #${updated.id} 已更新为 ${updated.account_status}${updated.disabled_reason ? `(${updated.disabled_reason})` : ""}。`, "success");
|
||||
await loadAccounts();
|
||||
} catch (error) {
|
||||
setStatus(actionStatus, `更新帐号状态失败:${error.message}`, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
hostFilterInput.value = "";
|
||||
providerFilterInput.value = "";
|
||||
logicalGroupFilterInput.value = "";
|
||||
routeFilterInput.value = "";
|
||||
shadowGroupFilterInput.value = "";
|
||||
statusFilterInput.value = "";
|
||||
queryFilterInput.value = "";
|
||||
limitFilterInput.value = "200";
|
||||
}
|
||||
|
||||
function setStatus(element, message, tone = "") {
|
||||
element.textContent = message;
|
||||
element.style.color = tone === "danger"
|
||||
? "var(--danger)"
|
||||
: tone === "warn"
|
||||
? "var(--warn)"
|
||||
: tone === "success"
|
||||
? "var(--success)"
|
||||
: "var(--muted)";
|
||||
element.style.borderColor = tone === "danger"
|
||||
? "rgba(178, 49, 49, 0.24)"
|
||||
: tone === "warn"
|
||||
? "rgba(155, 98, 21, 0.22)"
|
||||
: tone === "success"
|
||||
? "rgba(18, 107, 67, 0.22)"
|
||||
: "var(--line)";
|
||||
element.style.background = tone === "danger"
|
||||
? "rgba(178, 49, 49, 0.08)"
|
||||
: tone === "warn"
|
||||
? "rgba(155, 98, 21, 0.08)"
|
||||
: tone === "success"
|
||||
? "rgba(18, 107, 67, 0.08)"
|
||||
: "#fff";
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
document.getElementById("save-config-btn").addEventListener("click", writeConfig);
|
||||
document.getElementById("admin-login-btn").addEventListener("click", loginSession);
|
||||
document.getElementById("admin-logout-btn").addEventListener("click", logoutSession);
|
||||
document.getElementById("refresh-btn").addEventListener("click", loadAccounts);
|
||||
document.getElementById("apply-filters-btn").addEventListener("click", loadAccounts);
|
||||
document.getElementById("clear-filters-btn").addEventListener("click", () => {
|
||||
clearFilters();
|
||||
loadAccounts();
|
||||
});
|
||||
enableButton.addEventListener("click", () => updateAccountStatus("enable"));
|
||||
disableButton.addEventListener("click", () => updateAccountStatus("disable"));
|
||||
retireButton.addEventListener("click", () => updateAccountStatus("retire"));
|
||||
|
||||
hydrateConfig();
|
||||
refreshSession();
|
||||
loadAccounts();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -252,6 +252,7 @@
|
||||
<a href="/portal/admin/" class="is-current">管理首页</a>
|
||||
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
|
||||
<a href="/portal/admin/route-health.html">Route 健康视图</a>
|
||||
<a href="/portal/admin/accounts.html">帐号资产</a>
|
||||
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
|
||||
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
|
||||
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
|
||||
@@ -290,6 +291,10 @@
|
||||
<div class="metric-label">Route Health</div>
|
||||
<div class="metric-value">/route-health</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Accounts</div>
|
||||
<div class="metric-value">/accounts</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Batch Import</div>
|
||||
<div class="metric-value">/batch-import</div>
|
||||
@@ -364,6 +369,28 @@
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card panel">
|
||||
<h2>帐号资产</h2>
|
||||
<p>
|
||||
这页把导入结果收成插件侧 <code>provider_accounts</code> 库存,直接展示帐号属于哪个
|
||||
<code>logical_group / route / shadow_group / shadow_host</code>,并提供人工
|
||||
<code>enable / disable / retire</code> 动作。
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="cta primary" href="/portal/admin/accounts.html">打开帐号资产页</a>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<li>
|
||||
<strong>适用动作</strong>
|
||||
查看帐号库存、筛选 route 归属、执行人工启停与退役。
|
||||
</li>
|
||||
<li>
|
||||
<strong>当前边界</strong>
|
||||
启停动作当前只修改插件库存状态,不直接改宿主 account 记录。
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card panel">
|
||||
<h2>导入供应商帐号</h2>
|
||||
<p>
|
||||
|
||||
@@ -370,6 +370,7 @@
|
||||
<a href="/portal/admin/">管理首页</a>
|
||||
<a href="/portal/admin/logical-groups.html" class="is-current">逻辑分组 / 路由</a>
|
||||
<a href="/portal/admin/route-health.html">Route 健康视图</a>
|
||||
<a href="/portal/admin/accounts.html">帐号资产</a>
|
||||
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
|
||||
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
|
||||
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
|
||||
|
||||
@@ -361,6 +361,7 @@
|
||||
<a href="/portal/admin/">管理首页</a>
|
||||
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
|
||||
<a href="/portal/admin/route-health.html">Route 健康视图</a>
|
||||
<a href="/portal/admin/accounts.html">帐号资产</a>
|
||||
<a href="/portal/admin/providers.html" class="is-current">新增模型 / 供应商目录</a>
|
||||
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
|
||||
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
|
||||
|
||||
@@ -343,6 +343,7 @@
|
||||
<a href="/portal/admin/">管理首页</a>
|
||||
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
|
||||
<a href="/portal/admin/route-health.html" class="is-current">Route 健康视图</a>
|
||||
<a href="/portal/admin/accounts.html">帐号资产</a>
|
||||
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
|
||||
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
|
||||
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
|
||||
|
||||
@@ -12,13 +12,14 @@ import (
|
||||
)
|
||||
|
||||
type ListProviderAccountsRequest struct {
|
||||
HostID string
|
||||
ProviderID string
|
||||
RouteID string
|
||||
ShadowGroupID string
|
||||
AccountStatus string
|
||||
Query string
|
||||
Limit int
|
||||
HostID string
|
||||
ProviderID string
|
||||
LogicalGroupID string
|
||||
RouteID string
|
||||
ShadowGroupID string
|
||||
AccountStatus string
|
||||
Query string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type UpdateProviderAccountStatusRequest struct {
|
||||
@@ -28,22 +29,26 @@ type UpdateProviderAccountStatusRequest struct {
|
||||
}
|
||||
|
||||
type ProviderAccountInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
HostID string `json:"host_id"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
RouteID string `json:"route_id,omitempty"`
|
||||
LogicalGroupID string `json:"logical_group_id,omitempty"`
|
||||
ShadowGroupID string `json:"shadow_group_id,omitempty"`
|
||||
HostAccountID string `json:"host_account_id"`
|
||||
KeyFingerprint string `json:"key_fingerprint"`
|
||||
AccountName string `json:"account_name"`
|
||||
AccountStatus string `json:"account_status"`
|
||||
LastProbeStatus string `json:"last_probe_status,omitempty"`
|
||||
LastProbeAt string `json:"last_probe_at,omitempty"`
|
||||
DisabledReason string `json:"disabled_reason,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
HostID string `json:"host_id"`
|
||||
HostBaseURL string `json:"host_base_url"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
RouteName string `json:"route_name,omitempty"`
|
||||
RouteID string `json:"route_id,omitempty"`
|
||||
LogicalGroupID string `json:"logical_group_id,omitempty"`
|
||||
ShadowGroupID string `json:"shadow_group_id,omitempty"`
|
||||
ShadowHostID string `json:"shadow_host_id,omitempty"`
|
||||
UpstreamBaseURLHint string `json:"upstream_base_url_hint,omitempty"`
|
||||
HostAccountID string `json:"host_account_id"`
|
||||
KeyFingerprint string `json:"key_fingerprint"`
|
||||
AccountName string `json:"account_name"`
|
||||
AccountStatus string `json:"account_status"`
|
||||
LastProbeStatus string `json:"last_probe_status,omitempty"`
|
||||
LastProbeAt string `json:"last_probe_at,omitempty"`
|
||||
DisabledReason string `json:"disabled_reason,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func handleListProviderAccounts(w http.ResponseWriter, r *http.Request, fn func(context.Context, ListProviderAccountsRequest) ([]ProviderAccountInfo, error)) {
|
||||
@@ -52,13 +57,14 @@ func handleListProviderAccounts(w http.ResponseWriter, r *http.Request, fn func(
|
||||
return
|
||||
}
|
||||
accounts, err := fn(r.Context(), ListProviderAccountsRequest{
|
||||
HostID: strings.TrimSpace(r.URL.Query().Get("host_id")),
|
||||
ProviderID: strings.TrimSpace(r.URL.Query().Get("provider_id")),
|
||||
RouteID: strings.TrimSpace(r.URL.Query().Get("route_id")),
|
||||
ShadowGroupID: strings.TrimSpace(r.URL.Query().Get("shadow_group_id")),
|
||||
AccountStatus: strings.TrimSpace(r.URL.Query().Get("account_status")),
|
||||
Query: strings.TrimSpace(r.URL.Query().Get("q")),
|
||||
Limit: parsePositiveInt(r.URL.Query().Get("limit")),
|
||||
HostID: strings.TrimSpace(r.URL.Query().Get("host_id")),
|
||||
ProviderID: strings.TrimSpace(r.URL.Query().Get("provider_id")),
|
||||
LogicalGroupID: strings.TrimSpace(r.URL.Query().Get("logical_group_id")),
|
||||
RouteID: strings.TrimSpace(r.URL.Query().Get("route_id")),
|
||||
ShadowGroupID: strings.TrimSpace(r.URL.Query().Get("shadow_group_id")),
|
||||
AccountStatus: strings.TrimSpace(r.URL.Query().Get("account_status")),
|
||||
Query: strings.TrimSpace(r.URL.Query().Get("q")),
|
||||
Limit: parsePositiveInt(r.URL.Query().Get("limit")),
|
||||
})
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
@@ -125,13 +131,14 @@ func buildListProviderAccountsAction(sqliteDSN string) func(context.Context, Lis
|
||||
return nil, err
|
||||
}
|
||||
rows, err := store.ProviderAccounts().List(ctx, sqlite.ProviderAccountListFilter{
|
||||
HostID: req.HostID,
|
||||
ProviderID: req.ProviderID,
|
||||
RouteID: req.RouteID,
|
||||
ShadowGroupID: req.ShadowGroupID,
|
||||
AccountStatus: req.AccountStatus,
|
||||
Query: req.Query,
|
||||
Limit: req.Limit,
|
||||
HostID: req.HostID,
|
||||
ProviderID: req.ProviderID,
|
||||
LogicalGroupID: req.LogicalGroupID,
|
||||
RouteID: req.RouteID,
|
||||
ShadowGroupID: req.ShadowGroupID,
|
||||
AccountStatus: req.AccountStatus,
|
||||
Query: req.Query,
|
||||
Limit: req.Limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -168,21 +175,25 @@ func buildUpdateProviderAccountStatusAction(sqliteDSN, accountStatus string) fun
|
||||
|
||||
func providerAccountViewToInfo(row sqlite.ProviderAccountView) ProviderAccountInfo {
|
||||
return ProviderAccountInfo{
|
||||
ID: row.ID,
|
||||
HostID: row.HostID,
|
||||
ProviderID: row.ProviderID,
|
||||
ProviderName: row.ProviderName,
|
||||
RouteID: row.RouteID,
|
||||
LogicalGroupID: row.LogicalGroupID,
|
||||
ShadowGroupID: row.ShadowGroupID,
|
||||
HostAccountID: row.HostAccountID,
|
||||
KeyFingerprint: row.KeyFingerprint,
|
||||
AccountName: row.AccountName,
|
||||
AccountStatus: row.AccountStatus,
|
||||
LastProbeStatus: row.LastProbeStatus,
|
||||
LastProbeAt: row.LastProbeAt,
|
||||
DisabledReason: row.DisabledReason,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
ID: row.ID,
|
||||
HostID: row.HostID,
|
||||
HostBaseURL: row.HostBaseURL,
|
||||
ProviderID: row.ProviderID,
|
||||
ProviderName: row.ProviderName,
|
||||
RouteName: row.RouteName,
|
||||
RouteID: row.RouteID,
|
||||
LogicalGroupID: row.LogicalGroupID,
|
||||
ShadowGroupID: row.ShadowGroupID,
|
||||
ShadowHostID: row.ShadowHostID,
|
||||
UpstreamBaseURLHint: row.UpstreamBaseURLHint,
|
||||
HostAccountID: row.HostAccountID,
|
||||
KeyFingerprint: row.KeyFingerprint,
|
||||
AccountName: row.AccountName,
|
||||
AccountStatus: row.AccountStatus,
|
||||
LastProbeStatus: row.LastProbeStatus,
|
||||
LastProbeAt: row.LastProbeAt,
|
||||
DisabledReason: row.DisabledReason,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,23 +15,33 @@ func TestAPIListProviderAccountsReturnsRows(t *testing.T) {
|
||||
if req.ProviderID != "deepseek-official" {
|
||||
t.Fatalf("ProviderID = %q, want deepseek-official", req.ProviderID)
|
||||
}
|
||||
if req.LogicalGroupID != "gpt-shared" {
|
||||
t.Fatalf("LogicalGroupID = %q, want gpt-shared", req.LogicalGroupID)
|
||||
}
|
||||
if req.AccountStatus != "disabled" {
|
||||
t.Fatalf("AccountStatus = %q, want disabled", req.AccountStatus)
|
||||
}
|
||||
return []ProviderAccountInfo{{
|
||||
ID: 7,
|
||||
HostID: "remote43",
|
||||
ProviderID: "deepseek-official",
|
||||
ProviderName: "DeepSeek Official",
|
||||
HostAccountID: "9",
|
||||
AccountName: "deepseek-01",
|
||||
AccountStatus: "disabled",
|
||||
DisabledReason: "manual_disable",
|
||||
ID: 7,
|
||||
HostID: "remote43",
|
||||
HostBaseURL: "https://host.example.com",
|
||||
ProviderID: "deepseek-official",
|
||||
ProviderName: "DeepSeek Official",
|
||||
RouteID: "route-1",
|
||||
RouteName: "Primary Route",
|
||||
LogicalGroupID: "gpt-shared",
|
||||
ShadowGroupID: "group-9",
|
||||
ShadowHostID: "remote43",
|
||||
HostAccountID: "9",
|
||||
AccountName: "deepseek-01",
|
||||
AccountStatus: "disabled",
|
||||
DisabledReason: "manual_disable",
|
||||
UpstreamBaseURLHint: "https://api.deepseek.com",
|
||||
}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, "GET", "/api/provider-accounts?provider_id=deepseek-official&account_status=disabled", nil, "secret-token")
|
||||
request := httptestRequest(t, "GET", "/api/provider-accounts?provider_id=deepseek-official&logical_group_id=gpt-shared&account_status=disabled", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, 200)
|
||||
var payload map[string][]ProviderAccountInfo
|
||||
@@ -42,6 +52,9 @@ func TestAPIListProviderAccountsReturnsRows(t *testing.T) {
|
||||
if len(accounts) != 1 || accounts[0].ID != 7 || accounts[0].AccountStatus != "disabled" {
|
||||
t.Fatalf("provider_accounts = %+v, want one disabled row id=7", accounts)
|
||||
}
|
||||
if accounts[0].LogicalGroupID != "gpt-shared" || accounts[0].RouteName != "Primary Route" {
|
||||
t.Fatalf("provider_accounts relationship fields = %+v", accounts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDisableProviderAccountUsesPathID(t *testing.T) {
|
||||
@@ -108,9 +121,36 @@ func TestNewActionSetProviderAccountListAndStatusFlow(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Providers().Create() error = %v", err)
|
||||
}
|
||||
if _, err := store.LogicalGroups().Create(ctx, sqlite.LogicalGroup{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
DisplayName: "GPT Shared",
|
||||
Status: "active",
|
||||
StickyMode: "conversation_preferred",
|
||||
ConversationTTLSeconds: 7200,
|
||||
UserModelTTLSeconds: 1800,
|
||||
FailoverThreshold: 2,
|
||||
CooldownSeconds: 600,
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroups().Create() error = %v", err)
|
||||
}
|
||||
if _, err := store.LogicalGroupRoutes().Create(ctx, sqlite.LogicalGroupRoute{
|
||||
RouteID: "route-1",
|
||||
LogicalGroupID: "gpt-shared",
|
||||
Name: "Primary Route",
|
||||
Status: "active",
|
||||
Priority: 10,
|
||||
Weight: 100,
|
||||
ShadowGroupID: "group-9",
|
||||
ShadowHostID: "remote43",
|
||||
UpstreamBaseURLHint: "https://api.deepseek.com",
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroupRoutes().Create() error = %v", err)
|
||||
}
|
||||
providerAccountID, err := store.ProviderAccounts().Create(ctx, sqlite.ProviderAccount{
|
||||
HostID: hostRow.ID,
|
||||
ProviderID: providerRowID,
|
||||
RouteID: "route-1",
|
||||
ShadowGroupID: "group-9",
|
||||
HostAccountID: "9",
|
||||
KeyFingerprint: "sha256:abc",
|
||||
AccountName: "deepseek-01",
|
||||
@@ -129,6 +169,9 @@ func TestNewActionSetProviderAccountListAndStatusFlow(t *testing.T) {
|
||||
if len(listed) != 1 || listed[0].ID != providerAccountID {
|
||||
t.Fatalf("ListProviderAccounts() = %+v, want one row for id %d", listed, providerAccountID)
|
||||
}
|
||||
if listed[0].LogicalGroupID != "gpt-shared" || listed[0].RouteName != "Primary Route" || listed[0].ShadowHostID != "remote43" {
|
||||
t.Fatalf("ListProviderAccounts() relationship fields = %+v", listed[0])
|
||||
}
|
||||
|
||||
disabled, err := actions.DisableProviderAccount(ctx, UpdateProviderAccountStatusRequest{
|
||||
AccountID: providerAccountID,
|
||||
|
||||
@@ -2,6 +2,7 @@ package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
@@ -207,6 +208,66 @@ func (r *LogicalGroupRoutesRepo) DeleteByRouteID(ctx context.Context, routeID st
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LogicalGroupRoutesRepo) GetByShadowBinding(ctx context.Context, shadowHostID, shadowGroupID string) (LogicalGroupRoute, error) {
|
||||
shadowHostID = strings.TrimSpace(shadowHostID)
|
||||
shadowGroupID = strings.TrimSpace(shadowGroupID)
|
||||
if shadowHostID == "" {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("shadow_host_id is required")
|
||||
}
|
||||
if shadowGroupID == "" {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("shadow_group_id is required")
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(
|
||||
ctx,
|
||||
`SELECT id, route_id, logical_group_id, name, status, priority, weight, shadow_group_id, shadow_host_id, upstream_base_url_hint, cooldown_until, created_at, updated_at
|
||||
FROM logical_group_routes
|
||||
WHERE shadow_host_id = ? AND shadow_group_id = ?
|
||||
ORDER BY priority ASC, id ASC
|
||||
LIMIT 2`,
|
||||
shadowHostID,
|
||||
shadowGroupID,
|
||||
)
|
||||
if err != nil {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("get logical group route by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
routes := make([]LogicalGroupRoute, 0, 2)
|
||||
for rows.Next() {
|
||||
var route LogicalGroupRoute
|
||||
if err := rows.Scan(
|
||||
&route.ID,
|
||||
&route.RouteID,
|
||||
&route.LogicalGroupID,
|
||||
&route.Name,
|
||||
&route.Status,
|
||||
&route.Priority,
|
||||
&route.Weight,
|
||||
&route.ShadowGroupID,
|
||||
&route.ShadowHostID,
|
||||
&route.UpstreamBaseURLHint,
|
||||
&route.CooldownUntil,
|
||||
&route.CreatedAt,
|
||||
&route.UpdatedAt,
|
||||
); err != nil {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("scan logical group route by shadow binding: %w", err)
|
||||
}
|
||||
routes = append(routes, route)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("iterate logical group route by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
|
||||
}
|
||||
switch len(routes) {
|
||||
case 0:
|
||||
return LogicalGroupRoute{}, sql.ErrNoRows
|
||||
case 1:
|
||||
return routes[0], nil
|
||||
default:
|
||||
return LogicalGroupRoute{}, fmt.Errorf("multiple logical group routes match shadow binding %q/%q", shadowHostID, shadowGroupID)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLogicalGroupRoute(route LogicalGroupRoute) (LogicalGroupRoute, error) {
|
||||
route.RouteID = strings.TrimSpace(route.RouteID)
|
||||
route.LogicalGroupID = strings.TrimSpace(route.LogicalGroupID)
|
||||
|
||||
@@ -32,32 +32,37 @@ type ProviderAccount struct {
|
||||
}
|
||||
|
||||
type ProviderAccountListFilter struct {
|
||||
HostID string
|
||||
ProviderID string
|
||||
RouteID string
|
||||
ShadowGroupID string
|
||||
AccountStatus string
|
||||
Query string
|
||||
Limit int
|
||||
HostID string
|
||||
ProviderID string
|
||||
LogicalGroupID string
|
||||
RouteID string
|
||||
ShadowGroupID string
|
||||
AccountStatus string
|
||||
Query string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type ProviderAccountView struct {
|
||||
ID int64 `json:"id"`
|
||||
HostID string `json:"host_id"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
RouteID string `json:"route_id,omitempty"`
|
||||
LogicalGroupID string `json:"logical_group_id,omitempty"`
|
||||
ShadowGroupID string `json:"shadow_group_id,omitempty"`
|
||||
HostAccountID string `json:"host_account_id"`
|
||||
KeyFingerprint string `json:"key_fingerprint"`
|
||||
AccountName string `json:"account_name"`
|
||||
AccountStatus string `json:"account_status"`
|
||||
LastProbeStatus string `json:"last_probe_status,omitempty"`
|
||||
LastProbeAt string `json:"last_probe_at,omitempty"`
|
||||
DisabledReason string `json:"disabled_reason,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
HostID string `json:"host_id"`
|
||||
HostBaseURL string `json:"host_base_url"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
RouteName string `json:"route_name,omitempty"`
|
||||
RouteID string `json:"route_id,omitempty"`
|
||||
LogicalGroupID string `json:"logical_group_id,omitempty"`
|
||||
ShadowGroupID string `json:"shadow_group_id,omitempty"`
|
||||
ShadowHostID string `json:"shadow_host_id,omitempty"`
|
||||
UpstreamBaseURLHint string `json:"upstream_base_url_hint,omitempty"`
|
||||
HostAccountID string `json:"host_account_id"`
|
||||
KeyFingerprint string `json:"key_fingerprint"`
|
||||
AccountName string `json:"account_name"`
|
||||
AccountStatus string `json:"account_status"`
|
||||
LastProbeStatus string `json:"last_probe_status,omitempty"`
|
||||
LastProbeAt string `json:"last_probe_at,omitempty"`
|
||||
DisabledReason string `json:"disabled_reason,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderAccountsRepo struct {
|
||||
@@ -171,11 +176,15 @@ func (r *ProviderAccountsRepo) GetViewByID(ctx context.Context, id int64) (Provi
|
||||
return r.scanViewOne(ctx, `SELECT
|
||||
pa.id,
|
||||
h.host_id,
|
||||
h.base_url,
|
||||
p.provider_id,
|
||||
p.display_name,
|
||||
COALESCE(lgr.name, ''),
|
||||
COALESCE(pa.route_id, ''),
|
||||
COALESCE(lgr.logical_group_id, ''),
|
||||
COALESCE(pa.shadow_group_id, ''),
|
||||
COALESCE(lgr.shadow_host_id, ''),
|
||||
COALESCE(lgr.upstream_base_url_hint, ''),
|
||||
pa.host_account_id,
|
||||
pa.key_fingerprint,
|
||||
pa.account_name,
|
||||
@@ -252,11 +261,15 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
|
||||
query := `SELECT
|
||||
pa.id,
|
||||
h.host_id,
|
||||
h.base_url,
|
||||
p.provider_id,
|
||||
p.display_name,
|
||||
COALESCE(lgr.name, ''),
|
||||
COALESCE(pa.route_id, ''),
|
||||
COALESCE(lgr.logical_group_id, ''),
|
||||
COALESCE(pa.shadow_group_id, ''),
|
||||
COALESCE(lgr.shadow_host_id, ''),
|
||||
COALESCE(lgr.upstream_base_url_hint, ''),
|
||||
pa.host_account_id,
|
||||
pa.key_fingerprint,
|
||||
pa.account_name,
|
||||
@@ -280,6 +293,10 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
|
||||
query += ` AND p.provider_id = ?`
|
||||
args = append(args, value)
|
||||
}
|
||||
if value := strings.TrimSpace(filter.LogicalGroupID); value != "" {
|
||||
query += ` AND lgr.logical_group_id = ?`
|
||||
args = append(args, value)
|
||||
}
|
||||
if value := strings.TrimSpace(filter.RouteID); value != "" {
|
||||
query += ` AND pa.route_id = ?`
|
||||
args = append(args, value)
|
||||
@@ -299,9 +316,11 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
|
||||
LOWER(pa.account_name) LIKE ? OR
|
||||
LOWER(pa.key_fingerprint) LIKE ? OR
|
||||
LOWER(p.provider_id) LIKE ? OR
|
||||
LOWER(h.host_id) LIKE ?
|
||||
LOWER(h.host_id) LIKE ? OR
|
||||
LOWER(COALESCE(lgr.logical_group_id, '')) LIKE ? OR
|
||||
LOWER(COALESCE(lgr.name, '')) LIKE ?
|
||||
)`
|
||||
args = append(args, like, like, like, like, like)
|
||||
args = append(args, like, like, like, like, like, like, like)
|
||||
}
|
||||
query += ` ORDER BY pa.updated_at DESC, pa.id DESC`
|
||||
limit := filter.Limit
|
||||
@@ -323,11 +342,15 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
|
||||
if err := rows.Scan(
|
||||
&view.ID,
|
||||
&view.HostID,
|
||||
&view.HostBaseURL,
|
||||
&view.ProviderID,
|
||||
&view.ProviderName,
|
||||
&view.RouteName,
|
||||
&view.RouteID,
|
||||
&view.LogicalGroupID,
|
||||
&view.ShadowGroupID,
|
||||
&view.ShadowHostID,
|
||||
&view.UpstreamBaseURLHint,
|
||||
&view.HostAccountID,
|
||||
&view.KeyFingerprint,
|
||||
&view.AccountName,
|
||||
@@ -376,11 +399,15 @@ func (r *ProviderAccountsRepo) scanViewOne(ctx context.Context, query string, ar
|
||||
if err := r.db.QueryRowContext(ctx, query, args...).Scan(
|
||||
&view.ID,
|
||||
&view.HostID,
|
||||
&view.HostBaseURL,
|
||||
&view.ProviderID,
|
||||
&view.ProviderName,
|
||||
&view.RouteName,
|
||||
&view.RouteID,
|
||||
&view.LogicalGroupID,
|
||||
&view.ShadowGroupID,
|
||||
&view.ShadowHostID,
|
||||
&view.UpstreamBaseURLHint,
|
||||
&view.HostAccountID,
|
||||
&view.KeyFingerprint,
|
||||
&view.AccountName,
|
||||
|
||||
@@ -87,12 +87,13 @@ func TestProviderAccountsRepoCRUDAndFilters(t *testing.T) {
|
||||
}
|
||||
|
||||
rows, err := accountRepo.List(ctx, ProviderAccountListFilter{
|
||||
HostID: "host-" + sanitizeTestName(t.Name()),
|
||||
ProviderID: "deepseek-official",
|
||||
RouteID: "route-1",
|
||||
ShadowGroupID: "shadow-group-1",
|
||||
AccountStatus: ProviderAccountStatusBroken,
|
||||
Query: "deepseek",
|
||||
HostID: "host-" + sanitizeTestName(t.Name()),
|
||||
ProviderID: "deepseek-official",
|
||||
LogicalGroupID: "lg-1",
|
||||
RouteID: "route-1",
|
||||
ShadowGroupID: "shadow-group-1",
|
||||
AccountStatus: ProviderAccountStatusBroken,
|
||||
Query: "deepseek",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProviderAccounts().List() error = %v", err)
|
||||
@@ -100,6 +101,9 @@ func TestProviderAccountsRepoCRUDAndFilters(t *testing.T) {
|
||||
if len(rows) != 1 || rows[0].ID != accountID {
|
||||
t.Fatalf("ProviderAccounts().List() = %+v, want one row for account_id %d", rows, accountID)
|
||||
}
|
||||
if rows[0].LogicalGroupID != "lg-1" || rows[0].RouteName != "Route 1" || rows[0].ShadowHostID != "shadow-host-1" {
|
||||
t.Fatalf("ProviderAccounts().List() relationship view = %+v", rows[0])
|
||||
}
|
||||
|
||||
if err := accountRepo.UpdateStatusByID(ctx, accountID, ProviderAccountStatusDisabled, "manual_disable"); err != nil {
|
||||
t.Fatalf("ProviderAccounts().UpdateStatusByID() error = %v", err)
|
||||
@@ -284,3 +288,90 @@ func TestSyncProviderAccountsFromImportBatchPreservesManualDisabledStatus(t *tes
|
||||
t.Fatalf("account after resync = %+v, want disabled manual_disable preserved", account)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncProviderAccountsFromImportBatchInfersRouteFromShadowBinding(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := openTestDBWithFK(t)
|
||||
ctx := context.Background()
|
||||
hostID := createTestHost(t, store)
|
||||
hostRow, err := store.Hosts().GetByID(ctx, hostID)
|
||||
if err != nil {
|
||||
t.Fatalf("Hosts().GetByID() error = %v", err)
|
||||
}
|
||||
packID := createTestPack(t, store)
|
||||
providerID, err := store.Providers().Create(ctx, Provider{
|
||||
PackID: packID,
|
||||
ProviderID: "asxs-provider",
|
||||
DisplayName: "ASXS Provider",
|
||||
BaseURL: "https://api.asxs.top/v1",
|
||||
Platform: "openai",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Providers().Create() error = %v", err)
|
||||
}
|
||||
if _, err := store.LogicalGroups().Create(ctx, LogicalGroup{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
DisplayName: "GPT Shared",
|
||||
Status: "active",
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroups().Create() error = %v", err)
|
||||
}
|
||||
if _, err := store.LogicalGroupRoutes().Create(ctx, LogicalGroupRoute{
|
||||
RouteID: "route-shadow-1",
|
||||
LogicalGroupID: "gpt-shared",
|
||||
Name: "Shadow Route",
|
||||
Status: "active",
|
||||
Priority: 10,
|
||||
Weight: 100,
|
||||
ShadowGroupID: "group-1",
|
||||
ShadowHostID: hostRow.HostID,
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroupRoutes().Create() error = %v", err)
|
||||
}
|
||||
batchID, err := store.ImportBatches().Create(ctx, ImportBatch{
|
||||
HostID: hostID,
|
||||
PackID: packID,
|
||||
ProviderID: providerID,
|
||||
Mode: "strict",
|
||||
BatchStatus: "succeeded",
|
||||
AccessStatus: "subscription_ready",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ImportBatches().Create() error = %v", err)
|
||||
}
|
||||
if _, err := store.ImportBatchItems().Create(ctx, ImportBatchItem{
|
||||
BatchID: batchID,
|
||||
KeyFingerprint: "sha256:key1",
|
||||
AccountStatus: "passed",
|
||||
ProbeSummaryJSON: `{"account_id":"account-1","probe_status":"passed"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("ImportBatchItems().Create() error = %v", err)
|
||||
}
|
||||
for _, resource := range []ManagedResource{
|
||||
{BatchID: batchID, HostID: hostID, ResourceType: "group", HostResourceID: "group-1", ResourceName: "ASXS Group"},
|
||||
{BatchID: batchID, HostID: hostID, ResourceType: "account", HostResourceID: "account-1", ResourceName: "asxs-01"},
|
||||
} {
|
||||
if _, err := store.ManagedResources().Create(ctx, resource); err != nil {
|
||||
t.Fatalf("ManagedResources().Create(%s) error = %v", resource.ResourceType, err)
|
||||
}
|
||||
}
|
||||
if err := SyncProviderAccountsFromImportBatch(ctx, store, batchID); err != nil {
|
||||
t.Fatalf("SyncProviderAccountsFromImportBatch() error = %v", err)
|
||||
}
|
||||
|
||||
account, err := store.ProviderAccounts().GetByHostIDAndAccountID(ctx, hostID, "account-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ProviderAccounts().GetByHostIDAndAccountID() error = %v", err)
|
||||
}
|
||||
if account.RouteID != "route-shadow-1" || account.ShadowGroupID != "group-1" {
|
||||
t.Fatalf("provider account route binding = %+v, want route-shadow-1/group-1", account)
|
||||
}
|
||||
view, err := store.ProviderAccounts().GetViewByID(ctx, account.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ProviderAccounts().GetViewByID() error = %v", err)
|
||||
}
|
||||
if view.LogicalGroupID != "gpt-shared" || view.RouteName != "Shadow Route" || view.ShadowHostID != hostRow.HostID {
|
||||
t.Fatalf("provider account view route binding = %+v", view)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
@@ -54,12 +55,21 @@ func SyncProviderAccountsFromImportBatch(ctx context.Context, store *DB, batchID
|
||||
|
||||
nowText := time.Now().UTC().Format(time.RFC3339)
|
||||
shadowGroupID := ""
|
||||
shadowHostID := ""
|
||||
for _, resource := range resources {
|
||||
if strings.TrimSpace(resource.ResourceType) == "group" {
|
||||
shadowGroupID = strings.TrimSpace(resource.HostResourceID)
|
||||
break
|
||||
}
|
||||
}
|
||||
hostRow, err := store.Hosts().GetByID(ctx, batch.HostID)
|
||||
if err == nil {
|
||||
shadowHostID = strings.TrimSpace(hostRow.HostID)
|
||||
}
|
||||
matchedRoute, routeErr := resolveProviderAccountRouteBinding(ctx, store, shadowHostID, shadowGroupID)
|
||||
if routeErr != nil && routeErr != sql.ErrNoRows {
|
||||
return routeErr
|
||||
}
|
||||
|
||||
accountResources := make([]ManagedResource, 0)
|
||||
for _, resource := range resources {
|
||||
@@ -82,6 +92,7 @@ func SyncProviderAccountsFromImportBatch(ctx context.Context, store *DB, batchID
|
||||
row := ProviderAccount{
|
||||
HostID: batch.HostID,
|
||||
ProviderID: batch.ProviderID,
|
||||
RouteID: matchedRoute.RouteID,
|
||||
ShadowGroupID: shadowGroupID,
|
||||
HostAccountID: hostAccountID,
|
||||
KeyFingerprint: fallbackString(match.KeyFingerprint, "legacy:"+hostAccountID),
|
||||
@@ -109,6 +120,25 @@ func SyncProviderAccountsFromImportBatch(ctx context.Context, store *DB, batchID
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveProviderAccountRouteBinding(ctx context.Context, store *DB, shadowHostID, shadowGroupID string) (LogicalGroupRoute, error) {
|
||||
if store == nil {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("store is required")
|
||||
}
|
||||
shadowHostID = strings.TrimSpace(shadowHostID)
|
||||
shadowGroupID = strings.TrimSpace(shadowGroupID)
|
||||
if shadowHostID == "" || shadowGroupID == "" {
|
||||
return LogicalGroupRoute{}, sql.ErrNoRows
|
||||
}
|
||||
route, err := store.LogicalGroupRoutes().GetByShadowBinding(ctx, shadowHostID, shadowGroupID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return LogicalGroupRoute{}, err
|
||||
}
|
||||
return LogicalGroupRoute{}, fmt.Errorf("resolve logical route for provider account shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
|
||||
}
|
||||
return route, nil
|
||||
}
|
||||
|
||||
type legacyBatchAccountProjection struct {
|
||||
KeyFingerprint string
|
||||
AccountStatus string
|
||||
|
||||
@@ -171,6 +171,7 @@ portal url: https://sub.tksea.top/portal/
|
||||
portal admin home url: https://sub.tksea.top/portal/admin/
|
||||
logical groups admin url: https://sub.tksea.top/portal/admin/logical-groups.html
|
||||
route health admin url: https://sub.tksea.top/portal/admin/route-health.html
|
||||
accounts admin url: https://sub.tksea.top/portal/admin/accounts.html
|
||||
provider admin url: https://sub.tksea.top/portal/admin/providers.html
|
||||
batch import admin url: https://sub.tksea.top/portal/admin/batch-import.html
|
||||
batch import admin url: https://sub.tksea.top/portal/admin-batch-import.html
|
||||
|
||||
@@ -7,6 +7,7 @@ ADMIN_HTML_FILE="$ROOT_DIR/deploy/tksea-portal/admin-batch-import.html"
|
||||
ADMIN_HOME_FILE="$ROOT_DIR/deploy/tksea-portal/admin/index.html"
|
||||
ADMIN_LOGICAL_GROUPS_FILE="$ROOT_DIR/deploy/tksea-portal/admin/logical-groups.html"
|
||||
ADMIN_ROUTE_HEALTH_FILE="$ROOT_DIR/deploy/tksea-portal/admin/route-health.html"
|
||||
ADMIN_ACCOUNTS_FILE="$ROOT_DIR/deploy/tksea-portal/admin/accounts.html"
|
||||
ADMIN_PROVIDERS_FILE="$ROOT_DIR/deploy/tksea-portal/admin/providers.html"
|
||||
ADMIN_BATCH_FILE="$ROOT_DIR/deploy/tksea-portal/admin/batch-import.html"
|
||||
NGINX_FILE="$ROOT_DIR/deploy/tksea-portal/nginx.sub.tksea.top.conf.example"
|
||||
@@ -30,6 +31,7 @@ assert_contains_file() {
|
||||
[[ -f "$ADMIN_HOME_FILE" ]] || fail "missing $ADMIN_HOME_FILE"
|
||||
[[ -f "$ADMIN_LOGICAL_GROUPS_FILE" ]] || fail "missing $ADMIN_LOGICAL_GROUPS_FILE"
|
||||
[[ -f "$ADMIN_ROUTE_HEALTH_FILE" ]] || fail "missing $ADMIN_ROUTE_HEALTH_FILE"
|
||||
[[ -f "$ADMIN_ACCOUNTS_FILE" ]] || fail "missing $ADMIN_ACCOUNTS_FILE"
|
||||
[[ -f "$ADMIN_PROVIDERS_FILE" ]] || fail "missing $ADMIN_PROVIDERS_FILE"
|
||||
[[ -f "$ADMIN_BATCH_FILE" ]] || fail "missing $ADMIN_BATCH_FILE"
|
||||
[[ -f "$NGINX_FILE" ]] || fail "missing $NGINX_FILE"
|
||||
@@ -58,6 +60,7 @@ assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/logical-groups.html"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/providers.html"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/batch-import.html"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/accounts.html"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "/portal-admin-api"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "matched_account_state"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "account_resolution"
|
||||
@@ -72,17 +75,21 @@ assert_contains_file "$ADMIN_HTML_FILE" "reactivated"
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "Admin Portal"
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/logical-groups.html"
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/route-health.html"
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/accounts.html"
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/providers.html"
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/batch-import.html"
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "/portal-admin-api"
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "浏览器提交到 CRM"
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "逻辑分组 / 路由"
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "Route 健康视图"
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "帐号资产"
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "/accounts"
|
||||
|
||||
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "Logical Group Admin"
|
||||
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/"
|
||||
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/logical-groups.html"
|
||||
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/route-health.html"
|
||||
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/accounts.html"
|
||||
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/providers.html"
|
||||
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/batch-import.html"
|
||||
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/api/admin/session/login"
|
||||
@@ -100,6 +107,7 @@ assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "Route Health Admin"
|
||||
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/"
|
||||
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/logical-groups.html"
|
||||
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/route-health.html"
|
||||
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/accounts.html"
|
||||
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/providers.html"
|
||||
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/batch-import.html"
|
||||
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/api/admin/session/login"
|
||||
@@ -113,10 +121,33 @@ assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "disabled"
|
||||
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" 'credentials: "include"'
|
||||
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal-admin-api"
|
||||
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "Provider Accounts Admin"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/logical-groups.html"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/route-health.html"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/accounts.html"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/providers.html"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/batch-import.html"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/admin/session/login"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/admin/session/logout"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/admin/session"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/provider-accounts"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/enable"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/disable"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/retire"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "logical_group_id"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "route_id"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "shadow_group_id"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "shadow_host_id"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "provider_accounts"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" 'credentials: "include"'
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal-admin-api"
|
||||
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Admin"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "管理员登录"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin/logical-groups.html"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin/route-health.html"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin/accounts.html"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/login"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/logout"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session"
|
||||
@@ -145,6 +176,7 @@ assert_contains_file "$ADMIN_PROVIDERS_FILE" "modelConflicts"
|
||||
assert_contains_file "$ADMIN_BATCH_FILE" "/portal/admin-batch-import.html"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "管理员登录"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/route-health.html"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/accounts.html"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/login"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/logout"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session"
|
||||
@@ -162,6 +194,7 @@ assert_contains_file "$DEPLOY_SCRIPT" "portal url: https://sub.tksea.top/portal/
|
||||
assert_contains_file "$DEPLOY_SCRIPT" "portal admin home url: https://sub.tksea.top/portal/admin/"
|
||||
assert_contains_file "$DEPLOY_SCRIPT" "logical groups admin url: https://sub.tksea.top/portal/admin/logical-groups.html"
|
||||
assert_contains_file "$DEPLOY_SCRIPT" "route health admin url: https://sub.tksea.top/portal/admin/route-health.html"
|
||||
assert_contains_file "$DEPLOY_SCRIPT" "accounts admin url: https://sub.tksea.top/portal/admin/accounts.html"
|
||||
assert_contains_file "$DEPLOY_SCRIPT" "provider admin url: https://sub.tksea.top/portal/admin/providers.html"
|
||||
assert_contains_file "$DEPLOY_SCRIPT" "batch import admin url: https://sub.tksea.top/portal/admin/batch-import.html"
|
||||
assert_contains_file "$DEPLOY_SCRIPT" "batch import admin url: https://sub.tksea.top/portal/admin-batch-import.html"
|
||||
|
||||
Reference in New Issue
Block a user