feat(accounts): add explicit route binding workflow
This commit is contained in:
@@ -343,6 +343,13 @@
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
.binding-box {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.84);
|
||||
}
|
||||
.empty {
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
@@ -483,7 +490,16 @@
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field-grid two" style="margin-top:12px;">
|
||||
<div class="field-grid three" style="margin-top:12px;">
|
||||
<label>
|
||||
binding_state
|
||||
<select id="filter-binding-state">
|
||||
<option value="">全部归属状态</option>
|
||||
<option value="assigned">assigned</option>
|
||||
<option value="unassigned">unassigned</option>
|
||||
<option value="conflict">conflict</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
搜索
|
||||
<input id="filter-query" type="text" placeholder="provider / logical_group / host_account / fingerprint">
|
||||
@@ -520,7 +536,9 @@
|
||||
当前显式使用的动作接口是:
|
||||
<code>/api/provider-accounts/{account_id}/enable</code>、
|
||||
<code>/api/provider-accounts/{account_id}/disable</code>、
|
||||
<code>/api/provider-accounts/{account_id}/retire</code>。
|
||||
<code>/api/provider-accounts/{account_id}/retire</code>、
|
||||
<code>/api/provider-accounts/{account_id}/binding-candidates</code>、
|
||||
<code>/api/provider-accounts/{account_id}/binding</code>。
|
||||
</p>
|
||||
<div class="field-grid" style="margin-top:12px;">
|
||||
<label>
|
||||
@@ -535,6 +553,32 @@
|
||||
</div>
|
||||
<div class="statusbar" id="action-status">请选择左侧一条帐号记录。</div>
|
||||
|
||||
<div class="binding-box">
|
||||
<h2 style="margin:0 0 8px; font-size:20px;">显式整理归属</h2>
|
||||
<p class="panel-desc">
|
||||
当帐号因为同一 <code>shadow_host_id + shadow_group_id</code> 对应多条 route 而显示为
|
||||
<code>conflict</code> 时,直接在这里挑一条 route 绑定;也可以清空 binding,保留为未归属。
|
||||
</p>
|
||||
<div class="field-grid two" style="margin-top:12px;">
|
||||
<label>
|
||||
route 候选
|
||||
<select id="binding-route-select" disabled>
|
||||
<option value="">请先选择帐号</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
当前 binding_state
|
||||
<input id="binding-state-view" type="text" readonly value="-">
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="ghost" id="refresh-binding-btn" type="button" disabled>刷新候选 route</button>
|
||||
<button class="secondary" id="apply-binding-btn" type="button" disabled>绑定到所选 route</button>
|
||||
<button class="ghost" id="clear-binding-btn" type="button" disabled>清空 route 归属</button>
|
||||
</div>
|
||||
<div class="statusbar" id="binding-status">选择左侧一条帐号后,这里会加载 route 候选。</div>
|
||||
</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>
|
||||
@@ -549,6 +593,7 @@
|
||||
const state = {
|
||||
accounts: [],
|
||||
selectedAccountID: 0,
|
||||
bindingCandidates: [],
|
||||
};
|
||||
|
||||
const apiBaseInput = document.getElementById("api-base");
|
||||
@@ -561,6 +606,7 @@
|
||||
const routeFilterInput = document.getElementById("filter-route-id");
|
||||
const shadowGroupFilterInput = document.getElementById("filter-shadow-group-id");
|
||||
const statusFilterInput = document.getElementById("filter-status");
|
||||
const bindingStateFilterInput = document.getElementById("filter-binding-state");
|
||||
const queryFilterInput = document.getElementById("filter-query");
|
||||
const limitFilterInput = document.getElementById("filter-limit");
|
||||
const actionReasonInput = document.getElementById("action-reason");
|
||||
@@ -573,6 +619,9 @@
|
||||
const detailPanel = document.getElementById("detail-panel");
|
||||
const detailGrid = document.getElementById("detail-grid");
|
||||
const detailJSON = document.getElementById("detail-json");
|
||||
const bindingRouteSelect = document.getElementById("binding-route-select");
|
||||
const bindingStateView = document.getElementById("binding-state-view");
|
||||
const bindingStatus = document.getElementById("binding-status");
|
||||
|
||||
const metricApiRoot = document.getElementById("metric-api-root");
|
||||
const metricTotal = document.getElementById("metric-total");
|
||||
@@ -582,6 +631,9 @@
|
||||
const enableButton = document.getElementById("enable-btn");
|
||||
const disableButton = document.getElementById("disable-btn");
|
||||
const retireButton = document.getElementById("retire-btn");
|
||||
const refreshBindingButton = document.getElementById("refresh-binding-btn");
|
||||
const applyBindingButton = document.getElementById("apply-binding-btn");
|
||||
const clearBindingButton = document.getElementById("clear-binding-btn");
|
||||
|
||||
function readConfig() {
|
||||
try {
|
||||
@@ -603,6 +655,7 @@
|
||||
routeID: routeFilterInput.value.trim(),
|
||||
shadowGroupID: shadowGroupFilterInput.value.trim(),
|
||||
accountStatus: statusFilterInput.value,
|
||||
bindingState: bindingStateFilterInput.value,
|
||||
query: queryFilterInput.value.trim(),
|
||||
limit: limitFilterInput.value.trim(),
|
||||
};
|
||||
@@ -621,6 +674,7 @@
|
||||
routeFilterInput.value = config.routeID || "";
|
||||
shadowGroupFilterInput.value = config.shadowGroupID || "";
|
||||
statusFilterInput.value = config.accountStatus || "";
|
||||
bindingStateFilterInput.value = config.bindingState || "";
|
||||
queryFilterInput.value = config.query || "";
|
||||
limitFilterInput.value = config.limit || "200";
|
||||
}
|
||||
@@ -707,6 +761,7 @@
|
||||
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 (bindingStateFilterInput.value) params.set("binding_state", bindingStateFilterInput.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();
|
||||
@@ -718,19 +773,27 @@
|
||||
try {
|
||||
const payload = await requestJSON(buildListQuery(), { headers: authHeaders() });
|
||||
state.accounts = Array.isArray(payload.provider_accounts) ? payload.provider_accounts : [];
|
||||
state.bindingCandidates = [];
|
||||
if (!state.accounts.some((item) => item.id === state.selectedAccountID)) {
|
||||
state.selectedAccountID = state.accounts[0]?.id || 0;
|
||||
}
|
||||
renderMetrics();
|
||||
renderCatalog();
|
||||
renderDetail();
|
||||
if (state.selectedAccountID) {
|
||||
await loadBindingCandidates();
|
||||
} else {
|
||||
renderBindingCandidates();
|
||||
}
|
||||
setStatus(tableStatus, `已加载 ${state.accounts.length} 条帐号资产记录。`, "success");
|
||||
} catch (error) {
|
||||
state.accounts = [];
|
||||
state.selectedAccountID = 0;
|
||||
state.bindingCandidates = [];
|
||||
renderMetrics();
|
||||
renderCatalog();
|
||||
renderDetail();
|
||||
renderBindingCandidates();
|
||||
setStatus(tableStatus, `读取帐号资产失败:${error.message}`, "danger");
|
||||
}
|
||||
}
|
||||
@@ -780,6 +843,7 @@
|
||||
<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>
|
||||
<span class="badge ${statusClass(account.account_status)}">binding: ${escapeHTML(account.binding_state || "unassigned")} / candidates: ${escapeHTML(account.binding_candidate_count || 0)}</span>
|
||||
</div>
|
||||
<div class="meta-list">
|
||||
<span>route_name: <code>${escapeHTML(account.route_name || "-")}</code></span>
|
||||
@@ -791,6 +855,7 @@
|
||||
state.selectedAccountID = account.id;
|
||||
renderCatalog();
|
||||
renderDetail();
|
||||
loadBindingCandidates();
|
||||
});
|
||||
accountsCatalog.appendChild(card);
|
||||
});
|
||||
@@ -804,9 +869,12 @@
|
||||
enableButton.disabled = !hasSelection;
|
||||
disableButton.disabled = !hasSelection;
|
||||
retireButton.disabled = !hasSelection;
|
||||
refreshBindingButton.disabled = !hasSelection;
|
||||
clearBindingButton.disabled = !hasSelection;
|
||||
if (!account) {
|
||||
detailGrid.innerHTML = "";
|
||||
detailJSON.textContent = "{}";
|
||||
bindingStateView.value = "-";
|
||||
setStatus(actionStatus, "请选择左侧一条帐号记录。");
|
||||
return;
|
||||
}
|
||||
@@ -825,6 +893,8 @@
|
||||
["host_account_id", account.host_account_id],
|
||||
["key_fingerprint", account.key_fingerprint],
|
||||
["account_status", account.account_status],
|
||||
["binding_state", account.binding_state || "unassigned"],
|
||||
["binding_candidate_count", String(account.binding_candidate_count || 0)],
|
||||
["last_probe_status", account.last_probe_status || "-"],
|
||||
["last_probe_at", account.last_probe_at || "-"],
|
||||
["disabled_reason", account.disabled_reason || "-"],
|
||||
@@ -837,9 +907,56 @@
|
||||
</div>
|
||||
`).join("");
|
||||
detailJSON.textContent = JSON.stringify(account, null, 2);
|
||||
bindingStateView.value = account.binding_state || "unassigned";
|
||||
setStatus(actionStatus, `当前选中帐号 #${account.id},操作只会修改插件 provider_accounts 库存状态。`);
|
||||
}
|
||||
|
||||
async function loadBindingCandidates() {
|
||||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||||
if (!account) {
|
||||
state.bindingCandidates = [];
|
||||
renderBindingCandidates();
|
||||
return;
|
||||
}
|
||||
setStatus(bindingStatus, `正在读取帐号 #${account.id} 的 route 候选…`);
|
||||
try {
|
||||
const payload = await requestJSON(`/api/provider-accounts/${encodeURIComponent(account.id)}/binding-candidates`, { headers: authHeaders() });
|
||||
state.bindingCandidates = Array.isArray(payload.candidate_routes) ? payload.candidate_routes : [];
|
||||
if (payload.provider_account) {
|
||||
state.accounts = state.accounts.map((item) => item.id === account.id ? payload.provider_account : item);
|
||||
}
|
||||
renderCatalog();
|
||||
renderDetail();
|
||||
renderBindingCandidates();
|
||||
setStatus(bindingStatus, `已加载 ${state.bindingCandidates.length} 条 route 候选。`, "success");
|
||||
} catch (error) {
|
||||
state.bindingCandidates = [];
|
||||
renderBindingCandidates();
|
||||
setStatus(bindingStatus, `读取 route 候选失败:${error.message}`, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function renderBindingCandidates() {
|
||||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||||
const hasSelection = Boolean(account);
|
||||
bindingRouteSelect.disabled = !hasSelection;
|
||||
applyBindingButton.disabled = !hasSelection;
|
||||
if (!hasSelection) {
|
||||
bindingRouteSelect.innerHTML = '<option value="">请先选择帐号</option>';
|
||||
bindingStateView.value = "-";
|
||||
return;
|
||||
}
|
||||
const options = ['<option value="">请选择一个 route</option>'];
|
||||
state.bindingCandidates.forEach((route) => {
|
||||
const selected = route.route_id === account.route_id ? " selected" : "";
|
||||
options.push(`<option value="${escapeHTML(route.route_id)}"${selected}>${escapeHTML(route.route_id)} / ${escapeHTML(route.logical_group_id)} / ${escapeHTML(route.name || "-")}</option>`);
|
||||
});
|
||||
if (!state.bindingCandidates.length) {
|
||||
options.push('<option value="">当前 shadow binding 下没有候选 route</option>');
|
||||
}
|
||||
bindingRouteSelect.innerHTML = options.join("");
|
||||
}
|
||||
|
||||
async function updateAccountStatus(action) {
|
||||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||||
if (!account) {
|
||||
@@ -865,6 +982,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAccountBinding(mode) {
|
||||
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
|
||||
if (!account) {
|
||||
setStatus(bindingStatus, "请先选择一条帐号记录。", "warn");
|
||||
return;
|
||||
}
|
||||
let payload = {};
|
||||
if (mode === "assign") {
|
||||
const routeID = bindingRouteSelect.value.trim();
|
||||
if (!routeID) {
|
||||
setStatus(bindingStatus, "请先选择要绑定的 route。", "warn");
|
||||
return;
|
||||
}
|
||||
payload = { route_id: routeID };
|
||||
} else {
|
||||
payload = { clear: true };
|
||||
}
|
||||
try {
|
||||
const response = await requestJSON(`/api/provider-accounts/${encodeURIComponent(account.id)}/binding`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const updated = response.provider_account;
|
||||
setStatus(bindingStatus, `帐号 #${updated.id} 已更新归属:binding_state=${updated.binding_state || "unassigned"} route=${updated.route_id || "-"}`, "success");
|
||||
await loadAccounts();
|
||||
} catch (error) {
|
||||
setStatus(bindingStatus, `更新帐号归属失败:${error.message}`, "danger");
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
hostFilterInput.value = "";
|
||||
providerFilterInput.value = "";
|
||||
@@ -872,6 +1020,7 @@
|
||||
routeFilterInput.value = "";
|
||||
shadowGroupFilterInput.value = "";
|
||||
statusFilterInput.value = "";
|
||||
bindingStateFilterInput.value = "";
|
||||
queryFilterInput.value = "";
|
||||
limitFilterInput.value = "200";
|
||||
}
|
||||
@@ -922,6 +1071,9 @@
|
||||
enableButton.addEventListener("click", () => updateAccountStatus("enable"));
|
||||
disableButton.addEventListener("click", () => updateAccountStatus("disable"));
|
||||
retireButton.addEventListener("click", () => updateAccountStatus("retire"));
|
||||
refreshBindingButton.addEventListener("click", loadBindingCandidates);
|
||||
applyBindingButton.addEventListener("click", () => updateAccountBinding("assign"));
|
||||
clearBindingButton.addEventListener("click", () => updateAccountBinding("clear"));
|
||||
|
||||
hydrateConfig();
|
||||
refreshSession();
|
||||
|
||||
@@ -26,72 +26,74 @@ import (
|
||||
)
|
||||
|
||||
type ActionSet struct {
|
||||
CreateBatchImportRun func(context.Context, CreateBatchImportRunRequest) (BatchImportRunCreateResponse, error)
|
||||
ListBatchImportRuns func(context.Context, ListBatchImportRunsRequest) (ListBatchImportRunsResponse, error)
|
||||
GetBatchImportRun func(context.Context, string) (batch.RunSummaryProjection, error)
|
||||
ListBatchImportRunItems func(context.Context, ListBatchImportRunItemsRequest) (ListBatchImportRunItemsResponse, error)
|
||||
GetBatchImportRunItem func(context.Context, GetBatchImportRunItemRequest) (batch.ItemDetailProjection, error)
|
||||
CreateLogicalGroup func(context.Context, CreateLogicalGroupRequest) (LogicalGroupInfo, error)
|
||||
ListLogicalGroups func(context.Context) ([]LogicalGroupInfo, error)
|
||||
GetLogicalGroup func(context.Context, string) (LogicalGroupInfo, error)
|
||||
UpdateLogicalGroup func(context.Context, UpdateLogicalGroupRequest) (LogicalGroupInfo, error)
|
||||
DeleteLogicalGroup func(context.Context, string) error
|
||||
CreateLogicalGroupModel func(context.Context, CreateLogicalGroupModelRequest) (LogicalGroupModelInfo, error)
|
||||
ListLogicalGroupModels func(context.Context, string) ([]LogicalGroupModelInfo, error)
|
||||
DeleteLogicalGroupModel func(context.Context, DeleteLogicalGroupModelRequest) error
|
||||
CreateLogicalGroupRoute func(context.Context, CreateLogicalGroupRouteRequest) (LogicalGroupRouteInfo, error)
|
||||
ListLogicalGroupRoutes func(context.Context, string) ([]LogicalGroupRouteInfo, error)
|
||||
UpdateLogicalGroupRoute func(context.Context, UpdateLogicalGroupRouteRequest) (LogicalGroupRouteInfo, error)
|
||||
DeleteLogicalGroupRoute func(context.Context, DeleteLogicalGroupRouteRequest) error
|
||||
CreateLogicalGroupRouteModel func(context.Context, CreateLogicalGroupRouteModelRequest) (LogicalGroupRouteModelInfo, error)
|
||||
ListLogicalGroupRouteModels func(context.Context, ListLogicalGroupRouteModelsRequest) ([]LogicalGroupRouteModelInfo, error)
|
||||
AppendRouteDecisionLog func(context.Context, AppendRouteDecisionLogRequest) (RouteDecisionLogInfo, error)
|
||||
ListRouteDecisionLogs func(context.Context, ListRouteDecisionLogsRequest) ([]RouteDecisionLogInfo, error)
|
||||
AppendRouteFailoverEvent func(context.Context, AppendRouteFailoverEventRequest) (RouteFailoverEventInfo, error)
|
||||
ListRouteFailoverEvents func(context.Context, ListRouteFailoverEventsRequest) ([]RouteFailoverEventInfo, error)
|
||||
AppendRouteStickyAudit func(context.Context, AppendRouteStickyAuditRequest) (RouteStickyAuditInfo, error)
|
||||
ListRouteStickyAudit func(context.Context, ListRouteStickyAuditRequest) ([]RouteStickyAuditInfo, error)
|
||||
ListRouteHealth func(context.Context, ListRouteHealthRequest) ([]RouteHealthInfo, error)
|
||||
ResolveRoute func(context.Context, ResolveRouteRequest) (ResolveRouteInfo, error)
|
||||
RouteChatCompletions func(context.Context, RouteChatCompletionsRequest) (RouteChatCompletionsResult, error)
|
||||
ProxyRouteChatCompletions func(context.Context, ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error)
|
||||
SetStickyBinding func(context.Context, SetStickyBindingRequest) (StickyBindingInfo, error)
|
||||
GetStickyBinding func(context.Context, GetStickyBindingRequest) (StickyBindingInfo, error)
|
||||
SetRouteFailure func(context.Context, SetRouteFailureRequest) (RouteFailureInfo, error)
|
||||
GetRouteFailure func(context.Context, GetRouteFailureRequest) (RouteFailureInfo, error)
|
||||
SetRouteCooldown func(context.Context, SetRouteCooldownRequest) (RouteCooldownInfo, error)
|
||||
GetRouteCooldown func(context.Context, GetRouteCooldownRequest) (RouteCooldownInfo, error)
|
||||
ListProviderAccounts func(context.Context, ListProviderAccountsRequest) ([]ProviderAccountInfo, error)
|
||||
EnableProviderAccount func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)
|
||||
DisableProviderAccount func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)
|
||||
RetireProviderAccount func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)
|
||||
CreateProviderDraft func(context.Context, CreateProviderDraftRequest) (ProviderDraftInfo, error)
|
||||
ListProviderDrafts func(context.Context, ListProviderDraftsRequest) ([]ProviderDraftInfo, error)
|
||||
GetProviderDraft func(context.Context, string) (ProviderDraftInfo, error)
|
||||
UpdateProviderDraft func(context.Context, UpdateProviderDraftRequest) (ProviderDraftInfo, error)
|
||||
DeleteProviderDraft func(context.Context, string) error
|
||||
PublishProviderDraft func(context.Context, PublishProviderDraftRequest) (PublishProviderDraftResult, error)
|
||||
InstallPack func(context.Context, InstallPackRequest) (provision.PackInstallResult, error)
|
||||
BatchDetail func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error)
|
||||
GetProviderStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
|
||||
GetProviderResources func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
|
||||
GetProviderAccessStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
|
||||
ListProviderImportBatches func(context.Context, ProviderQueryRequest) ([]ImportBatchInfo, error)
|
||||
PreviewProvider func(context.Context, PreviewProviderRequest) (provision.PreviewReport, error)
|
||||
ImportProvider func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error)
|
||||
RollbackProvider func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error)
|
||||
RollbackBatch func(context.Context, RollbackBatchRequest) (provision.RollbackReport, error)
|
||||
ReconcileProvider func(context.Context, ReconcileProviderRequest) (reconcile.Result, error)
|
||||
CreateHost func(context.Context, CreateHostRequest) (HostInfo, error)
|
||||
ProbeHost func(context.Context, ProbeHostRequest) (HostInfo, error)
|
||||
ListHosts func(context.Context) ([]HostInfo, error)
|
||||
GetHost func(context.Context, string) (HostInfo, error)
|
||||
DeleteHost func(context.Context, string) error
|
||||
ListPacks func(context.Context) ([]PackInfo, error)
|
||||
GetPack func(context.Context, string) (PackInfo, error)
|
||||
ListPackProviders func(context.Context, string) ([]PackProviderInfo, error)
|
||||
AssignAccessSubscriptions func(context.Context, AssignAccessSubscriptionsRequest) (AssignAccessSubscriptionsResult, error)
|
||||
AccessPreview func(context.Context, AccessPreviewRequest) (AccessPreviewResult, error)
|
||||
CreateBatchImportRun func(context.Context, CreateBatchImportRunRequest) (BatchImportRunCreateResponse, error)
|
||||
ListBatchImportRuns func(context.Context, ListBatchImportRunsRequest) (ListBatchImportRunsResponse, error)
|
||||
GetBatchImportRun func(context.Context, string) (batch.RunSummaryProjection, error)
|
||||
ListBatchImportRunItems func(context.Context, ListBatchImportRunItemsRequest) (ListBatchImportRunItemsResponse, error)
|
||||
GetBatchImportRunItem func(context.Context, GetBatchImportRunItemRequest) (batch.ItemDetailProjection, error)
|
||||
CreateLogicalGroup func(context.Context, CreateLogicalGroupRequest) (LogicalGroupInfo, error)
|
||||
ListLogicalGroups func(context.Context) ([]LogicalGroupInfo, error)
|
||||
GetLogicalGroup func(context.Context, string) (LogicalGroupInfo, error)
|
||||
UpdateLogicalGroup func(context.Context, UpdateLogicalGroupRequest) (LogicalGroupInfo, error)
|
||||
DeleteLogicalGroup func(context.Context, string) error
|
||||
CreateLogicalGroupModel func(context.Context, CreateLogicalGroupModelRequest) (LogicalGroupModelInfo, error)
|
||||
ListLogicalGroupModels func(context.Context, string) ([]LogicalGroupModelInfo, error)
|
||||
DeleteLogicalGroupModel func(context.Context, DeleteLogicalGroupModelRequest) error
|
||||
CreateLogicalGroupRoute func(context.Context, CreateLogicalGroupRouteRequest) (LogicalGroupRouteInfo, error)
|
||||
ListLogicalGroupRoutes func(context.Context, string) ([]LogicalGroupRouteInfo, error)
|
||||
UpdateLogicalGroupRoute func(context.Context, UpdateLogicalGroupRouteRequest) (LogicalGroupRouteInfo, error)
|
||||
DeleteLogicalGroupRoute func(context.Context, DeleteLogicalGroupRouteRequest) error
|
||||
CreateLogicalGroupRouteModel func(context.Context, CreateLogicalGroupRouteModelRequest) (LogicalGroupRouteModelInfo, error)
|
||||
ListLogicalGroupRouteModels func(context.Context, ListLogicalGroupRouteModelsRequest) ([]LogicalGroupRouteModelInfo, error)
|
||||
AppendRouteDecisionLog func(context.Context, AppendRouteDecisionLogRequest) (RouteDecisionLogInfo, error)
|
||||
ListRouteDecisionLogs func(context.Context, ListRouteDecisionLogsRequest) ([]RouteDecisionLogInfo, error)
|
||||
AppendRouteFailoverEvent func(context.Context, AppendRouteFailoverEventRequest) (RouteFailoverEventInfo, error)
|
||||
ListRouteFailoverEvents func(context.Context, ListRouteFailoverEventsRequest) ([]RouteFailoverEventInfo, error)
|
||||
AppendRouteStickyAudit func(context.Context, AppendRouteStickyAuditRequest) (RouteStickyAuditInfo, error)
|
||||
ListRouteStickyAudit func(context.Context, ListRouteStickyAuditRequest) ([]RouteStickyAuditInfo, error)
|
||||
ListRouteHealth func(context.Context, ListRouteHealthRequest) ([]RouteHealthInfo, error)
|
||||
ResolveRoute func(context.Context, ResolveRouteRequest) (ResolveRouteInfo, error)
|
||||
RouteChatCompletions func(context.Context, RouteChatCompletionsRequest) (RouteChatCompletionsResult, error)
|
||||
ProxyRouteChatCompletions func(context.Context, ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error)
|
||||
SetStickyBinding func(context.Context, SetStickyBindingRequest) (StickyBindingInfo, error)
|
||||
GetStickyBinding func(context.Context, GetStickyBindingRequest) (StickyBindingInfo, error)
|
||||
SetRouteFailure func(context.Context, SetRouteFailureRequest) (RouteFailureInfo, error)
|
||||
GetRouteFailure func(context.Context, GetRouteFailureRequest) (RouteFailureInfo, error)
|
||||
SetRouteCooldown func(context.Context, SetRouteCooldownRequest) (RouteCooldownInfo, error)
|
||||
GetRouteCooldown func(context.Context, GetRouteCooldownRequest) (RouteCooldownInfo, error)
|
||||
ListProviderAccounts func(context.Context, ListProviderAccountsRequest) ([]ProviderAccountInfo, error)
|
||||
GetProviderAccountBindingCandidates func(context.Context, GetProviderAccountBindingCandidatesRequest) (ProviderAccountBindingCandidatesResult, error)
|
||||
UpdateProviderAccountBinding func(context.Context, UpdateProviderAccountBindingRequest) (ProviderAccountInfo, error)
|
||||
EnableProviderAccount func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)
|
||||
DisableProviderAccount func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)
|
||||
RetireProviderAccount func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)
|
||||
CreateProviderDraft func(context.Context, CreateProviderDraftRequest) (ProviderDraftInfo, error)
|
||||
ListProviderDrafts func(context.Context, ListProviderDraftsRequest) ([]ProviderDraftInfo, error)
|
||||
GetProviderDraft func(context.Context, string) (ProviderDraftInfo, error)
|
||||
UpdateProviderDraft func(context.Context, UpdateProviderDraftRequest) (ProviderDraftInfo, error)
|
||||
DeleteProviderDraft func(context.Context, string) error
|
||||
PublishProviderDraft func(context.Context, PublishProviderDraftRequest) (PublishProviderDraftResult, error)
|
||||
InstallPack func(context.Context, InstallPackRequest) (provision.PackInstallResult, error)
|
||||
BatchDetail func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error)
|
||||
GetProviderStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
|
||||
GetProviderResources func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
|
||||
GetProviderAccessStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
|
||||
ListProviderImportBatches func(context.Context, ProviderQueryRequest) ([]ImportBatchInfo, error)
|
||||
PreviewProvider func(context.Context, PreviewProviderRequest) (provision.PreviewReport, error)
|
||||
ImportProvider func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error)
|
||||
RollbackProvider func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error)
|
||||
RollbackBatch func(context.Context, RollbackBatchRequest) (provision.RollbackReport, error)
|
||||
ReconcileProvider func(context.Context, ReconcileProviderRequest) (reconcile.Result, error)
|
||||
CreateHost func(context.Context, CreateHostRequest) (HostInfo, error)
|
||||
ProbeHost func(context.Context, ProbeHostRequest) (HostInfo, error)
|
||||
ListHosts func(context.Context) ([]HostInfo, error)
|
||||
GetHost func(context.Context, string) (HostInfo, error)
|
||||
DeleteHost func(context.Context, string) error
|
||||
ListPacks func(context.Context) ([]PackInfo, error)
|
||||
GetPack func(context.Context, string) (PackInfo, error)
|
||||
ListPackProviders func(context.Context, string) ([]PackProviderInfo, error)
|
||||
AssignAccessSubscriptions func(context.Context, AssignAccessSubscriptionsRequest) (AssignAccessSubscriptionsResult, error)
|
||||
AccessPreview func(context.Context, AccessPreviewRequest) (AccessPreviewResult, error)
|
||||
}
|
||||
|
||||
const maxJSONBodyBytes int64 = 1 << 20
|
||||
@@ -439,6 +441,12 @@ func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet) http.Ha
|
||||
mux.Handle("GET /api/provider-accounts", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleListProviderAccounts(w, r, actions.ListProviderAccounts)
|
||||
})))
|
||||
mux.Handle("GET /api/provider-accounts/{accountID}/binding-candidates", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleGetProviderAccountBindingCandidates(w, r, actions.GetProviderAccountBindingCandidates)
|
||||
})))
|
||||
mux.Handle("POST /api/provider-accounts/{accountID}/binding", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleUpdateProviderAccountBinding(w, r, actions.UpdateProviderAccountBinding)
|
||||
})))
|
||||
mux.Handle("POST /api/provider-accounts/{accountID}/enable", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleEnableProviderAccount(w, r, actions.EnableProviderAccount)
|
||||
})))
|
||||
@@ -1261,45 +1269,47 @@ func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRu
|
||||
proxyRouteChatCompletions := buildProxyRouteChatCompletionsAction(sqliteDSN, resolveRoute, routeLogWriter)
|
||||
routeChatCompletions := buildRouteChatCompletionsAction(proxyRouteChatCompletions)
|
||||
return ActionSet{
|
||||
CreateBatchImportRun: buildCreateBatchImportRunAction(sqliteDSN),
|
||||
ListBatchImportRuns: buildListBatchImportRunsAction(sqliteDSN),
|
||||
GetBatchImportRun: buildGetBatchImportRunAction(sqliteDSN),
|
||||
ListBatchImportRunItems: buildListBatchImportRunItemsAction(sqliteDSN),
|
||||
GetBatchImportRunItem: buildGetBatchImportRunItemAction(sqliteDSN),
|
||||
CreateLogicalGroup: buildCreateLogicalGroupAction(sqliteDSN),
|
||||
ListLogicalGroups: buildListLogicalGroupsAction(sqliteDSN),
|
||||
GetLogicalGroup: buildGetLogicalGroupAction(sqliteDSN),
|
||||
UpdateLogicalGroup: buildUpdateLogicalGroupAction(sqliteDSN),
|
||||
DeleteLogicalGroup: buildDeleteLogicalGroupAction(sqliteDSN),
|
||||
CreateLogicalGroupModel: buildCreateLogicalGroupModelAction(sqliteDSN),
|
||||
ListLogicalGroupModels: buildListLogicalGroupModelsAction(sqliteDSN),
|
||||
DeleteLogicalGroupModel: buildDeleteLogicalGroupModelAction(sqliteDSN),
|
||||
CreateLogicalGroupRoute: buildCreateLogicalGroupRouteAction(sqliteDSN),
|
||||
ListLogicalGroupRoutes: buildListLogicalGroupRoutesAction(sqliteDSN),
|
||||
UpdateLogicalGroupRoute: buildUpdateLogicalGroupRouteAction(sqliteDSN),
|
||||
DeleteLogicalGroupRoute: buildDeleteLogicalGroupRouteAction(sqliteDSN),
|
||||
CreateLogicalGroupRouteModel: buildCreateLogicalGroupRouteModelAction(sqliteDSN),
|
||||
ListLogicalGroupRouteModels: buildListLogicalGroupRouteModelsAction(sqliteDSN),
|
||||
AppendRouteDecisionLog: buildAppendRouteDecisionLogAction(routeLogWriter, sqliteDSN),
|
||||
ListRouteDecisionLogs: buildListRouteDecisionLogsAction(sqliteDSN),
|
||||
AppendRouteFailoverEvent: buildAppendRouteFailoverEventAction(routeLogWriter, sqliteDSN),
|
||||
ListRouteFailoverEvents: buildListRouteFailoverEventsAction(sqliteDSN),
|
||||
AppendRouteStickyAudit: buildAppendRouteStickyAuditAction(routeLogWriter, sqliteDSN),
|
||||
ListRouteStickyAudit: buildListRouteStickyAuditAction(sqliteDSN),
|
||||
ListRouteHealth: buildListRouteHealthAction(sqliteDSN, stickyRuntime),
|
||||
ResolveRoute: resolveRoute,
|
||||
RouteChatCompletions: routeChatCompletions,
|
||||
ProxyRouteChatCompletions: proxyRouteChatCompletions,
|
||||
SetStickyBinding: buildSetStickyBindingAction(stickyRuntime),
|
||||
GetStickyBinding: buildGetStickyBindingAction(stickyRuntime),
|
||||
SetRouteFailure: buildSetRouteFailureAction(stickyRuntime),
|
||||
GetRouteFailure: buildGetRouteFailureAction(stickyRuntime),
|
||||
SetRouteCooldown: buildSetRouteCooldownAction(stickyRuntime),
|
||||
GetRouteCooldown: buildGetRouteCooldownAction(stickyRuntime),
|
||||
ListProviderAccounts: buildListProviderAccountsAction(sqliteDSN),
|
||||
EnableProviderAccount: buildUpdateProviderAccountStatusAction(sqliteDSN, sqlite.ProviderAccountStatusActive),
|
||||
DisableProviderAccount: buildUpdateProviderAccountStatusAction(sqliteDSN, sqlite.ProviderAccountStatusDisabled),
|
||||
RetireProviderAccount: buildUpdateProviderAccountStatusAction(sqliteDSN, sqlite.ProviderAccountStatusDeprecated),
|
||||
CreateBatchImportRun: buildCreateBatchImportRunAction(sqliteDSN),
|
||||
ListBatchImportRuns: buildListBatchImportRunsAction(sqliteDSN),
|
||||
GetBatchImportRun: buildGetBatchImportRunAction(sqliteDSN),
|
||||
ListBatchImportRunItems: buildListBatchImportRunItemsAction(sqliteDSN),
|
||||
GetBatchImportRunItem: buildGetBatchImportRunItemAction(sqliteDSN),
|
||||
CreateLogicalGroup: buildCreateLogicalGroupAction(sqliteDSN),
|
||||
ListLogicalGroups: buildListLogicalGroupsAction(sqliteDSN),
|
||||
GetLogicalGroup: buildGetLogicalGroupAction(sqliteDSN),
|
||||
UpdateLogicalGroup: buildUpdateLogicalGroupAction(sqliteDSN),
|
||||
DeleteLogicalGroup: buildDeleteLogicalGroupAction(sqliteDSN),
|
||||
CreateLogicalGroupModel: buildCreateLogicalGroupModelAction(sqliteDSN),
|
||||
ListLogicalGroupModels: buildListLogicalGroupModelsAction(sqliteDSN),
|
||||
DeleteLogicalGroupModel: buildDeleteLogicalGroupModelAction(sqliteDSN),
|
||||
CreateLogicalGroupRoute: buildCreateLogicalGroupRouteAction(sqliteDSN),
|
||||
ListLogicalGroupRoutes: buildListLogicalGroupRoutesAction(sqliteDSN),
|
||||
UpdateLogicalGroupRoute: buildUpdateLogicalGroupRouteAction(sqliteDSN),
|
||||
DeleteLogicalGroupRoute: buildDeleteLogicalGroupRouteAction(sqliteDSN),
|
||||
CreateLogicalGroupRouteModel: buildCreateLogicalGroupRouteModelAction(sqliteDSN),
|
||||
ListLogicalGroupRouteModels: buildListLogicalGroupRouteModelsAction(sqliteDSN),
|
||||
AppendRouteDecisionLog: buildAppendRouteDecisionLogAction(routeLogWriter, sqliteDSN),
|
||||
ListRouteDecisionLogs: buildListRouteDecisionLogsAction(sqliteDSN),
|
||||
AppendRouteFailoverEvent: buildAppendRouteFailoverEventAction(routeLogWriter, sqliteDSN),
|
||||
ListRouteFailoverEvents: buildListRouteFailoverEventsAction(sqliteDSN),
|
||||
AppendRouteStickyAudit: buildAppendRouteStickyAuditAction(routeLogWriter, sqliteDSN),
|
||||
ListRouteStickyAudit: buildListRouteStickyAuditAction(sqliteDSN),
|
||||
ListRouteHealth: buildListRouteHealthAction(sqliteDSN, stickyRuntime),
|
||||
ResolveRoute: resolveRoute,
|
||||
RouteChatCompletions: routeChatCompletions,
|
||||
ProxyRouteChatCompletions: proxyRouteChatCompletions,
|
||||
SetStickyBinding: buildSetStickyBindingAction(stickyRuntime),
|
||||
GetStickyBinding: buildGetStickyBindingAction(stickyRuntime),
|
||||
SetRouteFailure: buildSetRouteFailureAction(stickyRuntime),
|
||||
GetRouteFailure: buildGetRouteFailureAction(stickyRuntime),
|
||||
SetRouteCooldown: buildSetRouteCooldownAction(stickyRuntime),
|
||||
GetRouteCooldown: buildGetRouteCooldownAction(stickyRuntime),
|
||||
ListProviderAccounts: buildListProviderAccountsAction(sqliteDSN),
|
||||
GetProviderAccountBindingCandidates: buildGetProviderAccountBindingCandidatesAction(sqliteDSN),
|
||||
UpdateProviderAccountBinding: buildUpdateProviderAccountBindingAction(sqliteDSN),
|
||||
EnableProviderAccount: buildUpdateProviderAccountStatusAction(sqliteDSN, sqlite.ProviderAccountStatusActive),
|
||||
DisableProviderAccount: buildUpdateProviderAccountStatusAction(sqliteDSN, sqlite.ProviderAccountStatusDisabled),
|
||||
RetireProviderAccount: buildUpdateProviderAccountStatusAction(sqliteDSN, sqlite.ProviderAccountStatusDeprecated),
|
||||
CreateProviderDraft: func(ctx context.Context, req CreateProviderDraftRequest) (ProviderDraftInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
|
||||
@@ -18,6 +18,7 @@ type ListProviderAccountsRequest struct {
|
||||
RouteID string
|
||||
ShadowGroupID string
|
||||
AccountStatus string
|
||||
BindingState string
|
||||
Query string
|
||||
Limit int
|
||||
}
|
||||
@@ -28,27 +29,44 @@ type UpdateProviderAccountStatusRequest struct {
|
||||
DisabledReason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type GetProviderAccountBindingCandidatesRequest struct {
|
||||
AccountID int64
|
||||
}
|
||||
|
||||
type UpdateProviderAccountBindingRequest struct {
|
||||
AccountID int64 `json:"-"`
|
||||
RouteID string `json:"route_id,omitempty"`
|
||||
Clear bool `json:"clear,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderAccountBindingCandidatesResult struct {
|
||||
ProviderAccount ProviderAccountInfo `json:"provider_account"`
|
||||
CandidateRoutes []LogicalGroupRouteInfo `json:"candidate_routes"`
|
||||
}
|
||||
|
||||
type ProviderAccountInfo struct {
|
||||
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"`
|
||||
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"`
|
||||
BindingState string `json:"binding_state,omitempty"`
|
||||
BindingCandidateCount int `json:"binding_candidate_count,omitempty"`
|
||||
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)) {
|
||||
@@ -63,6 +81,7 @@ func handleListProviderAccounts(w http.ResponseWriter, r *http.Request, fn func(
|
||||
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")),
|
||||
BindingState: strings.TrimSpace(r.URL.Query().Get("binding_state")),
|
||||
Query: strings.TrimSpace(r.URL.Query().Get("q")),
|
||||
Limit: parsePositiveInt(r.URL.Query().Get("limit")),
|
||||
})
|
||||
@@ -76,6 +95,24 @@ func handleListProviderAccounts(w http.ResponseWriter, r *http.Request, fn func(
|
||||
writeJSON(w, http.StatusOK, map[string]any{"provider_accounts": accounts})
|
||||
}
|
||||
|
||||
func handleGetProviderAccountBindingCandidates(w http.ResponseWriter, r *http.Request, fn func(context.Context, GetProviderAccountBindingCandidatesRequest) (ProviderAccountBindingCandidatesResult, error)) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "get-provider-account-binding-candidates action is not configured"})
|
||||
return
|
||||
}
|
||||
accountID, parseErr := parseProviderAccountID(r.PathValue("accountID"))
|
||||
if parseErr != nil {
|
||||
writeHTTPError(w, parseErr)
|
||||
return
|
||||
}
|
||||
result, actionErr := fn(r.Context(), GetProviderAccountBindingCandidatesRequest{AccountID: accountID})
|
||||
if actionErr != nil {
|
||||
writeHTTPError(w, classifyError(actionErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func handleEnableProviderAccount(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error)) {
|
||||
handleUpdateProviderAccountStatus(w, r, fn, sqlite.ProviderAccountStatusActive)
|
||||
}
|
||||
@@ -88,15 +125,40 @@ func handleRetireProviderAccount(w http.ResponseWriter, r *http.Request, fn func
|
||||
handleUpdateProviderAccountStatus(w, r, fn, sqlite.ProviderAccountStatusDeprecated)
|
||||
}
|
||||
|
||||
func handleUpdateProviderAccountBinding(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderAccountBindingRequest) (ProviderAccountInfo, error)) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "update-provider-account-binding action is not configured"})
|
||||
return
|
||||
}
|
||||
accountID, err := parseProviderAccountID(r.PathValue("accountID"))
|
||||
if err != nil {
|
||||
writeHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
var req UpdateProviderAccountBindingRequest
|
||||
if r.ContentLength != 0 {
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
req.AccountID = accountID
|
||||
account, actionErr := fn(r.Context(), req)
|
||||
if actionErr != nil {
|
||||
writeHTTPError(w, classifyError(actionErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"provider_account": account})
|
||||
}
|
||||
|
||||
func handleUpdateProviderAccountStatus(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error), accountStatus string) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "update-provider-account-status action is not configured"})
|
||||
return
|
||||
}
|
||||
rawID := strings.TrimSpace(r.PathValue("accountID"))
|
||||
accountID, err := strconv.ParseInt(rawID, 10, 64)
|
||||
if err != nil || accountID <= 0 {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "invalid_request", Message: "account_id must be a positive integer"})
|
||||
accountID, err := parseProviderAccountID(r.PathValue("accountID"))
|
||||
if err != nil {
|
||||
writeHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
req := UpdateProviderAccountStatusRequest{
|
||||
@@ -111,14 +173,22 @@ func handleUpdateProviderAccountStatus(w http.ResponseWriter, r *http.Request, f
|
||||
req.AccountID = accountID
|
||||
req.AccountStatus = accountStatus
|
||||
}
|
||||
account, err := fn(r.Context(), req)
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
account, actionErr := fn(r.Context(), req)
|
||||
if actionErr != nil {
|
||||
writeHTTPError(w, classifyError(actionErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"provider_account": account})
|
||||
}
|
||||
|
||||
func parseProviderAccountID(rawID string) (int64, *httpError) {
|
||||
accountID, err := strconv.ParseInt(strings.TrimSpace(rawID), 10, 64)
|
||||
if err != nil || accountID <= 0 {
|
||||
return 0, &httpError{StatusCode: http.StatusBadRequest, Code: "invalid_request", Message: "account_id must be a positive integer"}
|
||||
}
|
||||
return accountID, nil
|
||||
}
|
||||
|
||||
func buildListProviderAccountsAction(sqliteDSN string) func(context.Context, ListProviderAccountsRequest) ([]ProviderAccountInfo, error) {
|
||||
return func(ctx context.Context, req ListProviderAccountsRequest) ([]ProviderAccountInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
@@ -137,6 +207,7 @@ func buildListProviderAccountsAction(sqliteDSN string) func(context.Context, Lis
|
||||
RouteID: req.RouteID,
|
||||
ShadowGroupID: req.ShadowGroupID,
|
||||
AccountStatus: req.AccountStatus,
|
||||
BindingState: req.BindingState,
|
||||
Query: req.Query,
|
||||
Limit: req.Limit,
|
||||
})
|
||||
@@ -151,6 +222,38 @@ func buildListProviderAccountsAction(sqliteDSN string) func(context.Context, Lis
|
||||
}
|
||||
}
|
||||
|
||||
func buildGetProviderAccountBindingCandidatesAction(sqliteDSN string) func(context.Context, GetProviderAccountBindingCandidatesRequest) (ProviderAccountBindingCandidatesResult, error) {
|
||||
return func(ctx context.Context, req GetProviderAccountBindingCandidatesRequest) (ProviderAccountBindingCandidatesResult, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return ProviderAccountBindingCandidatesResult{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
account, err := store.ProviderAccounts().GetViewByID(ctx, req.AccountID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ProviderAccountBindingCandidatesResult{}, fmt.Errorf("provider account %d not found", req.AccountID)
|
||||
}
|
||||
return ProviderAccountBindingCandidatesResult{}, err
|
||||
}
|
||||
candidates := make([]LogicalGroupRouteInfo, 0)
|
||||
if strings.TrimSpace(account.HostID) != "" && strings.TrimSpace(account.ShadowGroupID) != "" {
|
||||
routes, routeErr := store.LogicalGroupRoutes().ListByShadowBinding(ctx, account.HostID, account.ShadowGroupID)
|
||||
if routeErr != nil {
|
||||
return ProviderAccountBindingCandidatesResult{}, routeErr
|
||||
}
|
||||
for _, route := range routes {
|
||||
candidates = append(candidates, logicalGroupRouteRowToInfo(route, nil))
|
||||
}
|
||||
}
|
||||
return ProviderAccountBindingCandidatesResult{
|
||||
ProviderAccount: providerAccountViewToInfo(account),
|
||||
CandidateRoutes: candidates,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildUpdateProviderAccountStatusAction(sqliteDSN, accountStatus string) func(context.Context, UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error) {
|
||||
return func(ctx context.Context, req UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
@@ -173,27 +276,91 @@ func buildUpdateProviderAccountStatusAction(sqliteDSN, accountStatus string) fun
|
||||
}
|
||||
}
|
||||
|
||||
func providerAccountViewToInfo(row sqlite.ProviderAccountView) ProviderAccountInfo {
|
||||
return ProviderAccountInfo{
|
||||
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,
|
||||
func buildUpdateProviderAccountBindingAction(sqliteDSN string) func(context.Context, UpdateProviderAccountBindingRequest) (ProviderAccountInfo, error) {
|
||||
return func(ctx context.Context, req UpdateProviderAccountBindingRequest) (ProviderAccountInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return ProviderAccountInfo{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
account, err := store.ProviderAccounts().GetByID(ctx, req.AccountID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ProviderAccountInfo{}, fmt.Errorf("provider account %d not found", req.AccountID)
|
||||
}
|
||||
return ProviderAccountInfo{}, err
|
||||
}
|
||||
|
||||
if req.Clear {
|
||||
if err := store.ProviderAccounts().UpdateBindingByID(ctx, account.ID, "", account.ShadowGroupID); err != nil {
|
||||
return ProviderAccountInfo{}, err
|
||||
}
|
||||
} else {
|
||||
routeID := strings.TrimSpace(req.RouteID)
|
||||
if routeID == "" {
|
||||
return ProviderAccountInfo{}, fmt.Errorf("route_id is required")
|
||||
}
|
||||
route, routeErr := store.LogicalGroupRoutes().GetByRouteID(ctx, routeID)
|
||||
if routeErr != nil {
|
||||
if routeErr == sql.ErrNoRows {
|
||||
return ProviderAccountInfo{}, fmt.Errorf("logical group route %q not found", routeID)
|
||||
}
|
||||
return ProviderAccountInfo{}, routeErr
|
||||
}
|
||||
if strings.TrimSpace(route.ShadowHostID) != strings.TrimSpace(accountHostIDForBinding(store, ctx, account)) {
|
||||
return ProviderAccountInfo{}, fmt.Errorf("route %q shadow_host_id does not match provider account host", routeID)
|
||||
}
|
||||
if strings.TrimSpace(account.ShadowGroupID) != "" && strings.TrimSpace(route.ShadowGroupID) != strings.TrimSpace(account.ShadowGroupID) {
|
||||
return ProviderAccountInfo{}, fmt.Errorf("route %q shadow_group_id does not match provider account shadow_group_id", routeID)
|
||||
}
|
||||
if err := store.ProviderAccounts().UpdateBindingByID(ctx, account.ID, route.RouteID, route.ShadowGroupID); err != nil {
|
||||
return ProviderAccountInfo{}, err
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := store.ProviderAccounts().GetViewByID(ctx, account.ID)
|
||||
if err != nil {
|
||||
return ProviderAccountInfo{}, err
|
||||
}
|
||||
return providerAccountViewToInfo(updated), nil
|
||||
}
|
||||
}
|
||||
|
||||
func accountHostIDForBinding(store *sqlite.DB, ctx context.Context, account sqlite.ProviderAccount) string {
|
||||
if store == nil {
|
||||
return ""
|
||||
}
|
||||
hostRow, err := store.Hosts().GetByID(ctx, account.HostID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(hostRow.HostID)
|
||||
}
|
||||
|
||||
func providerAccountViewToInfo(row sqlite.ProviderAccountView) ProviderAccountInfo {
|
||||
return ProviderAccountInfo{
|
||||
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,
|
||||
BindingState: row.BindingState,
|
||||
BindingCandidateCount: row.BindingCandidateCount,
|
||||
LastProbeStatus: row.LastProbeStatus,
|
||||
LastProbeAt: row.LastProbeAt,
|
||||
DisabledReason: row.DisabledReason,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,27 +21,32 @@ func TestAPIListProviderAccountsReturnsRows(t *testing.T) {
|
||||
if req.AccountStatus != "disabled" {
|
||||
t.Fatalf("AccountStatus = %q, want disabled", req.AccountStatus)
|
||||
}
|
||||
if req.BindingState != "conflict" {
|
||||
t.Fatalf("BindingState = %q, want conflict", req.BindingState)
|
||||
}
|
||||
return []ProviderAccountInfo{{
|
||||
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",
|
||||
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",
|
||||
BindingState: "conflict",
|
||||
BindingCandidateCount: 2,
|
||||
DisabledReason: "manual_disable",
|
||||
UpstreamBaseURLHint: "https://api.deepseek.com",
|
||||
}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, "GET", "/api/provider-accounts?provider_id=deepseek-official&logical_group_id=gpt-shared&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&binding_state=conflict", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, 200)
|
||||
var payload map[string][]ProviderAccountInfo
|
||||
@@ -57,6 +62,53 @@ func TestAPIListProviderAccountsReturnsRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGetProviderAccountBindingCandidatesUsesPathID(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
GetProviderAccountBindingCandidates: func(_ context.Context, req GetProviderAccountBindingCandidatesRequest) (ProviderAccountBindingCandidatesResult, error) {
|
||||
if req.AccountID != 7 {
|
||||
t.Fatalf("AccountID = %d, want 7", req.AccountID)
|
||||
}
|
||||
return ProviderAccountBindingCandidatesResult{
|
||||
ProviderAccount: ProviderAccountInfo{ID: 7, BindingState: "conflict"},
|
||||
CandidateRoutes: []LogicalGroupRouteInfo{{RouteID: "route-a"}, {RouteID: "route-b"}},
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, "GET", "/api/provider-accounts/7/binding-candidates", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, 200)
|
||||
var payload struct {
|
||||
ProviderAccount ProviderAccountInfo `json:"provider_account"`
|
||||
CandidateRoutes []LogicalGroupRouteInfo `json:"candidate_routes"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if payload.ProviderAccount.ID != 7 || len(payload.CandidateRoutes) != 2 || payload.CandidateRoutes[0].RouteID != "route-a" {
|
||||
t.Fatalf("binding candidates payload = %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIUpdateProviderAccountBindingUsesPathID(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
UpdateProviderAccountBinding: func(_ context.Context, req UpdateProviderAccountBindingRequest) (ProviderAccountInfo, error) {
|
||||
if req.AccountID != 42 {
|
||||
t.Fatalf("AccountID = %d, want 42", req.AccountID)
|
||||
}
|
||||
if req.RouteID != "route-9" || req.Clear {
|
||||
t.Fatalf("request = %+v, want route-9 clear=false", req)
|
||||
}
|
||||
return ProviderAccountInfo{ID: req.AccountID, RouteID: req.RouteID, BindingState: "assigned"}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, "POST", "/api/provider-accounts/42/binding", map[string]any{"route_id": "route-9"}, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, 200)
|
||||
assertJSONContains(t, response.Body().Bytes(), "provider_account.route_id", "route-9")
|
||||
}
|
||||
|
||||
func TestAPIDisableProviderAccountUsesPathID(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
DisableProviderAccount: func(_ context.Context, req UpdateProviderAccountStatusRequest) (ProviderAccountInfo, error) {
|
||||
@@ -172,6 +224,42 @@ func TestNewActionSetProviderAccountListAndStatusFlow(t *testing.T) {
|
||||
if listed[0].LogicalGroupID != "gpt-shared" || listed[0].RouteName != "Primary Route" || listed[0].ShadowHostID != "remote43" {
|
||||
t.Fatalf("ListProviderAccounts() relationship fields = %+v", listed[0])
|
||||
}
|
||||
if listed[0].BindingState != sqlite.ProviderAccountBindingStateAssigned || listed[0].BindingCandidateCount != 1 {
|
||||
t.Fatalf("ListProviderAccounts() binding view = %+v", listed[0])
|
||||
}
|
||||
|
||||
candidates, err := actions.GetProviderAccountBindingCandidates(ctx, GetProviderAccountBindingCandidatesRequest{AccountID: providerAccountID})
|
||||
if err != nil {
|
||||
t.Fatalf("GetProviderAccountBindingCandidates() error = %v", err)
|
||||
}
|
||||
if len(candidates.CandidateRoutes) != 1 || candidates.CandidateRoutes[0].RouteID != "route-1" {
|
||||
t.Fatalf("GetProviderAccountBindingCandidates() = %+v", candidates)
|
||||
}
|
||||
|
||||
if _, err := store.LogicalGroupRoutes().Create(ctx, sqlite.LogicalGroupRoute{
|
||||
RouteID: "route-2",
|
||||
LogicalGroupID: "gpt-shared",
|
||||
Name: "Fallback Route",
|
||||
Status: "active",
|
||||
Priority: 20,
|
||||
Weight: 100,
|
||||
ShadowGroupID: "group-9",
|
||||
ShadowHostID: "remote43",
|
||||
UpstreamBaseURLHint: "https://api.backup.example.com",
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroupRoutes().Create(route-2) error = %v", err)
|
||||
}
|
||||
|
||||
updatedBinding, err := actions.UpdateProviderAccountBinding(ctx, UpdateProviderAccountBindingRequest{
|
||||
AccountID: providerAccountID,
|
||||
RouteID: "route-2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateProviderAccountBinding() error = %v", err)
|
||||
}
|
||||
if updatedBinding.RouteID != "route-2" || updatedBinding.BindingState != sqlite.ProviderAccountBindingStateAssigned {
|
||||
t.Fatalf("UpdateProviderAccountBinding() = %+v", updatedBinding)
|
||||
}
|
||||
|
||||
disabled, err := actions.DisableProviderAccount(ctx, UpdateProviderAccountStatusRequest{
|
||||
AccountID: providerAccountID,
|
||||
|
||||
@@ -209,13 +209,28 @@ func (r *LogicalGroupRoutesRepo) DeleteByRouteID(ctx context.Context, routeID st
|
||||
}
|
||||
|
||||
func (r *LogicalGroupRoutesRepo) GetByShadowBinding(ctx context.Context, shadowHostID, shadowGroupID string) (LogicalGroupRoute, error) {
|
||||
routes, err := r.ListByShadowBinding(ctx, shadowHostID, shadowGroupID)
|
||||
if err != nil {
|
||||
return LogicalGroupRoute{}, 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", strings.TrimSpace(shadowHostID), strings.TrimSpace(shadowGroupID))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *LogicalGroupRoutesRepo) ListByShadowBinding(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")
|
||||
return nil, fmt.Errorf("shadow_host_id is required")
|
||||
}
|
||||
if shadowGroupID == "" {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("shadow_group_id is required")
|
||||
return nil, fmt.Errorf("shadow_group_id is required")
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(
|
||||
@@ -223,17 +238,16 @@ func (r *LogicalGroupRoutesRepo) GetByShadowBinding(ctx context.Context, shadowH
|
||||
`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`,
|
||||
ORDER BY priority ASC, id ASC`,
|
||||
shadowHostID,
|
||||
shadowGroupID,
|
||||
)
|
||||
if err != nil {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("get logical group route by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
|
||||
return nil, fmt.Errorf("list logical group routes by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
routes := make([]LogicalGroupRoute, 0, 2)
|
||||
routes := make([]LogicalGroupRoute, 0, 4)
|
||||
for rows.Next() {
|
||||
var route LogicalGroupRoute
|
||||
if err := rows.Scan(
|
||||
@@ -251,21 +265,14 @@ func (r *LogicalGroupRoutesRepo) GetByShadowBinding(ctx context.Context, shadowH
|
||||
&route.CreatedAt,
|
||||
&route.UpdatedAt,
|
||||
); err != nil {
|
||||
return LogicalGroupRoute{}, fmt.Errorf("scan logical group route by shadow binding: %w", err)
|
||||
return nil, 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)
|
||||
return nil, fmt.Errorf("iterate logical group route by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
|
||||
}
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
func normalizeLogicalGroupRoute(route LogicalGroupRoute) (LogicalGroupRoute, error) {
|
||||
|
||||
@@ -12,6 +12,10 @@ const (
|
||||
ProviderAccountStatusDisabled = "disabled"
|
||||
ProviderAccountStatusDeprecated = "deprecated"
|
||||
ProviderAccountStatusBroken = "broken"
|
||||
|
||||
ProviderAccountBindingStateAssigned = "assigned"
|
||||
ProviderAccountBindingStateUnassigned = "unassigned"
|
||||
ProviderAccountBindingStateConflict = "conflict"
|
||||
)
|
||||
|
||||
type ProviderAccount struct {
|
||||
@@ -38,31 +42,34 @@ type ProviderAccountListFilter struct {
|
||||
RouteID string
|
||||
ShadowGroupID string
|
||||
AccountStatus string
|
||||
BindingState string
|
||||
Query string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type ProviderAccountView struct {
|
||||
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"`
|
||||
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"`
|
||||
BindingState string `json:"binding_state,omitempty"`
|
||||
BindingCandidateCount int `json:"binding_candidate_count,omitempty"`
|
||||
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 {
|
||||
@@ -189,6 +196,20 @@ func (r *ProviderAccountsRepo) GetViewByID(ctx context.Context, id int64) (Provi
|
||||
pa.key_fingerprint,
|
||||
pa.account_name,
|
||||
pa.account_status,
|
||||
CASE
|
||||
WHEN COALESCE(pa.route_id, '') <> '' THEN 'assigned'
|
||||
WHEN COALESCE((
|
||||
SELECT COUNT(1)
|
||||
FROM logical_group_routes lgrb
|
||||
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
|
||||
), 0) > 1 THEN 'conflict'
|
||||
ELSE 'unassigned'
|
||||
END,
|
||||
COALESCE((
|
||||
SELECT COUNT(1)
|
||||
FROM logical_group_routes lgrb
|
||||
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
|
||||
), 0),
|
||||
COALESCE(pa.last_probe_status, ''),
|
||||
COALESCE(pa.last_probe_at, ''),
|
||||
COALESCE(pa.disabled_reason, ''),
|
||||
@@ -224,6 +245,28 @@ func (r *ProviderAccountsRepo) UpdateStatusByID(ctx context.Context, id int64, a
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProviderAccountsRepo) UpdateBindingByID(ctx context.Context, id int64, routeID, shadowGroupID string) error {
|
||||
if id <= 0 {
|
||||
return fmt.Errorf("id is required")
|
||||
}
|
||||
routeID = strings.TrimSpace(routeID)
|
||||
shadowGroupID = strings.TrimSpace(shadowGroupID)
|
||||
result, err := r.db.ExecContext(ctx, `UPDATE provider_accounts
|
||||
SET route_id = ?, shadow_group_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`, routeID, shadowGroupID, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update provider account %d binding: %w", id, err)
|
||||
}
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("provider account %d binding rows affected: %w", id, err)
|
||||
}
|
||||
if rows == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProviderAccountsRepo) DeprecateMissingForScope(ctx context.Context, providerID, hostID int64, keepHostAccountIDs []string, reason string) error {
|
||||
if providerID <= 0 {
|
||||
return fmt.Errorf("provider_id is required")
|
||||
@@ -274,6 +317,20 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
|
||||
pa.key_fingerprint,
|
||||
pa.account_name,
|
||||
pa.account_status,
|
||||
CASE
|
||||
WHEN COALESCE(pa.route_id, '') <> '' THEN 'assigned'
|
||||
WHEN COALESCE((
|
||||
SELECT COUNT(1)
|
||||
FROM logical_group_routes lgrb
|
||||
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
|
||||
), 0) > 1 THEN 'conflict'
|
||||
ELSE 'unassigned'
|
||||
END,
|
||||
COALESCE((
|
||||
SELECT COUNT(1)
|
||||
FROM logical_group_routes lgrb
|
||||
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
|
||||
), 0),
|
||||
COALESCE(pa.last_probe_status, ''),
|
||||
COALESCE(pa.last_probe_at, ''),
|
||||
COALESCE(pa.disabled_reason, ''),
|
||||
@@ -309,6 +366,24 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
|
||||
query += ` AND pa.account_status = ?`
|
||||
args = append(args, value)
|
||||
}
|
||||
if value := normalizeProviderAccountBindingState(filter.BindingState); value != "" {
|
||||
switch value {
|
||||
case ProviderAccountBindingStateAssigned:
|
||||
query += ` AND COALESCE(pa.route_id, '') <> ''`
|
||||
case ProviderAccountBindingStateConflict:
|
||||
query += ` AND COALESCE(pa.route_id, '') = '' AND COALESCE((
|
||||
SELECT COUNT(1)
|
||||
FROM logical_group_routes lgrb
|
||||
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
|
||||
), 0) > 1`
|
||||
case ProviderAccountBindingStateUnassigned:
|
||||
query += ` AND COALESCE(pa.route_id, '') = '' AND COALESCE((
|
||||
SELECT COUNT(1)
|
||||
FROM logical_group_routes lgrb
|
||||
WHERE lgrb.shadow_host_id = h.host_id AND lgrb.shadow_group_id = pa.shadow_group_id
|
||||
), 0) <= 1`
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(filter.Query); value != "" {
|
||||
like := "%" + strings.ToLower(value) + "%"
|
||||
query += ` AND (
|
||||
@@ -355,6 +430,8 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
|
||||
&view.KeyFingerprint,
|
||||
&view.AccountName,
|
||||
&view.AccountStatus,
|
||||
&view.BindingState,
|
||||
&view.BindingCandidateCount,
|
||||
&view.LastProbeStatus,
|
||||
&view.LastProbeAt,
|
||||
&view.DisabledReason,
|
||||
@@ -412,6 +489,8 @@ func (r *ProviderAccountsRepo) scanViewOne(ctx context.Context, query string, ar
|
||||
&view.KeyFingerprint,
|
||||
&view.AccountName,
|
||||
&view.AccountStatus,
|
||||
&view.BindingState,
|
||||
&view.BindingCandidateCount,
|
||||
&view.LastProbeStatus,
|
||||
&view.LastProbeAt,
|
||||
&view.DisabledReason,
|
||||
@@ -463,3 +542,16 @@ func normalizeProviderAccountStatus(status string) string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProviderAccountBindingState(state string) string {
|
||||
switch strings.TrimSpace(state) {
|
||||
case ProviderAccountBindingStateAssigned:
|
||||
return ProviderAccountBindingStateAssigned
|
||||
case ProviderAccountBindingStateUnassigned:
|
||||
return ProviderAccountBindingStateUnassigned
|
||||
case ProviderAccountBindingStateConflict:
|
||||
return ProviderAccountBindingStateConflict
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestProviderAccountsRepoCRUDAndFilters(t *testing.T) {
|
||||
Priority: 10,
|
||||
Weight: 100,
|
||||
ShadowGroupID: "shadow-group-1",
|
||||
ShadowHostID: "shadow-host-1",
|
||||
ShadowHostID: "host-" + sanitizeTestName(t.Name()),
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroupRoutes().Create() error = %v", err)
|
||||
}
|
||||
@@ -101,9 +101,12 @@ 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" {
|
||||
if rows[0].LogicalGroupID != "lg-1" || rows[0].RouteName != "Route 1" || rows[0].ShadowHostID != "host-"+sanitizeTestName(t.Name()) {
|
||||
t.Fatalf("ProviderAccounts().List() relationship view = %+v", rows[0])
|
||||
}
|
||||
if rows[0].BindingState != ProviderAccountBindingStateAssigned || rows[0].BindingCandidateCount != 1 {
|
||||
t.Fatalf("ProviderAccounts().List() binding view = %+v", rows[0])
|
||||
}
|
||||
|
||||
if err := accountRepo.UpdateStatusByID(ctx, accountID, ProviderAccountStatusDisabled, "manual_disable"); err != nil {
|
||||
t.Fatalf("ProviderAccounts().UpdateStatusByID() error = %v", err)
|
||||
@@ -115,6 +118,39 @@ func TestProviderAccountsRepoCRUDAndFilters(t *testing.T) {
|
||||
if got.AccountStatus != ProviderAccountStatusDisabled || got.DisabledReason != "manual_disable" {
|
||||
t.Fatalf("ProviderAccounts().GetByID() after status update = %+v", got)
|
||||
}
|
||||
|
||||
if _, err := store.LogicalGroupRoutes().Create(ctx, LogicalGroupRoute{
|
||||
RouteID: "route-2",
|
||||
LogicalGroupID: "lg-1",
|
||||
Name: "Route 2",
|
||||
Status: "active",
|
||||
Priority: 20,
|
||||
Weight: 100,
|
||||
ShadowGroupID: "shadow-group-1",
|
||||
ShadowHostID: "host-" + sanitizeTestName(t.Name()),
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroupRoutes().Create(route-2) error = %v", err)
|
||||
}
|
||||
if err := accountRepo.UpdateBindingByID(ctx, accountID, "", "shadow-group-1"); err != nil {
|
||||
t.Fatalf("ProviderAccounts().UpdateBindingByID(clear) error = %v", err)
|
||||
}
|
||||
conflictRows, err := accountRepo.List(ctx, ProviderAccountListFilter{BindingState: ProviderAccountBindingStateConflict})
|
||||
if err != nil {
|
||||
t.Fatalf("ProviderAccounts().List(conflict) error = %v", err)
|
||||
}
|
||||
if len(conflictRows) != 1 || conflictRows[0].ID != accountID || conflictRows[0].BindingState != ProviderAccountBindingStateConflict {
|
||||
t.Fatalf("ProviderAccounts().List(conflict) = %+v", conflictRows)
|
||||
}
|
||||
if err := accountRepo.UpdateBindingByID(ctx, accountID, "route-2", "shadow-group-1"); err != nil {
|
||||
t.Fatalf("ProviderAccounts().UpdateBindingByID(assign) error = %v", err)
|
||||
}
|
||||
view, err = accountRepo.GetViewByID(ctx, accountID)
|
||||
if err != nil {
|
||||
t.Fatalf("ProviderAccounts().GetViewByID(after binding) error = %v", err)
|
||||
}
|
||||
if view.RouteID != "route-2" || view.BindingState != ProviderAccountBindingStateAssigned {
|
||||
t.Fatalf("ProviderAccounts().GetViewByID(after binding) = %+v", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncProviderAccountsFromImportBatchCreatesAndDeprecatesInventory(t *testing.T) {
|
||||
|
||||
@@ -132,14 +132,20 @@ 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" "/binding-candidates"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/binding"
|
||||
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" "binding_state"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "binding_candidate_count"
|
||||
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" "显式整理归属"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "conflict"
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" 'credentials: "include"'
|
||||
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal-admin-api"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user