385 lines
21 KiB
TypeScript
385 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import type {
|
|
AccountRecord,
|
|
AccountDeletionRequestRecord,
|
|
BetaAccessRequestRecord,
|
|
BetaRecord,
|
|
FeedbackRecord,
|
|
GrantRecord,
|
|
LaunchGateRecord,
|
|
RetentionMaintenanceResult,
|
|
ServiceIncidentRecord,
|
|
} from "@/db/repository";
|
|
|
|
type SubmitState = { loading: boolean; message: string; error: boolean };
|
|
const idle: SubmitState = { loading: false, message: "", error: false };
|
|
|
|
async function postJson<T extends Record<string, unknown> = Record<string, unknown>>(path: string, payload: Record<string, unknown>): Promise<T & { message?: string }> {
|
|
const response = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...payload, idempotencyKey: crypto.randomUUID() }) });
|
|
const body = (await response.json()) as T & { message?: string };
|
|
if (!response.ok) throw new Error(body.message || "操作失败");
|
|
return body;
|
|
}
|
|
|
|
export function BetaAction({ beta, blockedP0 }: { beta: BetaRecord | null; blockedP0: number }) {
|
|
const [enabled, setEnabled] = useState(beta?.state === "active");
|
|
const [capacity, setCapacity] = useState(beta?.capacity_slots?.toString() ?? "");
|
|
const [reason, setReason] = useState("");
|
|
const [state, setState] = useState(idle);
|
|
async function submit(event: React.FormEvent) {
|
|
event.preventDefault(); setState({ loading: true, message: "", error: false });
|
|
try { await postJson("/api/admin/beta", { enabled, capacitySlots: capacity ? Number(capacity) : null, reason }); setState({ loading: false, message: "新的公测政策版本已写入,页面即将刷新。", error: false }); window.setTimeout(() => window.location.reload(), 700); }
|
|
catch (error) { setState({ loading: false, message: error instanceof Error ? error.message : "操作失败", error: true }); }
|
|
}
|
|
return <form className="admin-form" onSubmit={submit}><label className="switch-row"><input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} /><span><strong>全局公测免费政策</strong><small>关闭只停止新的公开配对和未完成认领,不创建订单或付款,也不自动断开既有主机;开启不能绕过 P0。</small></span></label>{blockedP0 > 0 && <p className="form-error" role="status">仍有 {blockedP0} 项 P0 未通过:可以预设免费政策,但新的公开配对继续冻结;只有明确签发的闭测邀请可继续使用。</p>}<label><span>每账户免费主机上限</span><input type="number" min={1} value={capacity} onChange={(event) => setCapacity(event.target.value)} placeholder="留空 = 每账户不按槽位限额" /><small>下调后不会断开既有主机;超出新上限的未完成认领会停止。当前没有时间型宽限设置。</small></label><label><span>变更理由</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} placeholder="为什么现在调整公测政策" required /></label><Submit state={state} label="发布新公测政策版本" /></form>;
|
|
}
|
|
|
|
export function ExemptionAction({ accounts }: { accounts: AccountRecord[] }) {
|
|
const [accountId, setAccountId] = useState(accounts[0]?.id ?? "");
|
|
const [capacity, setCapacity] = useState("1");
|
|
const [endsAt, setEndsAt] = useState("");
|
|
const [reason, setReason] = useState("");
|
|
const [state, setState] = useState(idle);
|
|
async function submit(event: React.FormEvent) { event.preventDefault(); setState({ loading: true, message: "", error: false }); try { await postJson("/api/admin/exemptions", { action: "create", accountId, capacitySlots: capacity ? Number(capacity) : null, endsAt: new Date(endsAt).toISOString(), reason }); setState({ loading: false, message: "闭测邀请已签发;没有创建金额、余额或支付。", error: false }); window.setTimeout(() => window.location.reload(), 700); } catch (error) { setState({ loading: false, message: error instanceof Error ? error.message : "操作失败", error: true }); } }
|
|
return <form className="admin-form" onSubmit={submit}><label><span>目标账户</span><select value={accountId} onChange={(event) => setAccountId(event.target.value)} required disabled={!accounts.length}>{accounts.length ? accounts.map((account) => <option value={account.id} key={account.id}>{account.email} · {account.display_name} · {account.id.slice(-8)}</option>) : <option value="">暂无可选账户</option>}</select><small>邮箱只用于核对;邀请始终绑定不可变账户 ID。</small></label><div className="admin-form-grid"><label><span>免费主机槽位</span><input type="number" min={1} value={capacity} onChange={(event) => setCapacity(event.target.value)} placeholder="留空 = 不按槽位限额" /></label><label><span>邀请到期时间</span><input type="datetime-local" value={endsAt} onChange={(event) => setEndsAt(event.target.value)} required /></label></div><label><span>邀请理由</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} placeholder="例如:首批闭测用户,有效至指定日期" required /></label><Submit state={state} label="签发有期限闭测邀请" /></form>;
|
|
}
|
|
|
|
export function AccessRequestAction({ request }: { request: BetaAccessRequestRecord }) {
|
|
const [action, setAction] = useState<"approve" | "decline">("approve");
|
|
const [capacity, setCapacity] = useState(request.requested_slots);
|
|
const [endsAt, setEndsAt] = useState("");
|
|
const [response, setResponse] = useState("");
|
|
const [reason, setReason] = useState("");
|
|
const [state, setState] = useState(idle);
|
|
async function submit(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
setState({ loading: true, message: "", error: false });
|
|
try {
|
|
await postJson("/api/admin/beta-access", {
|
|
requestId: request.id,
|
|
action,
|
|
capacitySlots: capacity,
|
|
endsAt: action === "approve" ? new Date(endsAt).toISOString() : "",
|
|
response,
|
|
reason,
|
|
});
|
|
setState({ loading: false, message: action === "approve" ? "申请已批准,免费闭测邀请已签发。" : "申请已拒绝,用户可在控制台看到说明。", error: false });
|
|
window.setTimeout(() => window.location.reload(), 700);
|
|
} catch (error) {
|
|
setState({ loading: false, message: error instanceof Error ? error.message : "操作失败", error: true });
|
|
}
|
|
}
|
|
return (
|
|
<form className="admin-form" onSubmit={submit}>
|
|
<div className="admin-form-grid">
|
|
<label><span>处理结果</span><select value={action} onChange={(event) => setAction(event.target.value as "approve" | "decline")}><option value="approve">批准并签发免费邀请</option><option value="decline">暂不批准</option></select></label>
|
|
{action === "approve" && <label><span>批准主机数</span><input type="number" min={1} max={3} value={capacity} onChange={(event) => setCapacity(Number(event.target.value))} required /></label>}
|
|
</div>
|
|
{action === "approve" && <label><span>邀请到期时间</span><input type="datetime-local" value={endsAt} onChange={(event) => setEndsAt(event.target.value)} required /></label>}
|
|
<label><span>给用户的说明</span><textarea minLength={2} maxLength={500} value={response} onChange={(event) => setResponse(event.target.value)} placeholder={action === "approve" ? "例如:已开放 1 台主机的免费闭测资格,有效期见资格页。" : "例如:当前测试名额有限,暂未开放;以后可以重新申请。"} required /></label>
|
|
<label><span>内部处理理由</span><textarea maxLength={500} value={reason} onChange={(event) => setReason(event.target.value)} placeholder="审核依据;仅进入内部审计" required /></label>
|
|
<Submit state={state} label={action === "approve" ? "批准并签发邀请" : "拒绝申请"} />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function InvitationRevokeAction({ invitation }: { invitation: GrantRecord }) {
|
|
const [confirmed, setConfirmed] = useState(false);
|
|
const [reason, setReason] = useState("");
|
|
const [state, setState] = useState(idle);
|
|
async function submit(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
setState({ loading: true, message: "", error: false });
|
|
try {
|
|
await postJson("/api/admin/exemptions", {
|
|
action: "revoke",
|
|
grantId: invitation.id,
|
|
reason,
|
|
});
|
|
setState({ loading: false, message: "闭测邀请已撤销;既有主机不会被自动断开。", error: false });
|
|
window.setTimeout(() => window.location.reload(), 700);
|
|
} catch (error) {
|
|
setState({ loading: false, message: error instanceof Error ? error.message : "操作失败", error: true });
|
|
}
|
|
}
|
|
return (
|
|
<form className="admin-form invitation-revoke-form" onSubmit={submit}>
|
|
<label><span>撤销理由</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} maxLength={500} required /></label>
|
|
<label className="switch-row"><input type="checkbox" checked={confirmed} onChange={(event) => setConfirmed(event.target.checked)} /><span><strong>停止该账户后续闭测配对资格</strong><small>未完成的 daemon 认领会失败;已经认领的主机不会自动断开。</small></span></label>
|
|
<Submit state={state} label="撤销闭测邀请" disabled={!confirmed} />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function GateAction({ gates }: { gates: LaunchGateRecord[] }) {
|
|
const [key, setKey] = useState(gates[0]?.key ?? "");
|
|
const selected = gates.find((gate) => gate.key === key);
|
|
const [status, setStatus] = useState<LaunchGateRecord["status"]>(selected?.status ?? "blocked");
|
|
const [owner, setOwner] = useState(selected?.owner ?? "");
|
|
const [evidenceUrl, setEvidenceUrl] = useState(selected?.evidence_url ?? "");
|
|
const [notes, setNotes] = useState(selected?.notes ?? "");
|
|
const [reason, setReason] = useState("");
|
|
const [state, setState] = useState(idle);
|
|
function selectGate(nextKey: string) { const gate = gates.find((item) => item.key === nextKey); setKey(nextKey); setStatus(gate?.status ?? "blocked"); setOwner(gate?.owner ?? ""); setEvidenceUrl(gate?.evidence_url ?? ""); setNotes(gate?.notes ?? ""); }
|
|
async function submit(event: React.FormEvent) { event.preventDefault(); setState({ loading: true, message: "", error: false }); try { await postJson("/api/admin/launch-gates", { key, status, owner, evidenceUrl, notes, reason }); setState({ loading: false, message: "门禁状态和证据已审计保存。", error: false }); window.setTimeout(() => window.location.reload(), 700); } catch (error) { setState({ loading: false, message: error instanceof Error ? error.message : "操作失败", error: true }); } }
|
|
return <form className="admin-form" onSubmit={submit}><label><span>门禁</span><select value={key} onChange={(event) => selectGate(event.target.value)}>{gates.map((gate) => <option value={gate.key} key={gate.key}>{gate.priority} · {gate.title}</option>)}</select></label><div className="admin-form-grid"><label><span>状态</span><select value={status} onChange={(event) => setStatus(event.target.value as LaunchGateRecord["status"])}><option value="blocked">阻止</option><option value="in_progress">进行中</option><option value="passed">已通过</option><option value="not_applicable">不适用</option></select></label><label><span>负责人</span><input value={owner} onChange={(event) => setOwner(event.target.value)} placeholder="姓名或角色" /></label></div><label><span>证据 URL</span><input type="url" value={evidenceUrl} onChange={(event) => setEvidenceUrl(event.target.value)} placeholder="https://…" /></label><label><span>证据摘要</span><textarea value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="测试范围、日期和结论" /></label><label><span>变更理由</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} required /></label><Submit state={state} label="保存门禁状态" /></form>;
|
|
}
|
|
|
|
export function FeedbackAction({ feedback }: { feedback: FeedbackRecord }) {
|
|
const [response, setResponse] = useState("");
|
|
const [reason, setReason] = useState("");
|
|
const [state, setState] = useState(idle);
|
|
async function submit(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
setState({ loading: true, message: "", error: false });
|
|
try {
|
|
await postJson("/api/admin/feedback", {
|
|
feedbackId: feedback.id,
|
|
response,
|
|
reason,
|
|
});
|
|
setState({ loading: false, message: "回复已保存,反馈已关闭。", error: false });
|
|
window.setTimeout(() => window.location.reload(), 700);
|
|
} catch (error) {
|
|
setState({
|
|
loading: false,
|
|
message: error instanceof Error ? error.message : "操作失败",
|
|
error: true,
|
|
});
|
|
}
|
|
}
|
|
return (
|
|
<form className="admin-form feedback-admin-form" onSubmit={submit}>
|
|
<label>
|
|
<span>给用户的回复</span>
|
|
<textarea
|
|
minLength={2}
|
|
maxLength={1000}
|
|
value={response}
|
|
onChange={(event) => setResponse(event.target.value)}
|
|
required
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>内部处理理由</span>
|
|
<textarea
|
|
maxLength={500}
|
|
value={reason}
|
|
onChange={(event) => setReason(event.target.value)}
|
|
placeholder="例如:已确认配置问题并给出恢复步骤"
|
|
required
|
|
/>
|
|
</label>
|
|
<Submit state={state} label="回复并关闭" />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function IncidentAction() {
|
|
const [severity, setSeverity] = useState("degraded");
|
|
const [title, setTitle] = useState("");
|
|
const [message, setMessage] = useState("");
|
|
const [reason, setReason] = useState("");
|
|
const [state, setState] = useState(idle);
|
|
|
|
async function submit(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
setState({ loading: true, message: "", error: false });
|
|
try {
|
|
await postJson("/api/admin/incidents", {
|
|
action: "create",
|
|
severity,
|
|
title,
|
|
message,
|
|
reason,
|
|
});
|
|
setState({ loading: false, message: "故障公告已发布到服务状态页。", error: false });
|
|
window.setTimeout(() => window.location.reload(), 700);
|
|
} catch (error) {
|
|
setState({
|
|
loading: false,
|
|
message: error instanceof Error ? error.message : "操作失败",
|
|
error: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form className="admin-form" onSubmit={submit}>
|
|
<label>
|
|
<span>影响级别</span>
|
|
<select value={severity} onChange={(event) => setSeverity(event.target.value)}>
|
|
<option value="maintenance">计划维护</option>
|
|
<option value="degraded">服务降级</option>
|
|
<option value="outage">服务中断</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>公开标题</span>
|
|
<input minLength={4} maxLength={80} value={title} onChange={(event) => setTitle(event.target.value)} required />
|
|
</label>
|
|
<label>
|
|
<span>给用户的说明</span>
|
|
<textarea minLength={10} maxLength={1000} value={message} onChange={(event) => setMessage(event.target.value)} required />
|
|
</label>
|
|
<label>
|
|
<span>内部发布理由</span>
|
|
<textarea maxLength={500} value={reason} onChange={(event) => setReason(event.target.value)} required />
|
|
</label>
|
|
<Submit state={state} label="发布服务公告" />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function IncidentResolveAction({ incident }: { incident: ServiceIncidentRecord }) {
|
|
const [resolution, setResolution] = useState("");
|
|
const [reason, setReason] = useState("");
|
|
const [state, setState] = useState(idle);
|
|
|
|
async function submit(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
setState({ loading: true, message: "", error: false });
|
|
try {
|
|
await postJson("/api/admin/incidents", {
|
|
action: "resolve",
|
|
incidentId: incident.id,
|
|
resolution,
|
|
reason,
|
|
});
|
|
setState({ loading: false, message: "恢复说明已发布。", error: false });
|
|
window.setTimeout(() => window.location.reload(), 700);
|
|
} catch (error) {
|
|
setState({
|
|
loading: false,
|
|
message: error instanceof Error ? error.message : "操作失败",
|
|
error: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form className="admin-form incident-resolve-form" onSubmit={submit}>
|
|
<label>
|
|
<span>公开恢复说明</span>
|
|
<textarea minLength={4} maxLength={500} value={resolution} onChange={(event) => setResolution(event.target.value)} required />
|
|
</label>
|
|
<label>
|
|
<span>内部处理理由</span>
|
|
<textarea maxLength={500} value={reason} onChange={(event) => setReason(event.target.value)} required />
|
|
</label>
|
|
<Submit state={state} label="标记恢复并发布说明" />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function RetentionAction() {
|
|
const [confirmed, setConfirmed] = useState(false);
|
|
const [reason, setReason] = useState("");
|
|
const [state, setState] = useState(idle);
|
|
|
|
async function submit(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
setState({ loading: true, message: "", error: false });
|
|
try {
|
|
const body = await postJson<{ retention: RetentionMaintenanceResult }>(
|
|
"/api/admin/retention",
|
|
{ confirmed, reason },
|
|
);
|
|
const result = body.retention;
|
|
const total =
|
|
result.retiredPairingCodes +
|
|
result.deletedClaimRateWindows +
|
|
result.deletedClaimAttempts +
|
|
result.deletedIdempotencyRecords;
|
|
setState({
|
|
loading: false,
|
|
message: `清理完成:处理 ${total} 条到期技术记录,管理审计已追加。`,
|
|
error: false,
|
|
});
|
|
setConfirmed(false);
|
|
setReason("");
|
|
} catch (error) {
|
|
setState({
|
|
loading: false,
|
|
message: error instanceof Error ? error.message : "操作失败",
|
|
error: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form className="admin-form" onSubmit={submit}>
|
|
<div className="retention-scope-list">
|
|
<span>过期配对码摘要 → 不可恢复 tombstone</span>
|
|
<span>来源限速窗口 → 24 小时后删除</span>
|
|
<span>配对尝试 → 30 天后删除</span>
|
|
<span>幂等记录 → 自身到期后删除</span>
|
|
</div>
|
|
<label>
|
|
<span>执行理由</span>
|
|
<textarea
|
|
maxLength={500}
|
|
value={reason}
|
|
onChange={(event) => setReason(event.target.value)}
|
|
placeholder="例如:执行每周公测数据最小化维护"
|
|
required
|
|
/>
|
|
</label>
|
|
<label className="switch-row">
|
|
<input
|
|
type="checkbox"
|
|
checked={confirmed}
|
|
onChange={(event) => setConfirmed(event.target.checked)}
|
|
/>
|
|
<span><strong>只处理已经到期的技术记录</strong><small>不会删除账户、主机、反馈、审计、租户卷或备份。</small></span>
|
|
</label>
|
|
<Submit state={state} label="执行到期数据清理" disabled={!confirmed} />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function PurgeAction({ request }: { request: AccountDeletionRequestRecord }) {
|
|
const [confirmation, setConfirmation] = useState("");
|
|
const [reason, setReason] = useState("");
|
|
const [state, setState] = useState(idle);
|
|
|
|
async function submit(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
setState({ loading: true, message: "", error: false });
|
|
try {
|
|
await postJson("/api/admin/relay-purges", {
|
|
deletion_request_id: request.id,
|
|
reason,
|
|
confirmation,
|
|
});
|
|
setState({ loading: false, message: "访问已暂停,Relay 将删除实时数据、附件和全部备份。", error: false });
|
|
window.setTimeout(() => window.location.reload(), 700);
|
|
} catch (error) {
|
|
setState({
|
|
loading: false,
|
|
message: error instanceof Error ? error.message : "操作失败",
|
|
error: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form className="admin-form" onSubmit={submit}>
|
|
<label>
|
|
<span>不可撤回的删除理由</span>
|
|
<textarea minLength={8} maxLength={500} value={reason} onChange={(event) => setReason(event.target.value)} required />
|
|
</label>
|
|
<label>
|
|
<span>输入 DELETE TENANT DATA 确认</span>
|
|
<input value={confirmation} onChange={(event) => setConfirmation(event.target.value)} autoComplete="off" required />
|
|
<small>这会关闭租户 Engine,删除 Relay SQLite、附件、全部备份及活动凭据;不会物理覆写云盘块。</small>
|
|
</label>
|
|
<Submit state={state} label="永久逻辑删除 Relay 数据" disabled={confirmation !== "DELETE TENANT DATA" || reason.trim().length < 8} />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function Submit({ state, label, disabled = false }: { state: SubmitState; label: string; disabled?: boolean }) { return <><button className="button button-primary" type="submit" disabled={state.loading || disabled}>{state.loading ? "正在保存…" : label}</button>{state.message && <p className={state.error ? "form-error" : "form-success"} role="status">{state.message}</p>}</>; }
|