Compare commits
3
Commits
v1.1.25
...
f060f917a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f060f917a0 | ||
|
|
704740182a | ||
|
|
a61860fcb3 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tallynote",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.26",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
|
||||
+9
-2
@@ -930,6 +930,11 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
reply.header("Cache-Control", "no-store");
|
||||
reconcileOrphanedUpdateJobs(database.sqlite, config);
|
||||
const cached = publicCheckFromCache(database.sqlite, config);
|
||||
// Status is a live control surface, not an update history endpoint.
|
||||
// Terminal failures/cancellations from a previous attempt must not be
|
||||
// replayed as if the operator had just started an update. They remain in
|
||||
// the database/audit log, while this endpoint exposes only an actionable
|
||||
// task (or the latest successful completion for confirmation).
|
||||
const row = database.sqlite.prepare(`
|
||||
SELECT id, operation, status, version, platform, asset_name AS assetName,
|
||||
size_bytes AS sizeBytes, error_message AS errorMessage,
|
||||
@@ -937,8 +942,10 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) {
|
||||
downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt,
|
||||
download_speed_bps AS downloadSpeedBps,
|
||||
requested_at AS applyQueuedAt
|
||||
FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1
|
||||
`).get(request.auth!.admin.id) as Record<string, unknown> | undefined;
|
||||
FROM update_jobs
|
||||
WHERE admin_id=? AND status IN (${[...ACTIVE_UPDATE_STATUSES, "completed"].map(() => "?").join(",")})
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`).get(request.auth!.admin.id, ...ACTIVE_UPDATE_STATUSES, "completed") as Record<string, unknown> | undefined;
|
||||
return {
|
||||
...cached,
|
||||
strategy: config.updateStrategy,
|
||||
|
||||
@@ -137,6 +137,24 @@ describe("更新 API", () => {
|
||||
expect(disabledConfig.updateStrategy).toBe("disabled");
|
||||
});
|
||||
|
||||
it("首次进入状态页不会展示历史失败任务,也不会阻断新的检查", async () => {
|
||||
const session = await login("update-history");
|
||||
const admin = database.sqlite.prepare("SELECT id FROM admins WHERE username=?").get("update-history") as { id: string };
|
||||
const now = Date.now();
|
||||
database.sqlite.prepare(`
|
||||
INSERT INTO update_jobs(id, admin_id, operation, status, version, platform, asset_url, error_message, created_at, updated_at)
|
||||
VALUES (?, ?, 'download', 'failed', '1.1.0', ?, 'https://updates.example/old.tar.gz', 'old failure', ?, ?)
|
||||
`).run(randomUUID(), admin.id, detectPlatform().target, now - 60_000, now - 60_000);
|
||||
const initial = await app.inject({ method: "GET", url: "/api/update/status", headers: { cookie: session.cookies } });
|
||||
expect(initial.statusCode).toBe(200);
|
||||
expect(initial.json().job).toBeNull();
|
||||
|
||||
mockRelease();
|
||||
const checked = await app.inject({ method: "POST", url: "/api/update/check", headers: { origin: config.publicOrigin, cookie: session.cookies, "x-csrf-token": session.csrf }, payload: {} });
|
||||
expect(checked.statusCode).toBe(200);
|
||||
expect(checked.json().latest).toMatchObject({ version: "1.2.0", isNewer: true });
|
||||
});
|
||||
|
||||
it("更新任务只对发起管理员可见,并隐藏内部错误详情", async () => {
|
||||
const owner = await login("update-owner");
|
||||
const other = await login("update-other");
|
||||
|
||||
@@ -273,6 +273,7 @@ export default function UpdatePage({
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [reloadReady, setReloadReady] = useState(false);
|
||||
const [showNotesDialog, setShowNotesDialog] = useState(false);
|
||||
const [showPipelineDialog, setShowPipelineDialog] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
const announced = useRef<string | null>(null);
|
||||
@@ -337,7 +338,14 @@ export default function UpdatePage({
|
||||
else setLoading(false);
|
||||
}, [isMock]);
|
||||
|
||||
const job = info?.job;
|
||||
// The status endpoint intentionally hides terminal failures/cancellations.
|
||||
// Keep this guard in the UI as well so a stale response from an older
|
||||
// server cannot turn a fresh page visit into a false failure state.
|
||||
const job = info?.job && info.job.status !== "failed" && info.job.status !== "cancelled" ? info.job : null;
|
||||
useEffect(() => {
|
||||
if (job && activeStatuses.has(job.status)) setShowPipelineDialog(true);
|
||||
}, [job?.id, job?.status]);
|
||||
|
||||
const applyQueuedAt = timestamp(job?.applyQueuedAt);
|
||||
const restartAt =
|
||||
timestamp(job?.restartDeadline) ??
|
||||
@@ -436,7 +444,10 @@ export default function UpdatePage({
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
setLiveInfo((current) => ({ ...result, job: result.job ?? current?.job ?? null }));
|
||||
setLiveInfo((current) => ({
|
||||
...result,
|
||||
job: result.job ?? (current?.job && activeStatuses.has(current.job.status) ? current.job : null),
|
||||
}));
|
||||
notify?.(result.latest?.isNewer ? "发现新版本" : "当前已是最新版本", "success");
|
||||
} catch (caught) {
|
||||
if (caught instanceof ApiError && caught.code === "UPDATE_RATE_LIMITED") {
|
||||
@@ -468,6 +479,7 @@ export default function UpdatePage({
|
||||
mockTimer.current = null;
|
||||
setActionBusy(false);
|
||||
setConfirmVersion(null);
|
||||
setShowPipelineDialog(true);
|
||||
if (confirmAction === "download") {
|
||||
handleScenarioChange("downloading_30");
|
||||
notify?.("Mock:开始模拟下载,状态已流转至 [下载中]", "info");
|
||||
@@ -496,6 +508,7 @@ export default function UpdatePage({
|
||||
setConfirmVersion(null);
|
||||
setReloadReady(false);
|
||||
setLiveInfo((current) => (current ? { ...current, job: result.job } : current));
|
||||
setShowPipelineDialog(true);
|
||||
notify?.(
|
||||
confirmAction === "download" ? "更新包下载已开始" : "更新已开始,服务会短暂重启",
|
||||
"info"
|
||||
@@ -715,11 +728,18 @@ export default function UpdatePage({
|
||||
</Surface>
|
||||
</div>
|
||||
|
||||
{/* Stepper Process Card */}
|
||||
<Surface className="tn-stepper-surface">
|
||||
{/* Stepper Process Dialog */}
|
||||
<Dialog
|
||||
visible={showPipelineDialog}
|
||||
header="版本升级流水线"
|
||||
confirmBtn={{ content: "关闭", theme: "default" }}
|
||||
cancelBtn={null}
|
||||
onConfirm={() => setShowPipelineDialog(false)}
|
||||
onClose={() => setShowPipelineDialog(false)}
|
||||
>
|
||||
<div className="tn-stepper-surface">
|
||||
<div className="tn-stepper-head">
|
||||
<div>
|
||||
<h3 className="tn-section-heading">版本升级流水线</h3>
|
||||
<small className="tn-section-subheading">
|
||||
标准化发布流程:版本发现 → 校验签名与清单 → 安全暂存 → 原子切换并重启
|
||||
</small>
|
||||
@@ -834,7 +854,8 @@ export default function UpdatePage({
|
||||
<span>更新成功完成!新版本已通过内置端点健康检查,账本数据与附件完整无损。</span>
|
||||
</div>
|
||||
)}
|
||||
</Surface>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* Release Details Section */}
|
||||
{latest ? (
|
||||
@@ -856,32 +877,8 @@ export default function UpdatePage({
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="tn-release-header-actions">
|
||||
{notes && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="medium"
|
||||
onClick={() => setShowNotesDialog(true)}
|
||||
icon={<FileCode size={15} />}
|
||||
>
|
||||
查看完整更新日志
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Release Notes Preview snippet */}
|
||||
{notes && (
|
||||
<div className="tn-release-notes-box">
|
||||
<div className="tn-release-notes-heading">
|
||||
<span>更新内容概览</span>
|
||||
</div>
|
||||
<div className="tn-release-notes-content">
|
||||
<MarkdownNotes value={notes} compact />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integrity & compatibility facts grid */}
|
||||
<div className="tn-facts-grid">
|
||||
<div className="tn-fact-item">
|
||||
@@ -963,6 +960,30 @@ export default function UpdatePage({
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
{latest && notes && (
|
||||
<Surface className="tn-release-notes-surface">
|
||||
<div className="tn-release-notes-header">
|
||||
<div>
|
||||
<span className="tn-eyebrow">更新日志</span>
|
||||
<h3 className="tn-section-heading">本次版本更新内容</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="medium"
|
||||
onClick={() => setShowNotesDialog(true)}
|
||||
icon={<FileCode size={15} />}
|
||||
>
|
||||
查看完整日志
|
||||
</Button>
|
||||
</div>
|
||||
<div className="tn-release-notes-box">
|
||||
<div className="tn-release-notes-content">
|
||||
<MarkdownNotes value={notes} compact />
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
)}
|
||||
|
||||
{/* Manual source warning if not systemd */}
|
||||
{!info.configured && (
|
||||
<div className="tn-update-explainer">
|
||||
|
||||
@@ -847,6 +847,17 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||
margin-bottom: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
.tn-release-notes-surface {
|
||||
margin-bottom: 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
.tn-release-notes-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tn-release-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -870,7 +881,7 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||
color: var(--tn-text-secondary);
|
||||
}
|
||||
.tn-release-notes-box {
|
||||
margin: 16px 0;
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--tn-border);
|
||||
@@ -1064,6 +1075,13 @@ input:focus-visible, textarea:focus-visible, select:focus-visible {
|
||||
.tn-release-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
.tn-release-notes-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
.tn-release-notes-header .t-button {
|
||||
width: 100%;
|
||||
}
|
||||
.tn-release-card-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user