Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fae8225299 | ||
|
|
de63fcffff | ||
|
|
12d9c46323 | ||
|
|
1230116a6f |
@@ -83,7 +83,7 @@ PostgreSQL 18 兼容工作继续冻结。
|
||||
先在宝塔面板停止并删除当前错误的 Java 项目,再执行:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.42/install.sh | sudo env KAIDI_APP_PORT=18080 bash
|
||||
curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/install.sh | sudo env KAIDI_APP_PORT=18080 bash
|
||||
```
|
||||
|
||||
该命令只安装程序运行所需的 systemd 单元,自动创建 `kaidi` 用户并检测现有 Java 17(包括
|
||||
@@ -96,7 +96,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe
|
||||
随后执行一键清理:
|
||||
|
||||
```bash
|
||||
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.42/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash
|
||||
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION bash
|
||||
```
|
||||
|
||||
上面是一条完整命令:脚本通过管道直接交给 root 执行,不创建临时安装文件,避免终端自动换行导致 `-o` 参数丢失。
|
||||
@@ -110,16 +110,16 @@ Nginx/反向代理和外部 MySQL 属于外部资源,卸载程序不会误删
|
||||
`0600`,确认新安装及数据无误后再由 root 删除。脚本不删除外部 MySQL、系统 Java、Nginx 或宝塔站点配置;
|
||||
新安装必须连接一个新的空 MySQL 数据库,旧数据库保留用于回滚。
|
||||
|
||||
### Preview.42 直链与宝塔手动部署
|
||||
### Preview.43 直链与宝塔手动部署
|
||||
|
||||
本版不使用一键安装脚本。发布物是一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
|
||||
本版提供 systemd 一键安装脚本和宝塔手动部署两种互斥方式。手动部署使用一个不带顶层目录的压缩包,下载后直接解压到版本目录,再由宝塔面板创建
|
||||
Spring Boot 项目。数据库、JDK、Nginx 和宝塔本身都由运维人员准备;程序不会自动安装 MySQL 或任何第三方服务。
|
||||
|
||||
下载地址:
|
||||
|
||||
`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.42/kaidi-finance-1.0.0-preview.42.tar.gz`
|
||||
`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/kaidi-finance-1.0.0-preview.46.tar.gz`
|
||||
|
||||
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.42/SHA256SUMS`
|
||||
校验文件:`https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/SHA256SUMS`
|
||||
|
||||
服务器要求:Linux、Java 17(宝塔项目选择 JDK 17)、可访问外部 MySQL 8.4.x;**systemd 一键安装**还需要
|
||||
systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux 需要宿主机
|
||||
@@ -136,7 +136,7 @@ systemd/systemd-analyze,**宝塔手动部署**不要求 systemd。32 位 Linux
|
||||
必须直接位于 `RELEASE_ROOT`,不能再嵌套一层目录。
|
||||
|
||||
```bash
|
||||
VERSION=1.0.0-preview.42
|
||||
VERSION=1.0.0-preview.46
|
||||
APP_ROOT=/www/wwwroot/kaidi
|
||||
RELEASE_ROOT="$APP_ROOT/releases/$VERSION"
|
||||
sudo install -d -m 0755 "$RELEASE_ROOT"
|
||||
@@ -245,7 +245,7 @@ MySQL 8.4;提前准备外部 MySQL,完成上述向导即可。systemd 在线
|
||||
`KAIDI_PURGE_DELETE_BACKUP=true`,实现本地受管文件和恢复包一并清除:
|
||||
|
||||
```bash
|
||||
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.42/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash
|
||||
curl -fsSL 'https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.46/purge.sh' | sudo env KAIDI_PURGE_CONFIRM=DELETE_LOCAL_KAIDI_INSTALLATION KAIDI_PURGE_DELETE_BACKUP=true bash
|
||||
```
|
||||
|
||||
执行前先在宝塔停止并删除 `kaidi-finance` Java 项目;宝塔面板元数据属于外部资源,必须由面板先停用,
|
||||
@@ -271,8 +271,8 @@ Actions 页面显示 “No matching online runner”,先启动并注册该标
|
||||
Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布:
|
||||
|
||||
```bash
|
||||
git tag v1.0.0-preview.42
|
||||
git push origin v1.0.0-preview.42
|
||||
git tag v1.0.0-preview.46
|
||||
git push origin v1.0.0-preview.46
|
||||
```
|
||||
|
||||
在线更新仍使用独立的 TDesign 页面:系统管理员进入“系统治理 → 系统更新”。更新源由 root 在
|
||||
@@ -324,7 +324,7 @@ cat /var/lib/kaidi-update/status.json
|
||||
```bash
|
||||
KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \
|
||||
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
||||
./scripts/package-release.sh 1.0.0-preview.42
|
||||
./scripts/package-release.sh 1.0.0-preview.46
|
||||
KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \
|
||||
./scripts/verify-release.sh dist/release
|
||||
```
|
||||
|
||||
@@ -22,6 +22,54 @@ public interface AuditMapper {
|
||||
""")
|
||||
int insert(AuditEntry entry);
|
||||
|
||||
@Select("""
|
||||
SELECT request_id, user_public_id, username, active_role, company_public_id, project_public_id,
|
||||
CAST(after_json AS CHAR) AS after_json, ip_address, user_agent
|
||||
FROM audit_log
|
||||
WHERE object_type = 'SYSTEM_UPDATE'
|
||||
AND action_code = #{actionCode}
|
||||
AND request_id = #{requestId}
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
SystemUpdateAuditSource findSystemUpdateSourceByRequestId(@Param("requestId") String requestId,
|
||||
@Param("actionCode") String actionCode);
|
||||
|
||||
@Select("""
|
||||
SELECT request_id, user_public_id, username, active_role, company_public_id, project_public_id,
|
||||
CAST(after_json AS CHAR) AS after_json, ip_address, user_agent
|
||||
FROM audit_log
|
||||
WHERE object_type = 'SYSTEM_UPDATE'
|
||||
AND action_code = #{actionCode}
|
||||
AND (
|
||||
JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.targetVersion')) = #{targetVersion}
|
||||
OR JSON_UNQUOTE(JSON_EXTRACT(after_json, '$.latestVersion')) = #{targetVersion}
|
||||
)
|
||||
AND created_at >= DATE_SUB(#{completedAt}, INTERVAL 7 DAY)
|
||||
AND created_at <= DATE_ADD(#{completedAt}, INTERVAL 5 MINUTE)
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
SystemUpdateAuditSource findSystemUpdateSourceByVersion(@Param("targetVersion") String targetVersion,
|
||||
@Param("actionCode") String actionCode,
|
||||
@Param("completedAt") LocalDateTime completedAt);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO audit_log (
|
||||
public_id, request_id, user_public_id, username, active_role, company_public_id,
|
||||
project_public_id, action_code, object_type, object_public_id, result_code, reason,
|
||||
before_json, after_json, ip_address, user_agent, dedupe_key, created_at
|
||||
) VALUES (
|
||||
#{entry.publicId}, #{entry.requestId}, #{entry.userPublicId}, #{entry.username}, #{entry.activeRole},
|
||||
#{entry.companyPublicId}, #{entry.projectPublicId}, #{entry.actionCode}, #{entry.objectType},
|
||||
#{entry.objectPublicId}, #{entry.resultCode}, #{entry.reason}, #{entry.beforeJson}, #{entry.afterJson},
|
||||
#{entry.ipAddress}, #{entry.userAgent}, #{dedupeKey}, #{occurredAt}
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE dedupe_key = #{dedupeKey}
|
||||
""")
|
||||
int insertSystemUpdateTerminal(@Param("entry") AuditEntry entry, @Param("dedupeKey") String dedupeKey,
|
||||
@Param("occurredAt") LocalDateTime occurredAt);
|
||||
|
||||
@Select("""
|
||||
SELECT created_at, ip_address, user_agent
|
||||
FROM audit_log
|
||||
@@ -36,4 +84,9 @@ public interface AuditMapper {
|
||||
|
||||
record LoginAuditRow(LocalDateTime occurredAt, String ipAddress, String userAgent) {
|
||||
}
|
||||
|
||||
record SystemUpdateAuditSource(String requestId, String userPublicId, String username, String activeRole,
|
||||
String companyPublicId, String projectPublicId, String afterJson,
|
||||
String ipAddress, String userAgent) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,16 @@ import com.kaidi.finance.shared.id.UlidGenerator;
|
||||
import com.kaidi.finance.shared.infrastructure.RequestContext;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -54,6 +64,71 @@ public class AuditService {
|
||||
null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the updater's terminal state as a separate immutable audit event.
|
||||
*
|
||||
* The shell updater finishes while the application is restarting, so this method is invoked by the first
|
||||
* successful status read after restart. A database-level dedupe key makes repeated polling and application
|
||||
* restarts idempotent. When possible the terminal event inherits the actor and request id from the original
|
||||
* download/install request; legacy status files without a request id fall back to the most recent matching
|
||||
* target version.
|
||||
*
|
||||
* @return the stable dedupe key, or {@code null} when the supplied state is not persistable
|
||||
*/
|
||||
public String recordSystemUpdateTerminal(String updateRequestId, String updateAction, String state,
|
||||
String targetVersion, String message, Instant updatedAt) {
|
||||
String normalizedState = normalizeTerminalState(state);
|
||||
String normalizedVersion = clean(targetVersion, 128);
|
||||
if (normalizedState == null || normalizedVersion == null || updatedAt == null) return null;
|
||||
|
||||
String normalizedRequestId = validRequestId(updateRequestId) ? updateRequestId : null;
|
||||
String normalizedAction = clean(updateAction, 16);
|
||||
if (normalizedAction != null) normalizedAction = normalizedAction.toUpperCase(Locale.ROOT);
|
||||
String sourceAction = sourceAction(normalizedState, normalizedAction);
|
||||
AuditMapper.SystemUpdateAuditSource source = findSystemUpdateSource(
|
||||
normalizedRequestId, normalizedVersion, sourceAction, updatedAt);
|
||||
if (source == null && normalizedAction == null && !"SYSTEM_UPDATE_DOWNLOAD_REQUEST".equals(sourceAction)) {
|
||||
source = findSystemUpdateSource(normalizedRequestId, normalizedVersion,
|
||||
"SYSTEM_UPDATE_DOWNLOAD_REQUEST", updatedAt);
|
||||
}
|
||||
|
||||
String correlation = normalizedRequestId == null
|
||||
? normalizedVersion + '|' + normalizedState + '|' + updatedAt
|
||||
: normalizedRequestId + '|' + normalizedState;
|
||||
String dedupeKey = "SYSUPD:" + sha256(correlation);
|
||||
String terminalAction = switch (normalizedState) {
|
||||
case "SUCCEEDED" -> "SYSTEM_UPDATE_SUCCEEDED";
|
||||
case "RECOVERY_REQUIRED" -> "SYSTEM_UPDATE_RECOVERY_REQUIRED";
|
||||
default -> "SYSTEM_UPDATE_FAILED";
|
||||
};
|
||||
String result = "SUCCEEDED".equals(normalizedState)
|
||||
? "SUCCESS" : ("RECOVERY_REQUIRED".equals(normalizedState) ? "BLOCKED" : "FAILED");
|
||||
String terminalReason = truncate(sanitizeReason(clean(message, 1000)), 500);
|
||||
|
||||
Map<String, Object> terminal = new LinkedHashMap<>();
|
||||
terminal.put("state", normalizedState);
|
||||
terminal.put("targetVersion", normalizedVersion);
|
||||
terminal.put("message", terminalReason == null ? "" : terminalReason);
|
||||
terminal.put("updatedAt", updatedAt);
|
||||
if (normalizedAction != null) terminal.put("action", normalizedAction);
|
||||
if (normalizedRequestId != null) terminal.put("requestId", normalizedRequestId);
|
||||
|
||||
Actor actor = source == null
|
||||
? currentActor() : new Actor(source.userPublicId(), source.username(), source.activeRole());
|
||||
AuditEntry entry = new AuditEntry(
|
||||
ulidGenerator.next(), source == null ? RequestContext.requestId() : source.requestId(),
|
||||
actor.publicId(), actor.username(), actor.activeRole(),
|
||||
source == null ? null : source.companyPublicId(), source == null ? null : source.projectPublicId(),
|
||||
terminalAction, "SYSTEM_UPDATE", "SYSTEM_UPDATE", result, terminalReason,
|
||||
source == null ? null : source.afterJson(), json(terminal),
|
||||
source == null ? clientIp() : source.ipAddress(), source == null
|
||||
? truncate(request.getHeader("User-Agent"), 500) : source.userAgent()
|
||||
);
|
||||
auditMapper.insertSystemUpdateTerminal(entry, dedupeKey,
|
||||
LocalDateTime.ofInstant(updatedAt, ZoneOffset.UTC));
|
||||
return dedupeKey;
|
||||
}
|
||||
|
||||
private void insert(Actor actor, String companyPublicId, String projectPublicId, String action,
|
||||
String objectType, String objectPublicId, String result, String reason,
|
||||
Object before, Object after) {
|
||||
@@ -76,6 +151,49 @@ public class AuditService {
|
||||
return new Actor(null, null, null);
|
||||
}
|
||||
|
||||
private AuditMapper.SystemUpdateAuditSource findSystemUpdateSource(String requestId, String targetVersion,
|
||||
String actionCode, Instant completedAt) {
|
||||
AuditMapper.SystemUpdateAuditSource source = requestId == null
|
||||
? null : auditMapper.findSystemUpdateSourceByRequestId(requestId, actionCode);
|
||||
return source == null ? auditMapper.findSystemUpdateSourceByVersion(targetVersion, actionCode,
|
||||
LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC)) : source;
|
||||
}
|
||||
|
||||
private static String normalizeTerminalState(String state) {
|
||||
String normalized = clean(state, 32);
|
||||
if (normalized == null) return null;
|
||||
normalized = normalized.toUpperCase(Locale.ROOT);
|
||||
return switch (normalized) {
|
||||
case "SUCCEEDED", "FAILED", "RECOVERY_REQUIRED" -> normalized;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private static String sourceAction(String state, String action) {
|
||||
if ("SUCCEEDED".equals(state) || "INSTALL".equals(action)) return "SYSTEM_UPDATE_REQUEST";
|
||||
if ("DOWNLOAD".equals(action)) return "SYSTEM_UPDATE_DOWNLOAD_REQUEST";
|
||||
return "SYSTEM_UPDATE_REQUEST";
|
||||
}
|
||||
|
||||
private static boolean validRequestId(String requestId) {
|
||||
return requestId != null && requestId.matches("^[0-9A-HJKMNP-TV-Z]{26}$");
|
||||
}
|
||||
|
||||
private static String clean(String value, int max) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
String cleaned = value.trim();
|
||||
return cleaned.length() <= max ? cleaned : cleaned.substring(0, max);
|
||||
}
|
||||
|
||||
private static String sha256(String value) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String json(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
|
||||
+63
-8
@@ -36,12 +36,16 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class SystemUpdateApplicationService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SystemUpdateApplicationService.class);
|
||||
|
||||
private static final int MAX_MANIFEST_BYTES = 64 * 1024;
|
||||
private static final int MAX_RELEASE_API_BYTES = 256 * 1024;
|
||||
private static final int MAX_STATUS_BYTES = 64 * 1024;
|
||||
@@ -71,6 +75,7 @@ public class SystemUpdateApplicationService {
|
||||
private final HttpClient httpClient;
|
||||
private volatile ReleaseManifest lastManifest;
|
||||
private volatile Instant lastCheckedAt;
|
||||
private volatile String lastTerminalAuditFingerprint;
|
||||
|
||||
public SystemUpdateApplicationService(SystemUpdateProperties properties,
|
||||
AuthorizationService authorizationService,
|
||||
@@ -90,7 +95,9 @@ public class SystemUpdateApplicationService {
|
||||
|
||||
public SystemUpdateView status() {
|
||||
requireIsolatedSystemAdministrator();
|
||||
return view(lastManifest, readStatus());
|
||||
UpdateStatus status = readStatus();
|
||||
persistTerminalAudit(status);
|
||||
return view(lastManifest, status);
|
||||
}
|
||||
|
||||
public SystemUpdateView check() {
|
||||
@@ -99,7 +106,9 @@ public class SystemUpdateApplicationService {
|
||||
ReleaseManifest manifest = fetchManifest();
|
||||
lastManifest = manifest;
|
||||
lastCheckedAt = Instant.now();
|
||||
SystemUpdateView result = view(manifest, readStatus());
|
||||
UpdateStatus status = readStatus();
|
||||
persistTerminalAudit(status);
|
||||
SystemUpdateView result = view(manifest, status);
|
||||
auditService.record("SYSTEM_UPDATE_CHECK", "SYSTEM_UPDATE", "SYSTEM_UPDATE", "SUCCESS", null, null,
|
||||
result);
|
||||
return result;
|
||||
@@ -120,6 +129,7 @@ public class SystemUpdateApplicationService {
|
||||
throw validation("当前已是相同或更高版本");
|
||||
}
|
||||
UpdateStatus currentStatus = readStatus();
|
||||
persistTerminalAudit(currentStatus);
|
||||
if ("READY".equals(currentStatus.state()) && requested.equals(currentStatus.targetVersion())) {
|
||||
throw validation("该版本已经下载并通过校验,请确认安装");
|
||||
}
|
||||
@@ -137,6 +147,7 @@ public class SystemUpdateApplicationService {
|
||||
requireConfigured();
|
||||
String requested = request.version().trim();
|
||||
UpdateStatus ready = readStatus();
|
||||
persistTerminalAudit(ready);
|
||||
if (!"READY".equals(ready.state()) || !requested.equals(ready.targetVersion())) {
|
||||
throw validation("该版本尚未完成下载和签名校验");
|
||||
}
|
||||
@@ -369,7 +380,8 @@ public class SystemUpdateApplicationService {
|
||||
"已有系统更新请求等待执行");
|
||||
}
|
||||
writeRequest(requestFile, version, reason, action);
|
||||
UpdateStatus queued = new UpdateStatus(queuedState, queuedMessage, version, Instant.now());
|
||||
UpdateStatus queued = new UpdateStatus(queuedState, queuedMessage, version, Instant.now(),
|
||||
RequestContext.requestId(), action);
|
||||
return new QueueTransition(currentStatus, queued);
|
||||
} catch (IOException exception) {
|
||||
throw storage("系统更新队列锁定失败");
|
||||
@@ -431,7 +443,8 @@ public class SystemUpdateApplicationService {
|
||||
blank(root.path("targetVersion").asText(null)), parseInstant(root.path("updatedAt").asText(null)),
|
||||
nonNegativeLong(root, "downloadedBytes"), nonNegativeLong(root, "totalBytes"),
|
||||
nonNegativeLong(root, "bytesPerSecond"), boundedInteger(root, "downloadPercent", 0, 100),
|
||||
boundedInteger(root, "restartExpectedSeconds", 0, 300));
|
||||
boundedInteger(root, "restartExpectedSeconds", 0, 300),
|
||||
boundedText(root, "requestId", 64), updateAction(root.path("action").asText(null)));
|
||||
} catch (IOException exception) {
|
||||
return new UpdateStatus("UNKNOWN", "更新状态读取失败", null, null);
|
||||
}
|
||||
@@ -525,10 +538,13 @@ public class SystemUpdateApplicationService {
|
||||
String version = blank(root.path("version").asText(null));
|
||||
String action = root.path("action").asText("INSTALL").toUpperCase(Locale.ROOT);
|
||||
Instant requestedAt = parseInstant(root.path("requestedAt").asText(null));
|
||||
String requestId = boundedText(root, "requestId", 64);
|
||||
if ("DOWNLOAD".equals(action)) {
|
||||
return new UpdateStatus("DOWNLOAD_QUEUED", "下载请求已排队,等待更新服务处理", version, requestedAt);
|
||||
return new UpdateStatus("DOWNLOAD_QUEUED", "下载请求已排队,等待更新服务处理", version,
|
||||
requestedAt, requestId, action);
|
||||
}
|
||||
return new UpdateStatus("INSTALL_QUEUED", "安装请求已排队,等待更新服务处理", version, requestedAt);
|
||||
return new UpdateStatus("INSTALL_QUEUED", "安装请求已排队,等待更新服务处理", version,
|
||||
requestedAt, requestId, action);
|
||||
} catch (IOException exception) {
|
||||
return new UpdateStatus("UNKNOWN", "更新请求读取失败", null, null);
|
||||
}
|
||||
@@ -631,6 +647,39 @@ public class SystemUpdateApplicationService {
|
||||
return parsed < minimum || parsed > maximum ? null : parsed;
|
||||
}
|
||||
|
||||
private static String boundedText(JsonNode root, String field, int maximum) {
|
||||
JsonNode value = root.path(field);
|
||||
if (!value.isTextual()) return null;
|
||||
String parsed = blank(value.asText(null));
|
||||
return parsed != null && parsed.length() <= maximum ? parsed : null;
|
||||
}
|
||||
|
||||
private static String updateAction(String value) {
|
||||
String action = blank(value);
|
||||
if (action == null) return null;
|
||||
action = action.toUpperCase(Locale.ROOT);
|
||||
return "DOWNLOAD".equals(action) || "INSTALL".equals(action) ? action : null;
|
||||
}
|
||||
|
||||
private void persistTerminalAudit(UpdateStatus status) {
|
||||
if (status == null || status.updatedAt() == null || status.targetVersion() == null
|
||||
|| !("SUCCEEDED".equals(status.state()) || "FAILED".equals(status.state())
|
||||
|| "RECOVERY_REQUIRED".equals(status.state()))) {
|
||||
return;
|
||||
}
|
||||
String fingerprint = String.join("|", status.state(), String.valueOf(status.requestId()),
|
||||
status.targetVersion(), status.updatedAt().toString(), String.valueOf(status.action()));
|
||||
if (fingerprint.equals(lastTerminalAuditFingerprint)) return;
|
||||
try {
|
||||
String persistedKey = auditService.recordSystemUpdateTerminal(status.requestId(), status.action(),
|
||||
status.state(), status.targetVersion(), status.message(), status.updatedAt());
|
||||
if (persistedKey != null) lastTerminalAuditFingerprint = fingerprint;
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("系统更新终态审计写入失败:state={}, targetVersion={}", status.state(),
|
||||
status.targetVersion(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private BusinessException validation(String message) {
|
||||
return new BusinessException(HttpStatus.UNPROCESSABLE_ENTITY, ErrorCode.VALIDATION_FAILED, message);
|
||||
}
|
||||
@@ -649,9 +698,15 @@ public class SystemUpdateApplicationService {
|
||||
|
||||
private record UpdateStatus(String state, String message, String targetVersion, Instant updatedAt,
|
||||
Long downloadedBytes, Long totalBytes, Long bytesPerSecond,
|
||||
Integer downloadPercent, Integer restartExpectedSeconds) {
|
||||
Integer downloadPercent, Integer restartExpectedSeconds,
|
||||
String requestId, String action) {
|
||||
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt) {
|
||||
this(state, message, targetVersion, updatedAt, null, null, null, null, null);
|
||||
this(state, message, targetVersion, updatedAt, null, null, null, null, null, null, null);
|
||||
}
|
||||
|
||||
private UpdateStatus(String state, String message, String targetVersion, Instant updatedAt,
|
||||
String requestId, String action) {
|
||||
this(state, message, targetVersion, updatedAt, null, null, null, null, null, requestId, action);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE audit_log
|
||||
ADD COLUMN dedupe_key VARCHAR(96) NULL AFTER user_agent,
|
||||
ADD UNIQUE KEY uk_audit_log_dedupe_key (dedupe_key);
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.kaidi.finance.shared.audit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kaidi.finance.shared.id.UlidGenerator;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
class AuditServiceTest {
|
||||
|
||||
@Test
|
||||
void terminalUpdateInheritsTheOriginalActorAndProducesAStableDedupeKey() {
|
||||
AuditMapper mapper = mock(AuditMapper.class);
|
||||
UlidGenerator ulids = mock(UlidGenerator.class);
|
||||
when(ulids.next()).thenReturn("01M00000000000000000000999");
|
||||
when(mapper.findSystemUpdateSourceByRequestId(
|
||||
"01M00000000000000000000092", "SYSTEM_UPDATE_REQUEST"))
|
||||
.thenReturn(new AuditMapper.SystemUpdateAuditSource(
|
||||
"01M00000000000000000000092", "01M00000000000000000000001", "admin", "SYSTEM_ADMIN",
|
||||
null, null, "{\"state\":\"INSTALL_QUEUED\",\"targetVersion\":\"1.0.0-preview.44\"}",
|
||||
"127.0.0.1", "fixture-agent"));
|
||||
when(mapper.insertSystemUpdateTerminal(any(), anyString(), any())).thenReturn(1);
|
||||
AuditService service = new AuditService(mapper, ulids, new ObjectMapper().findAndRegisterModules(),
|
||||
new MockHttpServletRequest("GET", "/api/v1/admin/system-update"));
|
||||
Instant completedAt = Instant.parse("2026-08-19T00:10:00Z");
|
||||
|
||||
String firstKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
|
||||
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt);
|
||||
String secondKey = service.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
|
||||
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查", completedAt);
|
||||
|
||||
assertEquals(firstKey, secondKey);
|
||||
assertTrue(firstKey.startsWith("SYSUPD:"));
|
||||
ArgumentCaptor<AuditEntry> entry = ArgumentCaptor.forClass(AuditEntry.class);
|
||||
verify(mapper, org.mockito.Mockito.times(2)).insertSystemUpdateTerminal(entry.capture(),
|
||||
org.mockito.ArgumentMatchers.eq(firstKey), any());
|
||||
AuditEntry persisted = entry.getAllValues().get(0);
|
||||
assertEquals("01M00000000000000000000092", persisted.requestId());
|
||||
assertEquals("01M00000000000000000000001", persisted.userPublicId());
|
||||
assertEquals("SYSTEM_UPDATE_SUCCEEDED", persisted.actionCode());
|
||||
assertEquals("SUCCESS", persisted.resultCode());
|
||||
assertTrue(persisted.beforeJson().contains("INSTALL_QUEUED"));
|
||||
assertTrue(persisted.afterJson().contains("1.0.0-preview.44"));
|
||||
assertTrue(persisted.afterJson().contains("SUCCEEDED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyTerminalStatusFindsTheRecentInstallRequestByTargetVersion() {
|
||||
AuditMapper mapper = mock(AuditMapper.class);
|
||||
UlidGenerator ulids = mock(UlidGenerator.class);
|
||||
when(ulids.next()).thenReturn("01M00000000000000000000998");
|
||||
LocalDateTime completedAt = LocalDateTime.parse("2026-08-18T23:50:00");
|
||||
when(mapper.findSystemUpdateSourceByVersion(
|
||||
"1.0.0-preview.43", "SYSTEM_UPDATE_REQUEST", completedAt))
|
||||
.thenReturn(new AuditMapper.SystemUpdateAuditSource(
|
||||
"01M00000000000000000000088", "01M00000000000000000000001", "admin", "SYSTEM_ADMIN",
|
||||
null, null, "{\"state\":\"INSTALL_QUEUED\",\"targetVersion\":\"1.0.0-preview.43\"}",
|
||||
"127.0.0.1", "fixture-agent"));
|
||||
when(mapper.insertSystemUpdateTerminal(any(), anyString(), any())).thenReturn(1);
|
||||
AuditService service = new AuditService(mapper, ulids, new ObjectMapper().findAndRegisterModules(),
|
||||
new MockHttpServletRequest("GET", "/api/v1/admin/system-update"));
|
||||
|
||||
service.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43",
|
||||
"Release 1.0.0-preview.43 is running", Instant.parse("2026-08-18T23:50:00Z"));
|
||||
|
||||
verify(mapper).findSystemUpdateSourceByVersion(
|
||||
"1.0.0-preview.43", "SYSTEM_UPDATE_REQUEST", completedAt);
|
||||
ArgumentCaptor<AuditEntry> entry = ArgumentCaptor.forClass(AuditEntry.class);
|
||||
verify(mapper).insertSystemUpdateTerminal(entry.capture(), anyString(), any());
|
||||
assertEquals("01M00000000000000000000088", entry.getValue().requestId());
|
||||
assertEquals("SYSTEM_UPDATE_SUCCEEDED", entry.getValue().actionCode());
|
||||
}
|
||||
}
|
||||
+60
@@ -6,6 +6,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -286,6 +288,64 @@ class SystemUpdateApplicationServiceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistsCorrelatedTerminalUpdateAuditOnlyOncePerApplicationProcess() throws Exception {
|
||||
Path inbox = Files.createDirectory(tempDir.resolve("terminal-inbox"));
|
||||
Path statusFile = tempDir.resolve("terminal-status.json");
|
||||
Files.writeString(statusFile, """
|
||||
{"state":"SUCCEEDED","message":"新版本已通过健康检查","targetVersion":"1.0.0-preview.44",
|
||||
"updatedAt":"2026-08-19T00:10:00Z","requestId":"01M00000000000000000000092",
|
||||
"action":"INSTALL"}
|
||||
""");
|
||||
AuditService auditService = mock(AuditService.class);
|
||||
when(auditService.recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL", "SUCCEEDED",
|
||||
"1.0.0-preview.44", "新版本已通过健康检查", java.time.Instant.parse("2026-08-19T00:10:00Z")))
|
||||
.thenReturn("SYSUPD:terminal");
|
||||
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService);
|
||||
|
||||
assertEquals("SUCCEEDED", service.status().state());
|
||||
assertEquals("SUCCEEDED", service.status().state());
|
||||
|
||||
verify(auditService, times(1)).recordSystemUpdateTerminal("01M00000000000000000000092", "INSTALL",
|
||||
"SUCCEEDED", "1.0.0-preview.44", "新版本已通过健康检查",
|
||||
java.time.Instant.parse("2026-08-19T00:10:00Z"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistsLegacyTerminalStatusWithoutRequestCorrelation() throws Exception {
|
||||
Path inbox = Files.createDirectory(tempDir.resolve("legacy-terminal-inbox"));
|
||||
Path statusFile = tempDir.resolve("legacy-terminal-status.json");
|
||||
Files.writeString(statusFile, """
|
||||
{"state":"SUCCEEDED","message":"Release 1.0.0-preview.43 is running",
|
||||
"targetVersion":"1.0.0-preview.43","updatedAt":"2026-08-18T23:50:00Z"}
|
||||
""");
|
||||
AuditService auditService = mock(AuditService.class);
|
||||
when(auditService.recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43",
|
||||
"Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z")))
|
||||
.thenReturn("SYSUPD:legacy");
|
||||
SystemUpdateApplicationService service = serviceForStatus(inbox, statusFile, auditService);
|
||||
|
||||
assertEquals("SUCCEEDED", service.status().state());
|
||||
|
||||
verify(auditService).recordSystemUpdateTerminal(null, null, "SUCCEEDED", "1.0.0-preview.43",
|
||||
"Release 1.0.0-preview.43 is running", java.time.Instant.parse("2026-08-18T23:50:00Z"));
|
||||
}
|
||||
|
||||
private SystemUpdateApplicationService serviceForStatus(Path inbox, Path statusFile, AuditService auditService) {
|
||||
SystemUpdateProperties properties = new SystemUpdateProperties(true, "1.0.0-preview.43", null,
|
||||
"https://release.fixture.invalid/", null, null, inbox.resolve("request.json"), statusFile,
|
||||
Duration.ofSeconds(2), Duration.ofSeconds(2), true);
|
||||
AuthorizationService authorization = mock(AuthorizationService.class);
|
||||
when(authorization.hasPermission(any())).thenReturn(true);
|
||||
IdentityContext identity = mock(IdentityContext.class);
|
||||
when(identity.requireActiveRole()).thenReturn("SYSTEM_ADMIN");
|
||||
when(identity.requirePrincipal()).thenReturn(new FinancePrincipal(1, "01M00000000000000000000001",
|
||||
"admin", "系统管理员", "信息中心", false,
|
||||
List.of(new RoleAssignment(1, "SYSTEM_ADMIN", "系统管理员", "系统治理"))));
|
||||
return new SystemUpdateApplicationService(properties, authorization, identity, auditService,
|
||||
new ObjectMapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
void checksConfiguredManifestAndQueuesOnlyItsLatestVersion() throws Exception {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
|
||||
@@ -15,7 +15,7 @@ FILE_SCANNER_ENABLED=true
|
||||
FINANCE_BOOTSTRAP_ENABLED=false
|
||||
FINANCE_BOOTSTRAP_PASSWORD=
|
||||
|
||||
APP_VERSION=1.0.0-preview.42
|
||||
APP_VERSION=1.0.0-preview.46
|
||||
UPDATE_CURRENT_VERSION_FILE=/opt/kaidi/current/VERSION
|
||||
FINANCE_UPDATE_ENABLED=true
|
||||
# Use either a stable direct asset base URL or the public Gitea latest-release API.
|
||||
|
||||
@@ -41,6 +41,8 @@ WORK_DIR=
|
||||
CACHE_TEMP=
|
||||
RELEASE_AUTH_HEADER_FILE=
|
||||
TARGET_VERSION=
|
||||
REQUEST_ID=
|
||||
REQUEST_ACTION=
|
||||
TERMINAL_STATUS_WRITTEN=false
|
||||
DOWNLOAD_PID=
|
||||
DOWNLOAD_PGID=
|
||||
@@ -293,10 +295,26 @@ status() {
|
||||
restart_expected_seconds=${8:-}
|
||||
previous_state=
|
||||
previous_message=
|
||||
previous_request_id=
|
||||
previous_action=
|
||||
if [ -f "$STATUS_FILE" ] && [ ! -L "$STATUS_FILE" ]; then
|
||||
previous_state=$(jq -r '.state // empty' "$STATUS_FILE" 2>/dev/null || true)
|
||||
previous_message=$(jq -r '.message // empty' "$STATUS_FILE" 2>/dev/null || true)
|
||||
previous_request_id=$(jq -r '.requestId // empty | strings | select(length <= 64)' \
|
||||
"$STATUS_FILE" 2>/dev/null || true)
|
||||
previous_action=$(jq -r '.action // empty | strings | ascii_upcase \
|
||||
| select(. == "DOWNLOAD" or . == "INSTALL")' "$STATUS_FILE" 2>/dev/null || true)
|
||||
fi
|
||||
status_request_id=
|
||||
status_action=
|
||||
if [ -f "$PROCESSING_FILE" ] && [ ! -L "$PROCESSING_FILE" ]; then
|
||||
status_request_id=$(jq -r '.requestId // empty | strings | select(length > 0 and length <= 64)' \
|
||||
"$PROCESSING_FILE" 2>/dev/null || true)
|
||||
status_action=$(jq -r '.action // empty | strings | ascii_upcase \
|
||||
| select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE" 2>/dev/null || true)
|
||||
fi
|
||||
[ -n "$status_request_id" ] || status_request_id=${REQUEST_ID:-$previous_request_id}
|
||||
[ -n "$status_action" ] || status_action=${REQUEST_ACTION:-$previous_action}
|
||||
tmp="$STATUS_FILE.tmp.$$"
|
||||
jq -n \
|
||||
--arg state "$state" \
|
||||
@@ -307,9 +325,13 @@ status() {
|
||||
--arg bytesPerSecond "$bytes_per_second" \
|
||||
--arg downloadPercent "$download_percent" \
|
||||
--arg restartExpectedSeconds "$restart_expected_seconds" \
|
||||
--arg requestId "$status_request_id" \
|
||||
--arg action "$status_action" \
|
||||
--arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'{state:$state,message:$message,updatedAt:$updatedAt}
|
||||
+ (if ($version | length) > 0 then {targetVersion:$version} else {} end)
|
||||
+ (if ($requestId | length) > 0 then {requestId:$requestId} else {} end)
|
||||
+ (if ($action == "DOWNLOAD" or $action == "INSTALL") then {action:$action} else {} end)
|
||||
+ (if ($downloadedBytes | test("^[0-9]+$")) then {downloadedBytes:($downloadedBytes | tonumber)} else {} end)
|
||||
+ (if ($totalBytes | test("^[0-9]+$")) then {totalBytes:($totalBytes | tonumber)} else {} end)
|
||||
+ (if ($bytesPerSecond | test("^[0-9]+$")) then {bytesPerSecond:($bytesPerSecond | tonumber)} else {} end)
|
||||
@@ -1035,6 +1057,8 @@ TARGET_VERSION=$REQUESTED_VERSION
|
||||
REQUEST_ACTION=$(jq -er '(.action // "INSTALL") | strings | ascii_upcase
|
||||
| select(. == "DOWNLOAD" or . == "INSTALL")' "$PROCESSING_FILE") \
|
||||
|| fail "Update request action is invalid"
|
||||
REQUEST_ID=$(jq -r '.requestId // empty | strings | select(length > 0 and length <= 64)' \
|
||||
"$PROCESSING_FILE" 2>/dev/null || true)
|
||||
prepare_update_layout
|
||||
reset_event_log
|
||||
load_runtime_database_env
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const permissions = ['admin:user:view', 'admin:user:create', 'admin:update:view', 'admin:update:execute'];
|
||||
const permissions = [
|
||||
'admin:user:view',
|
||||
'admin:user:create',
|
||||
'admin:update:view',
|
||||
'admin:update:execute',
|
||||
'audit:log:view',
|
||||
];
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return { data, requestId: '01M00000000000000000000090' };
|
||||
@@ -111,8 +117,13 @@ async function installFixture(
|
||||
let state = recoveryPending ? 'RECOVERY_REQUIRED' : initialState || 'IDLE';
|
||||
let progressDeadline = 0;
|
||||
let restartDeadline = 0;
|
||||
let terminalHistoryRecorded = false;
|
||||
const history: Array<Record<string, unknown>> = [];
|
||||
const recordHistory = (actionCode: string, targetVersion = '1.0.0-preview.2') => {
|
||||
const recordHistory = (
|
||||
actionCode: string,
|
||||
targetVersion = '1.0.0-preview.2',
|
||||
reason = actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即更新并重启' : null,
|
||||
) => {
|
||||
history.unshift({
|
||||
publicId: `01M00000000000000000000${String(history.length + 1).padStart(3, '0')}`,
|
||||
eventSequence: history.length + 1,
|
||||
@@ -123,9 +134,13 @@ async function installFixture(
|
||||
objectType: 'SYSTEM_UPDATE',
|
||||
objectPublicId: 'SYSTEM_UPDATE',
|
||||
resultCode: 'SUCCESS',
|
||||
reason: actionCode === 'SYSTEM_UPDATE_REQUEST' ? '管理员确认立即更新并重启' : null,
|
||||
reason,
|
||||
beforeJson: null,
|
||||
afterJson: JSON.stringify({ targetVersion }),
|
||||
afterJson: JSON.stringify({
|
||||
targetVersion,
|
||||
...(actionCode === 'SYSTEM_UPDATE_CHECK' ? { releaseNotes: 'Preview update' } : {}),
|
||||
...(actionCode === 'SYSTEM_UPDATE_SUCCEEDED' ? { state: 'SUCCEEDED' } : {}),
|
||||
}),
|
||||
occurredAt: '2026-08-16T00:02:00Z',
|
||||
allowedActions: [],
|
||||
});
|
||||
@@ -156,6 +171,10 @@ async function installFixture(
|
||||
return route.abort('connectionrefused');
|
||||
}
|
||||
if (state === 'RUNNING' && restartDeadline > 0) state = 'SUCCEEDED';
|
||||
if (state === 'SUCCEEDED' && !terminalHistoryRecorded) {
|
||||
recordHistory('SYSTEM_UPDATE_SUCCEEDED', '1.0.0-preview.2', '新版本已通过健康检查');
|
||||
terminalHistoryRecorded = true;
|
||||
}
|
||||
const response = updateView(state, checked, enabled, recoveryPending);
|
||||
if (state === 'DOWNLOADING' && Date.now() >= progressDeadline) state = 'READY';
|
||||
if (completeBusyAfterFirstRead && state === 'INSTALLING') state = 'SUCCEEDED';
|
||||
@@ -192,6 +211,9 @@ async function installFixture(
|
||||
return route.fulfill({ json: envelope(queued) });
|
||||
}
|
||||
if (pathname === '/api/v1/audit/logs' && request.method() === 'GET') {
|
||||
const query = new URL(request.url()).searchParams;
|
||||
expect(query.get('occurredFrom')).toBe('1970-01-01T00:00:00Z');
|
||||
expect(query.get('objectType')).toBe('SYSTEM_UPDATE');
|
||||
return route.fulfill({
|
||||
json: {
|
||||
data: history,
|
||||
@@ -393,6 +415,18 @@ test('successful installation starts the ten-second automatic refresh countdown'
|
||||
await expect(page.getByText('新版本已通过健康检查,页面将在 10 秒后自动刷新。', { exact: true })).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const historyPanel = page.locator('.history-panel');
|
||||
const timelineItem = historyPanel.locator('.update-timeline__item[data-version="1.0.0-preview.2"]');
|
||||
await expect(timelineItem).toHaveCount(1);
|
||||
await expect(timelineItem).toContainText('更新完成');
|
||||
await expect(timelineItem).toContainText('新版本已通过健康检查');
|
||||
await expect(timelineItem).toContainText('Preview update');
|
||||
await timelineItem.getByRole('button', { name: '查看完整记录', exact: true }).click();
|
||||
const historyDialog = page.getByRole('dialog', { name: '更新日志详情' });
|
||||
await expect(historyDialog).toContainText('获取版本');
|
||||
await expect(historyDialog).toContainText('下载更新包');
|
||||
await expect(historyDialog).toContainText('立即更新并重启');
|
||||
await expect(historyDialog).toContainText('更新完成');
|
||||
});
|
||||
|
||||
test('reloading during installation resumes polling and starts the refresh countdown', async ({ page }) => {
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
<div class="system-update-page">
|
||||
<h1 class="page-title-sr-only">系统更新</h1>
|
||||
|
||||
<header class="page-header page-header--actions-only" aria-label="页面操作">
|
||||
<t-button variant="outline" :loading="statusLoading || historyLoading" @click="refreshPage">
|
||||
<template #icon><t-icon name="refresh" /></template>
|
||||
刷新状态
|
||||
</t-button>
|
||||
</header>
|
||||
|
||||
<section class="update-panel" aria-label="系统版本与更新操作">
|
||||
<header class="update-toolbar" aria-label="系统更新工具栏">
|
||||
<div class="update-toolbar__copy">
|
||||
<div class="update-toolbar__title">系统更新</div>
|
||||
<p>获取版本、下载校验并安全重启应用</p>
|
||||
</div>
|
||||
<t-button variant="outline" :loading="statusLoading || historyLoading" @click="refreshPage">
|
||||
<template #icon><t-icon name="refresh" /></template>
|
||||
刷新状态
|
||||
</t-button>
|
||||
</header>
|
||||
|
||||
<div class="update-heading">
|
||||
<div class="version-grid">
|
||||
<div class="version-item">
|
||||
@@ -107,9 +111,9 @@
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>历史更新记录</h2>
|
||||
<p>记录版本获取、下载和重启安装操作。</p>
|
||||
<p>按版本汇总获取、下载、重启安装和最终执行结果。</p>
|
||||
</div>
|
||||
<span>最近 {{ historyRows.length }} 条</span>
|
||||
<span>最近 {{ historyTimeline.length }} 个版本 · {{ historyRows.length }} 条记录</span>
|
||||
</div>
|
||||
|
||||
<t-alert v-if="historyError" theme="error" :close-btn="false">
|
||||
@@ -118,23 +122,65 @@
|
||||
<t-link theme="primary" @click="() => loadHistory()">重新加载</t-link>
|
||||
</div>
|
||||
</t-alert>
|
||||
<t-table
|
||||
v-if="historyLoading || historyRows.length"
|
||||
:columns="historyColumns"
|
||||
:data="historyRows"
|
||||
row-key="publicId"
|
||||
:loading="historyLoading"
|
||||
table-layout="fixed"
|
||||
:horizontal-scroll-affixed-bottom="true"
|
||||
>
|
||||
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template>
|
||||
<template #actionCode="{ row }">{{ actionLabel(row.actionCode) }}</template>
|
||||
<template #version="{ row }">{{ historyVersion(row) }}</template>
|
||||
<template #resultCode="{ row }">
|
||||
<t-tag :theme="resultTheme(row.resultCode)" variant="light">{{ resultLabel(row.resultCode) }}</t-tag>
|
||||
</template>
|
||||
<template #reason="{ row }">{{ row.reason || '-' }}</template>
|
||||
</t-table>
|
||||
<div v-if="historyLoading" class="history-loading" aria-live="polite">
|
||||
<t-loading text="正在加载历史更新记录" />
|
||||
</div>
|
||||
<div v-else-if="historyTimeline.length" class="update-timeline" aria-label="按版本排列的更新时间线">
|
||||
<article
|
||||
v-for="item in historyTimeline"
|
||||
:key="item.key"
|
||||
class="update-timeline__item"
|
||||
:data-version="item.version"
|
||||
>
|
||||
<div class="update-timeline__rail" aria-hidden="true">
|
||||
<span class="update-timeline__marker" :class="`update-timeline__marker--${resultTheme(item.resultCode)}`">
|
||||
<t-icon :name="item.resultCode === 'SUCCESS' ? 'check' : 'error-circle'" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="timeline-card">
|
||||
<div class="timeline-card__header">
|
||||
<div class="timeline-card__identity">
|
||||
<strong>{{ item.version }}</strong>
|
||||
<span>{{ formatDateTime(item.occurredAt) }} · {{ item.username || '系统' }}</span>
|
||||
</div>
|
||||
<t-tag :theme="resultTheme(item.resultCode)" variant="light">{{ resultLabel(item.resultCode) }}</t-tag>
|
||||
</div>
|
||||
|
||||
<div class="timeline-card__summary">
|
||||
<span class="timeline-card__label">执行流程</span>
|
||||
<span>{{ item.operations.join(' · ') }}</span>
|
||||
<span class="timeline-card__count">{{ item.steps.length }} 条记录</span>
|
||||
</div>
|
||||
<p class="timeline-card__reason">{{ item.reason || '已记录本次版本更新操作。' }}</p>
|
||||
|
||||
<div v-if="item.releaseNotes" class="timeline-card__notes">
|
||||
<span>版本说明</span>
|
||||
<p>{{ item.releaseNotes }}</p>
|
||||
</div>
|
||||
|
||||
<div class="timeline-steps" aria-label="更新步骤">
|
||||
<div v-for="step in item.steps" :key="step.row.publicId" class="timeline-step">
|
||||
<span class="timeline-step__dot" :class="`timeline-step__dot--${resultTheme(step.row.resultCode)}`" />
|
||||
<div class="timeline-step__body">
|
||||
<div class="timeline-step__heading">
|
||||
<strong>{{ actionLabel(step.row.actionCode) }}</strong>
|
||||
<t-tag size="small" :theme="resultTheme(step.row.resultCode)" variant="light">
|
||||
{{ resultLabel(step.row.resultCode) }}
|
||||
</t-tag>
|
||||
<time>{{ formatDateTime(step.row.occurredAt) }}</time>
|
||||
</div>
|
||||
<p>{{ step.row.reason || '操作已记录' }} · {{ step.row.username || '系统' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-card__footer">
|
||||
<span>最后更新:{{ formatDateTime(item.occurredAt) }}</span>
|
||||
<t-button variant="text" size="small" @click="openHistoryDetail(item)">查看完整记录</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<t-empty v-else description="暂无系统更新记录" />
|
||||
</section>
|
||||
|
||||
@@ -219,11 +265,47 @@
|
||||
</div>
|
||||
</div>
|
||||
</t-dialog>
|
||||
|
||||
<t-dialog
|
||||
v-model:visible="historyDetailVisible"
|
||||
header="更新日志详情"
|
||||
aria-label="更新日志详情"
|
||||
width="min(680px, calc(100vw - 32px))"
|
||||
:footer="false"
|
||||
>
|
||||
<div v-if="selectedHistory" class="history-detail" role="dialog" aria-label="更新日志详情" aria-modal="true">
|
||||
<div class="history-detail__header">
|
||||
<div>
|
||||
<span>目标版本</span>
|
||||
<strong>{{ selectedHistory.version }}</strong>
|
||||
</div>
|
||||
<t-tag :theme="resultTheme(selectedHistory.resultCode)" variant="light">
|
||||
{{ resultLabel(selectedHistory.resultCode) }}
|
||||
</t-tag>
|
||||
</div>
|
||||
<p class="history-detail__reason">{{ selectedHistory.reason || '已记录本次版本更新操作。' }}</p>
|
||||
<div v-if="selectedHistory.releaseNotes" class="history-detail__notes">
|
||||
<span>版本说明</span>
|
||||
<p>{{ selectedHistory.releaseNotes }}</p>
|
||||
</div>
|
||||
<div class="history-detail__steps" aria-label="更新日志">
|
||||
<div v-for="step in selectedHistory.steps" :key="step.row.publicId" class="history-detail__step">
|
||||
<div>
|
||||
<strong>{{ actionLabel(step.row.actionCode) }}</strong>
|
||||
<t-tag size="small" :theme="resultTheme(step.row.resultCode)" variant="light">
|
||||
{{ resultLabel(step.row.resultCode) }}
|
||||
</t-tag>
|
||||
</div>
|
||||
<time>{{ formatDateTime(step.row.occurredAt) }} · {{ step.row.username || '系统' }}</time>
|
||||
<p>{{ step.row.reason || '操作已记录' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { isAxiosError } from 'axios';
|
||||
import type { PrimaryTableCol } from 'tdesign-vue-next';
|
||||
import { MessagePlugin } from 'tdesign-vue-next';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
@@ -240,6 +322,22 @@ import {
|
||||
defineOptions({ name: 'SystemUpdatePage' });
|
||||
|
||||
type TagTheme = 'default' | 'primary' | 'success' | 'warning' | 'danger';
|
||||
interface HistoryTimelineStep {
|
||||
row: AuditLog;
|
||||
}
|
||||
|
||||
interface HistoryTimelineItem {
|
||||
key: string;
|
||||
version: string;
|
||||
occurredAt: string;
|
||||
username: string;
|
||||
resultCode: string;
|
||||
reason: string;
|
||||
operations: string[];
|
||||
releaseNotes: string;
|
||||
steps: HistoryTimelineStep[];
|
||||
}
|
||||
|
||||
const UPDATE_PENDING_KEY = 'kaidi-system-update-pending';
|
||||
|
||||
const updateStatus = ref<SystemUpdateView | null>(null);
|
||||
@@ -255,6 +353,8 @@ const historyError = ref('');
|
||||
const updateRefreshSeconds = ref<number | null>(null);
|
||||
const restartCountdown = ref<number | null>(null);
|
||||
const updateDialogVisible = ref(false);
|
||||
const historyDetailVisible = ref(false);
|
||||
const selectedHistory = ref<HistoryTimelineItem | null>(null);
|
||||
const connectionInterrupted = ref(false);
|
||||
const localEvents = ref<SystemUpdateEvent[]>([]);
|
||||
const awaitingRefresh = ref(false);
|
||||
@@ -383,14 +483,39 @@ const updateStateTheme = computed<TagTheme>(() => {
|
||||
return 'default';
|
||||
});
|
||||
|
||||
const historyColumns: PrimaryTableCol[] = [
|
||||
{ colKey: 'occurredAt', title: '操作时间', width: 180 },
|
||||
{ colKey: 'version', title: '版本', width: 170, ellipsis: true },
|
||||
{ colKey: 'actionCode', title: '操作', width: 150 },
|
||||
{ colKey: 'username', title: '操作人', width: 130, ellipsis: true },
|
||||
{ colKey: 'resultCode', title: '结果', width: 100 },
|
||||
{ colKey: 'reason', title: '说明', minWidth: 220, ellipsis: true },
|
||||
];
|
||||
const historyTimeline = computed<HistoryTimelineItem[]>(() => {
|
||||
const grouped = new Map<string, AuditLog[]>();
|
||||
for (const row of historyRows.value) {
|
||||
const version = historyVersion(row);
|
||||
const key = version === '-' ? `unknown:${row.requestId || row.publicId}` : `version:${version}`;
|
||||
const rows = grouped.get(key) || [];
|
||||
rows.push(row);
|
||||
grouped.set(key, rows);
|
||||
}
|
||||
|
||||
return [...grouped.entries()]
|
||||
.map(([key, rows]) => {
|
||||
const newestFirst = rows.slice().sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt));
|
||||
const terminal = newestFirst.find((row) => isTerminalHistoryAction(row.actionCode));
|
||||
const representative = terminal || newestFirst[0];
|
||||
const chronological = newestFirst.slice().reverse();
|
||||
const operations = [...new Set(chronological.map((row) => actionLabel(row.actionCode)).filter(Boolean))];
|
||||
const releaseNotes = newestFirst.map((row) => historyReleaseNotes(row)).find(Boolean) || '';
|
||||
|
||||
return {
|
||||
key,
|
||||
version: representative ? historyVersion(representative) : '-',
|
||||
occurredAt: representative?.occurredAt || '',
|
||||
username: representative?.username || '',
|
||||
resultCode: representative?.resultCode || 'UNKNOWN',
|
||||
reason: representative?.reason || '',
|
||||
operations,
|
||||
releaseNotes,
|
||||
steps: chronological.map((row) => ({ row })),
|
||||
};
|
||||
})
|
||||
.sort((left, right) => compareHistoryDates(right.occurredAt, left.occurredAt));
|
||||
});
|
||||
|
||||
async function loadStatus(silent = false) {
|
||||
if (!silent) {
|
||||
@@ -442,6 +567,7 @@ async function loadHistory(silent = false) {
|
||||
}
|
||||
try {
|
||||
const result = await getAuditLogs({
|
||||
occurredFrom: '1970-01-01T00:00:00Z',
|
||||
objectType: 'SYSTEM_UPDATE',
|
||||
sort: 'occurredAt,desc',
|
||||
page: 1,
|
||||
@@ -700,26 +826,58 @@ function eventStageLabel(stage: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function compareHistoryDates(left: string, right: string) {
|
||||
const leftTime = Date.parse(left);
|
||||
const rightTime = Date.parse(right);
|
||||
if (Number.isNaN(leftTime) || Number.isNaN(rightTime)) return left.localeCompare(right);
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
|
||||
function isTerminalHistoryAction(action: string) {
|
||||
return ['SYSTEM_UPDATE_SUCCEEDED', 'SYSTEM_UPDATE_FAILED', 'SYSTEM_UPDATE_RECOVERY_REQUIRED'].includes(action);
|
||||
}
|
||||
|
||||
function parseHistoryPayload(source?: string | null) {
|
||||
if (!source) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(source);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function historyVersion(row: AuditLog) {
|
||||
for (const source of [row.afterJson, row.beforeJson]) {
|
||||
if (!source) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(source) as Record<string, unknown>;
|
||||
const version = parsed.targetVersion || parsed.latestVersion || parsed.currentVersion || parsed.version;
|
||||
if (version) return String(version);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseHistoryPayload(source);
|
||||
const version = parsed?.targetVersion || parsed?.latestVersion || parsed?.currentVersion || parsed?.version;
|
||||
if (version !== undefined && version !== null && String(version).trim()) return String(version);
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
function historyReleaseNotes(row: AuditLog) {
|
||||
for (const source of [row.afterJson, row.beforeJson]) {
|
||||
const parsed = parseHistoryPayload(source);
|
||||
if (typeof parsed?.releaseNotes === 'string' && parsed.releaseNotes.trim()) return parsed.releaseNotes;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function openHistoryDetail(item: HistoryTimelineItem) {
|
||||
selectedHistory.value = item;
|
||||
historyDetailVisible.value = true;
|
||||
}
|
||||
|
||||
function actionLabel(action: string) {
|
||||
return (
|
||||
{
|
||||
SYSTEM_UPDATE_CHECK: '获取版本',
|
||||
SYSTEM_UPDATE_DOWNLOAD_REQUEST: '下载更新包',
|
||||
SYSTEM_UPDATE_REQUEST: '立即更新并重启',
|
||||
SYSTEM_UPDATE_SUCCEEDED: '更新完成',
|
||||
SYSTEM_UPDATE_FAILED: '更新失败',
|
||||
SYSTEM_UPDATE_RECOVERY_REQUIRED: '更新需人工恢复',
|
||||
}[action] || action
|
||||
);
|
||||
}
|
||||
@@ -782,7 +940,7 @@ onBeforeUnmount(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.update-toolbar,
|
||||
.update-heading,
|
||||
.update-actions,
|
||||
.section-heading,
|
||||
@@ -791,10 +949,6 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.update-panel,
|
||||
.history-panel {
|
||||
width: 100%;
|
||||
@@ -818,6 +972,31 @@ onBeforeUnmount(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.update-toolbar {
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.update-toolbar__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.update-toolbar__title {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 18px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.update-toolbar__copy p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.version-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||
@@ -897,6 +1076,313 @@ onBeforeUnmount(() => {
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.history-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 160px;
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.update-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.update-timeline__item {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.update-timeline__rail {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.update-timeline__rail::after {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
bottom: -18px;
|
||||
width: 1px;
|
||||
background: var(--td-component-border);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.update-timeline__item:last-child .update-timeline__rail::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.update-timeline__marker {
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--td-text-color-secondary);
|
||||
background: var(--td-bg-color-container);
|
||||
border: 2px solid var(--td-component-border);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.update-timeline__marker--success {
|
||||
color: #fff;
|
||||
background: var(--td-success-color);
|
||||
border-color: var(--td-success-color);
|
||||
}
|
||||
|
||||
.update-timeline__marker--danger {
|
||||
color: #fff;
|
||||
background: var(--td-error-color);
|
||||
border-color: var(--td-error-color);
|
||||
}
|
||||
|
||||
.update-timeline__marker--warning {
|
||||
color: #fff;
|
||||
background: var(--td-warning-color);
|
||||
border-color: var(--td-warning-color);
|
||||
}
|
||||
|
||||
.timeline-card {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
background: var(--td-bg-color-container);
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.timeline-card__header,
|
||||
.timeline-card__summary,
|
||||
.timeline-card__footer,
|
||||
.timeline-step__heading,
|
||||
.history-detail__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-card__header,
|
||||
.timeline-card__footer,
|
||||
.history-detail__header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.timeline-card__identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-card__identity strong {
|
||||
overflow: hidden;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-card__identity span,
|
||||
.timeline-card__count,
|
||||
.timeline-card__footer,
|
||||
.timeline-step__heading time,
|
||||
.history-detail__header span,
|
||||
.history-detail__step time {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timeline-card__summary {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
padding: 9px 10px;
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.timeline-card__label {
|
||||
color: var(--td-text-color-secondary);
|
||||
}
|
||||
|
||||
.timeline-card__count {
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-card__reason,
|
||||
.history-detail__reason {
|
||||
margin: 12px 0 0;
|
||||
color: var(--td-text-color-primary);
|
||||
line-height: 22px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.timeline-card__notes,
|
||||
.history-detail__notes {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--td-component-border);
|
||||
}
|
||||
|
||||
.timeline-card__notes > span,
|
||||
.history-detail__notes > span {
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timeline-card__notes p,
|
||||
.history-detail__notes p {
|
||||
margin: 0;
|
||||
color: var(--td-text-color-primary);
|
||||
line-height: 22px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.timeline-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.timeline-step {
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-step__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 7px;
|
||||
background: var(--td-component-border);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.timeline-step__dot--success {
|
||||
background: var(--td-success-color);
|
||||
}
|
||||
|
||||
.timeline-step__dot--danger {
|
||||
background: var(--td-error-color);
|
||||
}
|
||||
|
||||
.timeline-step__dot--warning {
|
||||
background: var(--td-warning-color);
|
||||
}
|
||||
|
||||
.timeline-step__body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timeline-step__heading {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.timeline-step__heading strong {
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timeline-step__heading time {
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.timeline-step__body p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.timeline-card__footer {
|
||||
margin-top: 14px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--td-component-border);
|
||||
}
|
||||
|
||||
.history-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.history-detail__header > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.history-detail__header strong {
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 18px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.history-detail__steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 420px;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border: 1px solid var(--td-component-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.history-detail__step {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--td-component-border);
|
||||
}
|
||||
|
||||
.history-detail__step:last-child {
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.history-detail__step > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.history-detail__step strong {
|
||||
color: var(--td-text-color-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.history-detail__step time {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.history-detail__step p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--td-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
@@ -1022,6 +1508,16 @@ onBeforeUnmount(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.update-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.update-toolbar :deep(.t-button) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.update-actions,
|
||||
.update-actions :deep(.t-button) {
|
||||
width: 100%;
|
||||
@@ -1038,6 +1534,40 @@ onBeforeUnmount(() => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.update-timeline__item {
|
||||
grid-template-columns: 18px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.update-timeline__marker {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.update-timeline__rail::after {
|
||||
top: 24px;
|
||||
}
|
||||
|
||||
.timeline-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.timeline-card__count {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.timeline-step__heading time {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.timeline-card__notes,
|
||||
.history-detail__notes {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.download-progress__meta,
|
||||
.dialog-status-line,
|
||||
.dialog-actions {
|
||||
|
||||
@@ -297,9 +297,12 @@ write_request() {
|
||||
local fixture=$1
|
||||
local version=$2
|
||||
local action=$3
|
||||
local request_id=01M00000000000000000000091
|
||||
[ "$action" = INSTALL ] && request_id=01M00000000000000000000092
|
||||
mkdir -p "$fixture/state/inbox"
|
||||
jq -n --arg action "$action" --arg version "$version" \
|
||||
'{action:$action,version:$version,reason:"fixture"}' > "$fixture/state/inbox/request.json"
|
||||
jq -n --arg action "$action" --arg version "$version" --arg requestId "$request_id" \
|
||||
'{action:$action,version:$version,reason:"fixture",requestId:$requestId,
|
||||
requestedAt:"2026-08-19T00:00:00Z"}' > "$fixture/state/inbox/request.json"
|
||||
}
|
||||
|
||||
download_and_prepare_install() {
|
||||
@@ -309,6 +312,9 @@ download_and_prepare_install() {
|
||||
run_update "$fixture" success
|
||||
[ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \
|
||||
|| fail 'download phase did not persist READY'
|
||||
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000091 ] \
|
||||
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = DOWNLOAD ] \
|
||||
|| fail 'download phase did not preserve request correlation'
|
||||
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|
||||
|| fail 'download phase changed the active application'
|
||||
[ -s "$fixture/state/cache/$version/release.tar.gz" ] \
|
||||
@@ -447,6 +453,9 @@ assert_success_case() {
|
||||
|| fail 'success case did not activate the signed updater'
|
||||
[ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \
|
||||
|| fail 'success case did not persist SUCCEEDED'
|
||||
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \
|
||||
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \
|
||||
|| fail 'success case did not preserve install request correlation'
|
||||
[ ! -e "$fixture/state/processing/request.json" ] \
|
||||
|| fail 'success case left a claimed request behind'
|
||||
grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded'
|
||||
@@ -522,6 +531,9 @@ assert_rollback_case() {
|
||||
|| fail 'rollback case did not restart both the candidate and restored releases'
|
||||
[ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \
|
||||
|| fail 'rollback case did not persist FAILED'
|
||||
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \
|
||||
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \
|
||||
|| fail 'rollback case did not preserve install request correlation'
|
||||
[ ! -e "$fixture/app/releases/$version" ] \
|
||||
|| fail 'rollback case left the failed release installed'
|
||||
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|
||||
@@ -550,6 +562,9 @@ assert_incomplete_rollback_requires_manual_recovery_case() {
|
||||
|| fail 'incomplete rollback case did not quarantine transaction evidence'
|
||||
[ "$(jq -r '.state' "$fixture/state/status.json")" = RECOVERY_REQUIRED ] \
|
||||
|| fail 'incomplete rollback case did not lock the updater for recovery'
|
||||
[ "$(jq -r '.requestId' "$fixture/state/status.json")" = 01M00000000000000000000092 ] \
|
||||
&& [ "$(jq -r '.action' "$fixture/state/status.json")" = INSTALL ] \
|
||||
|| fail 'incomplete rollback case did not preserve install request correlation'
|
||||
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|
||||
|| fail 'incomplete rollback case did not archive its claimed request'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user