feat: add safe backup management and theme preferences
Add snapshot usage and protected cleanup, preserve unsaved profile edits, avoid holding SQL connections during snapshot deletion, and fix download filenames. Include regression tests and API documentation.
This commit is contained in:
@@ -8,8 +8,21 @@
|
|||||||
- GET /api/runtime-session.js:无需登录,返回 JavaScript、Cache-Control: no-store,用于运行时页面续期。
|
- GET /api/runtime-session.js:无需登录,返回 JavaScript、Cache-Control: no-store,用于运行时页面续期。
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
- UPLOAD_ALLOWED_EXTENSIONS:可选逗号分隔扩展名白名单(例如 .txt,.md);空表示不限制。大小写不敏感,非内容/恶意软件检测。
|
- UPLOAD_ALLOWED_EXTENSIONS:可选逗号分隔扩展名白名单(例如 .txt,.md);空表示不限制。大小写不敏感,非内容/恶意软件检测。
|
||||||
|
|
||||||
|
## 实例备份与快照管理
|
||||||
|
|
||||||
|
- `DELETE /api/instances/:id/backups?id=<32hex>` 删除当前用户实例的加密 `.dshbak` 包及其索引,记录 `backup.delete` 审计。ID 为32位小写十六进制;支持无索引的旧包,以及文件已不存在的残留索引。
|
||||||
|
- `GET /api/instances/:id/snapshots` 返回数组 `{id,size,created_at,protected,protection_reason,size_error?}`。`size` 为普通文件逻辑字节总和(与配额口径相同,硬链接逐路径计数,不跟随符号链接);扫描失败为 `-1` 并提供 `size_error`,不能显示为零。`created_at` 为目录修改时间的 RFC3339 字符串,不保证真实创建时间。未保护时 `protection_reason` 是空字符串。
|
||||||
|
- `DELETE /api/instances/:id/snapshots?id=<id>` 删除选定快照。先持久化 `snapshot.delete.started`,删除成功后记录 `snapshot.delete.completed`,删除失败则记录 `snapshot.delete.failed`;target 为实例 ID,detail 为快照 ID。初始审计失败时不会删除,终态审计失败返回500并保留 started 记录。不提供快照下载。
|
||||||
|
|
||||||
|
仅识别实例目录直属的 `pre-restore-<32hex>`、`failed-restore-<32hex>`、`upgrade-<32hex>-snapshot`、`failed-home-<32hex>` 真实目录;不列出或删除 `home`、其它命名或符号链接目录。删除成功返回 `200 {ok:true}`,非法ID/不安全路径400,不存在/所有权不符404,恢复保护409,存储/索引/审计错误500。不支持的方法405。删除仍要求同源 Origin 与 CSRF,不要求备份密码,不调用 Docker inspect/stop/restart。
|
||||||
|
|
||||||
|
实例 `error`、未完成升级(仅 `complete` / `rolled_back` 为终态)、恢复日志查询错误时拒绝两类删除。快照列表仍可读取并报告保护原因。所有权在实例锁与用户锁内重新检查。无自动清理;文件系统和数据库无法原子提交,包删除后的索引提交失败会返回500,重试同一删除可清理残留索引。
|
||||||
|
|
||||||
|
快照递归删除期间保留实例锁与用户锁,但不持有 SQL 连接或事务。仅明确识别的非法/不安全路径返回400;权限、I/O 等操作系统故障返回500。快照目录不存在时列表返回空数组,删除返回404。
|
||||||
|
|
||||||
## 管理员孤儿回收
|
## 管理员孤儿回收
|
||||||
|
|
||||||
GET /api/orphans 返回可回收的完整容器ID数组。DELETE /api/orphans?id=<id>&confirm=<相同完整id> 二次核验后非强制删除,保留bind数据及网络。仅接受本项目身份/挂载匹配、已停止、无实例或升级引用的容器;普通用户403,确认不匹配400,状态不安全409。
|
GET /api/orphans 返回可回收的完整容器ID数组。DELETE /api/orphans?id=<id>&confirm=<相同完整id> 二次核验后非强制删除,保留bind数据及网络。仅接受本项目身份/挂载匹配、已停止、无实例或升级引用的容器;普通用户403,确认不匹配400,状态不安全409。
|
||||||
|
|||||||
@@ -118,6 +118,14 @@ bash scripts/check-trial.sh
|
|||||||
|
|
||||||
`scripts/test-panel.mjs` 必须指向独立测试部署,通过 `TEST_PANEL_URL`、`TEST_USERNAME`、`TEST_PASSWORD` 指定访问参数;`TEST_UPGRADE=1` 包含同镜像替换升级。运行前需由管理员通过 Git 发布构建准备已验证且准入的本地 runtime 镜像;可用 `TEST_FROM_VERSION` / `TEST_TO_VERSION` 选择源和目标版本。脚本不提交构建,只复用这些镜像,会创建及删除测试实例,删除时一并清理实例数据。不要对生产账号运行。`scripts/smoke-image.sh <image-id>` 和 `scripts/test-mobile-image.mjs <image-id>` 仅测试已有镜像的运行行为,不读取 main 的构建源;仅在隔离测试环境使用。旧构建脚本和构建后创建意图的 live 测试已删除。
|
`scripts/test-panel.mjs` 必须指向独立测试部署,通过 `TEST_PANEL_URL`、`TEST_USERNAME`、`TEST_PASSWORD` 指定访问参数;`TEST_UPGRADE=1` 包含同镜像替换升级。运行前需由管理员通过 Git 发布构建准备已验证且准入的本地 runtime 镜像;可用 `TEST_FROM_VERSION` / `TEST_TO_VERSION` 选择源和目标版本。脚本不提交构建,只复用这些镜像,会创建及删除测试实例,删除时一并清理实例数据。不要对生产账号运行。`scripts/smoke-image.sh <image-id>` 和 `scripts/test-mobile-image.mjs <image-id>` 仅测试已有镜像的运行行为,不读取 main 的构建源;仅在隔离测试环境使用。旧构建脚本和构建后创建意图的 live 测试已删除。
|
||||||
|
|
||||||
|
## 手动备份空间管理
|
||||||
|
|
||||||
|
实例备份包和历史完整工作区快照必须由用户显式删除,不执行自动清理。快照列表与删除接口见 API.md。删除快照会降低工作区逻辑用量;加密包在独立 backups 目录,不计入当前工作区软配额。快照大小不等于物理磁盘占用,硬链接按路径重复计数;`size_error` 表示无法完整扫描,应显示未知而非零。
|
||||||
|
|
||||||
|
恢复失败、未完成升级及恢复日志不可读时,列表仍报告保护状态,但禁止删除包和快照。先完成恢复并核实运行状态和升级日志,不要为了释放空间直接修改日志状态。`home` 永远不属于历史快照管理对象。删除不需要停机或 Docker 可用,也不会触发重启。
|
||||||
|
|
||||||
|
文件删除与 SQLite 提交不具备跨系统原子性。若加密包已删除而索引提交失败,接口明确返回500;重试相同删除会清理残留索引。快照删除先持久化 `snapshot.delete.started`,失败则不触碰目录;递归删除期间不占用 SQL 连接或事务,仍保留实例锁和用户锁。随后分别记录 `snapshot.delete.completed` 或 `snapshot.delete.failed`。终态审计写入失败返回500;只有 started 的记录也可能表示进程中断或操作仍在进行,不代表删除成功,应由管理员核实目录和审计记录。目录删除中途遇到存储错误可能部分完成,排除故障后再重试。明确不安全路径返回400,权限或 I/O 故障返回500,缺失快照删除返回404(列表为空)。
|
||||||
|
|
||||||
## 会话有效期
|
## 会话有效期
|
||||||
|
|
||||||
在 `.env` 设置 `SESSION_TTL=24h`(默认),七天可设置为 `168h`。使用 Go duration 单位 `h`、`m`、`s`,不支持 `d`;必须至少一秒且为整秒,非法值会阻止启动。修改后运行 `docker compose up -d --force-recreate panel`。新登录使用新期限;已有会话保留原绝对到期时间,刷新不会延长。access token签发有效期为五分钟,但数据库会话过期或被撤销后,即使token尚未到期也不能认证。修改自己的密码或管理员重设密码会撤销该用户所有会话,需要重新登录。`.env` 由 Compose 读取,直接运行二进制时应在进程环境中导出 `SESSION_TTL`。
|
在 `.env` 设置 `SESSION_TTL=24h`(默认),七天可设置为 `168h`。使用 Go duration 单位 `h`、`m`、`s`,不支持 `d`;必须至少一秒且为整秒,非法值会阻止启动。修改后运行 `docker compose up -d --force-recreate panel`。新登录使用新期限;已有会话保留原绝对到期时间,刷新不会延长。access token签发有效期为五分钟,但数据库会话过期或被撤销后,即使token尚未到期也不能认证。修改自己的密码或管理员重设密码会撤销该用户所有会话,需要重新登录。`.env` 由 Compose 读取,直接运行二进制时应在进程环境中导出 `SESSION_TTL`。
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
var snapshotIDPattern = regexp.MustCompile(`^(pre-restore-[a-f0-9]{32}|failed-restore-[a-f0-9]{32}|upgrade-[a-f0-9]{32}-snapshot|failed-home-[a-f0-9]{32})$`)
|
||||||
|
|
||||||
|
var errUnsafeManagementPath = errors.New("unsafe storage path")
|
||||||
|
|
||||||
|
func managementPathError(w http.ResponseWriter, err error) {
|
||||||
|
if errors.Is(err, errUnsafeManagementPath) {
|
||||||
|
fail(w, 400, "unsafe storage path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(w, 500, "storage unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
type snapshotInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Protected bool `json:"protected"`
|
||||||
|
ProtectionReason string `json:"protection_reason"`
|
||||||
|
SizeError string `json:"size_error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func managementRoot(base string, parts ...string) (*os.Root, error) {
|
||||||
|
root, err := os.OpenRoot(base)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, part := range parts {
|
||||||
|
if part == "" || part == "." || part == ".." || strings.ContainsAny(part, "/\\") {
|
||||||
|
root.Close()
|
||||||
|
return nil, errUnsafeManagementPath
|
||||||
|
}
|
||||||
|
st, err := root.Lstat(part)
|
||||||
|
if err != nil {
|
||||||
|
root.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !st.IsDir() {
|
||||||
|
root.Close()
|
||||||
|
return nil, errUnsafeManagementPath
|
||||||
|
}
|
||||||
|
next, err := root.OpenRoot(part)
|
||||||
|
root.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
actual, err := next.Stat(".")
|
||||||
|
if err != nil {
|
||||||
|
next.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !os.SameFile(st, actual) {
|
||||||
|
next.Close()
|
||||||
|
return nil, errUnsafeManagementPath
|
||||||
|
}
|
||||||
|
root = next
|
||||||
|
}
|
||||||
|
return root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) backupProtection(i store.Instance) string {
|
||||||
|
if i.Status == "error" {
|
||||||
|
return "instance recovery required"
|
||||||
|
}
|
||||||
|
var exists, pending int
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='upgrade_journal'").Scan(&exists); err != nil {
|
||||||
|
return "recovery journal unavailable"
|
||||||
|
}
|
||||||
|
if exists != 0 {
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM upgrade_journal WHERE instance_id=? AND (phase IS NULL OR phase NOT IN ('complete','rolled_back'))", i.ID).Scan(&pending); err != nil {
|
||||||
|
return "recovery journal unavailable"
|
||||||
|
}
|
||||||
|
if pending != 0 {
|
||||||
|
return "instance recovery required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshotBytes(ctx context.Context, root *os.Root, name string) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
count := 0
|
||||||
|
err := fs.WalkDir(root.FS(), name, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if count > 100000 || strings.Count(p, "/") > 128 {
|
||||||
|
return errors.New("tree limit")
|
||||||
|
}
|
||||||
|
st, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if st.Mode().IsRegular() {
|
||||||
|
total += st.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) backupManagement(w http.ResponseWriter, r *http.Request, i store.Instance, action string) {
|
||||||
|
w, flush := deferJSONResponse(w)
|
||||||
|
defer flush()
|
||||||
|
if r.Method != http.MethodDelete && !(action == "snapshots" && r.Method == http.MethodGet) {
|
||||||
|
fail(w, 405, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := r.URL.Query().Get("id")
|
||||||
|
if r.Method == http.MethodDelete && ((action == "snapshots" && !snapshotIDPattern.MatchString(id)) || (action == "backups" && !backupIDPattern.MatchString(id))) {
|
||||||
|
fail(w, 400, "invalid ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lock := a.instanceLocks.Acquire(i.ID)
|
||||||
|
defer lock.Release()
|
||||||
|
user := a.userLocks.Acquire(i.UID)
|
||||||
|
defer user.Release()
|
||||||
|
current, err := a.s.GetInstance(i.ID)
|
||||||
|
if err != nil || current.UID != i.UID {
|
||||||
|
fail(w, 404, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
i = current
|
||||||
|
reason := a.backupProtection(i)
|
||||||
|
if r.Method == http.MethodDelete && reason != "" {
|
||||||
|
fail(w, 409, reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := []string{"workspaces", i.UID, "instances", i.ID}
|
||||||
|
if action == "backups" {
|
||||||
|
parts = []string{"backups", i.UID, i.ID}
|
||||||
|
}
|
||||||
|
root, err := managementRoot(a.root, parts...)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
if action == "backups" {
|
||||||
|
a.deleteBackup(w, i, nil, id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
writeJSON(w, []snapshotInfo{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(w, 404, "not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
managementPathError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer root.Close()
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
f, err := root.Open(".")
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "snapshot listing failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries, err := f.ReadDir(-1)
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "snapshot listing failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := []snapshotInfo{}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !snapshotIDPattern.MatchString(entry.Name()) || !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
st, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "snapshot metadata unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := snapshotInfo{ID: entry.Name(), CreatedAt: st.ModTime(), Protected: reason != "", ProtectionReason: reason}
|
||||||
|
s.Size, err = snapshotBytes(r.Context(), root, entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
s.Size = -1
|
||||||
|
s.SizeError = "snapshot size unavailable: " + err.Error()
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
writeJSON(w, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if action == "backups" {
|
||||||
|
a.deleteBackup(w, i, root, id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := root.Lstat(id)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
fail(w, 404, "snapshot not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
managementPathError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !st.IsDir() {
|
||||||
|
fail(w, 400, "unsafe snapshot path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.deleteSnapshot(w, i, id, func() error { return root.RemoveAll(id) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) deleteSnapshot(w http.ResponseWriter, i store.Instance, id string, remove func() error) {
|
||||||
|
audit := func(state string) error {
|
||||||
|
_, err := a.s.DB.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", i.UID, "snapshot.delete."+state, i.ID, id, time.Now().Unix())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := audit("started"); err != nil {
|
||||||
|
fail(w, 500, "audit unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := remove(); err != nil {
|
||||||
|
if err := audit("failed"); err != nil {
|
||||||
|
fail(w, 500, "snapshot deletion failed; audit unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(w, 500, "snapshot deletion failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := audit("completed"); err != nil {
|
||||||
|
fail(w, 500, "snapshot removed; completion audit unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) deleteBackup(w http.ResponseWriter, i store.Instance, root *os.Root, id string) {
|
||||||
|
name := id + ".dshbak"
|
||||||
|
var st os.FileInfo
|
||||||
|
statErr := error(os.ErrNotExist)
|
||||||
|
if root != nil {
|
||||||
|
st, statErr = root.Lstat(name)
|
||||||
|
}
|
||||||
|
if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
|
||||||
|
fail(w, 500, "backup unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if statErr == nil && !st.Mode().IsRegular() {
|
||||||
|
fail(w, 400, "unsafe backup path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tx, err := a.s.DB.Begin()
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "backup index unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
var exists, indexed int
|
||||||
|
err = tx.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='backups'").Scan(&exists)
|
||||||
|
if err == nil && exists != 0 {
|
||||||
|
var result sql.Result
|
||||||
|
result, err = tx.Exec("DELETE FROM backups WHERE id=? AND uid=? AND instance_id=?", id, i.UID, i.ID)
|
||||||
|
if err == nil {
|
||||||
|
n, e := result.RowsAffected()
|
||||||
|
err = e
|
||||||
|
indexed = int(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "backup index unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if statErr != nil && indexed == 0 {
|
||||||
|
fail(w, 404, "backup not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err = tx.Exec("INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", i.UID, "backup.delete", i.ID, id, time.Now().Unix()); err != nil {
|
||||||
|
fail(w, 500, "audit unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if statErr == nil {
|
||||||
|
if err = root.Remove(name); err != nil {
|
||||||
|
fail(w, 500, "backup deletion failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filesystem and SQLite cannot commit atomically; a retry clears a stale index after a failed commit.
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
fail(w, 500, "backup removed; index update failed, retry deletion")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSnapshotManagement(t *testing.T) {
|
||||||
|
a, i, home := configTestAPI(t, false)
|
||||||
|
a.docker = nil // These operations must never inspect or stop Docker.
|
||||||
|
parent := filepath.Dir(home)
|
||||||
|
suffix := strings.Repeat("a", 32)
|
||||||
|
names := []string{"pre-restore-" + suffix, "failed-restore-" + suffix, "upgrade-" + suffix + "-snapshot", "failed-home-" + suffix}
|
||||||
|
for _, name := range append(append([]string{}, names...), "pre-restore-no", "upgrade-"+suffix, "other") {
|
||||||
|
if err := os.Mkdir(filepath.Join(parent, name), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
configWrite(t, filepath.Join(parent, name, "data"), "abc")
|
||||||
|
}
|
||||||
|
outside := t.TempDir()
|
||||||
|
configWrite(t, filepath.Join(outside, "keep"), "outside")
|
||||||
|
if err := os.Symlink(outside, filepath.Join(parent, names[0], "link")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Link(filepath.Join(parent, names[0], "data"), filepath.Join(parent, names[0], "hardlink")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := configCall(a, i, "GET", "snapshots", "")
|
||||||
|
var got []snapshotInfo
|
||||||
|
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got) != 4 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
for _, s := range got {
|
||||||
|
want := int64(3)
|
||||||
|
if s.ID == names[0] {
|
||||||
|
want = 6
|
||||||
|
}
|
||||||
|
if s.Size != want || s.Protected || s.ProtectionReason != "" || s.SizeError != "" || s.CreatedAt.IsZero() {
|
||||||
|
t.Fatalf("%+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range names {
|
||||||
|
w = configCall(a, i, "DELETE", "snapshots?id="+name, "")
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(parent, name)); !os.IsNotExist(err) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range []string{home, filepath.Join(parent, "other"), filepath.Join(outside, "keep")} {
|
||||||
|
if _, err := os.Stat(p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='snapshot.delete.completed' AND uid=? AND target=?", i.UID, i.ID).Scan(&count); err != nil || count != 4 {
|
||||||
|
t.Fatal(count, err)
|
||||||
|
}
|
||||||
|
if w = configCall(a, i, "DELETE", "snapshots?id="+names[0], ""); w.Code != 404 {
|
||||||
|
t.Fatal(w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagementInvalidPaths(t *testing.T) {
|
||||||
|
a, i, home := configTestAPI(t, false)
|
||||||
|
a.docker = nil
|
||||||
|
for _, action := range []string{"snapshots", "backups"} {
|
||||||
|
for _, id := range []string{"", "home", "..", "../home", "/home", strings.Repeat("A", 32), "pre-restore-" + strings.Repeat("a", 32) + "/../home"} {
|
||||||
|
if w := configCall(a, i, "DELETE", action+"?id="+url.QueryEscape(id), ""); w.Code != 400 {
|
||||||
|
t.Fatal(action, id, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
id := "failed-home-" + strings.Repeat("b", 32)
|
||||||
|
outside := t.TempDir()
|
||||||
|
configWrite(t, filepath.Join(outside, "keep"), "safe")
|
||||||
|
if err := os.Symlink(outside, filepath.Join(filepath.Dir(home), id)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w := configCall(a, i, "DELETE", "snapshots?id="+id, ""); w.Code != 400 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := configCall(a, i, "GET", "snapshots", ""); w.Code != 200 || strings.TrimSpace(w.Body.String()) != "[]" {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if err := os.Rename(filepath.Dir(home), filepath.Dir(home)+"-saved"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(outside, filepath.Dir(home)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, method := range []string{"GET", "DELETE"} {
|
||||||
|
if w := configCall(a, i, method, "snapshots?id="+id, ""); w.Code != 400 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(outside, "keep")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagementProtection(t *testing.T) {
|
||||||
|
for _, state := range []string{"error", "pending", "journal-error", "complete", "rolled_back"} {
|
||||||
|
t.Run(state, func(t *testing.T) {
|
||||||
|
a, i, home := configTestAPI(t, false)
|
||||||
|
id := "pre-restore-" + strings.Repeat("c", 32)
|
||||||
|
if err := os.Mkdir(filepath.Join(filepath.Dir(home), id), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if state == "error" {
|
||||||
|
_, err = a.s.DB.Exec("UPDATE instances SET status='error' WHERE id=?", i.ID)
|
||||||
|
} else if state == "journal-error" {
|
||||||
|
_, err = a.s.DB.Exec("CREATE TABLE upgrade_journal(broken TEXT)")
|
||||||
|
} else {
|
||||||
|
_, err = a.s.DB.Exec("CREATE TABLE upgrade_journal(instance_id TEXT,phase TEXT)")
|
||||||
|
if err == nil {
|
||||||
|
_, err = a.s.DB.Exec("INSERT INTO upgrade_journal VALUES(?,?)", i.ID, state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
protected := state != "complete" && state != "rolled_back"
|
||||||
|
w := configCall(a, i, "GET", "snapshots", "")
|
||||||
|
var got []snapshotInfo
|
||||||
|
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got) != 1 || got[0].Protected != protected || (got[0].ProtectionReason != "") != protected {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w = configCall(a, i, "DELETE", "snapshots?id="+id, "")
|
||||||
|
want := 200
|
||||||
|
if protected {
|
||||||
|
want = 409
|
||||||
|
}
|
||||||
|
if w.Code != want {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if protected {
|
||||||
|
w = configCall(a, i, "DELETE", "backups?id="+strings.Repeat("a", 32), "")
|
||||||
|
if w.Code != 409 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotSizeError(t *testing.T) {
|
||||||
|
a, i, home := configTestAPI(t, false)
|
||||||
|
id := "failed-restore-" + strings.Repeat("d", 32)
|
||||||
|
deep := filepath.Join(filepath.Dir(home), id)
|
||||||
|
for n := 0; n < 130; n++ {
|
||||||
|
deep = filepath.Join(deep, "d")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(deep, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := configCall(a, i, "GET", "snapshots", "")
|
||||||
|
var got []snapshotInfo
|
||||||
|
if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil || len(got) != 1 || got[0].Size != -1 || got[0].SizeError == "" {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupDelete(t *testing.T) {
|
||||||
|
for _, mode := range []string{"indexed", "legacy", "stale", "missing-parent", "index-error", "symlink"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
a, i, _ := configTestAPI(t, false)
|
||||||
|
a.docker = nil
|
||||||
|
id := strings.Repeat("e", 32)
|
||||||
|
dir := filepath.Join(a.root, "backups", i.UID, i.ID)
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
file := filepath.Join(dir, id+".dshbak")
|
||||||
|
if mode != "stale" && mode != "symlink" {
|
||||||
|
configWrite(t, file, "encrypted")
|
||||||
|
}
|
||||||
|
if mode == "symlink" {
|
||||||
|
if err := os.Symlink(t.TempDir(), file); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "indexed" || mode == "stale" || mode == "missing-parent" {
|
||||||
|
if err := a.indexBackup(i, id, "manual", 2, 9); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "index-error" {
|
||||||
|
if _, err := a.s.DB.Exec("CREATE TABLE backups(broken TEXT)"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "missing-parent" {
|
||||||
|
if err := os.RemoveAll(filepath.Join(a.root, "backups")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w := configCall(a, i, "DELETE", "backups?id="+id, "")
|
||||||
|
want := 200
|
||||||
|
if mode == "index-error" {
|
||||||
|
want = 500
|
||||||
|
}
|
||||||
|
if mode == "symlink" {
|
||||||
|
want = 400
|
||||||
|
}
|
||||||
|
if w.Code != want {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if want != 200 {
|
||||||
|
if _, err := os.Lstat(file); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if mode != "legacy" {
|
||||||
|
var count int
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM backups WHERE id=?", id).Scan(&count); err != nil || count != 0 {
|
||||||
|
t.Fatal(count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action='backup.delete' AND target=? AND detail=?", i.ID, id).Scan(&count); err != nil || count != 1 {
|
||||||
|
t.Fatal(count, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagementOwnershipRecheck(t *testing.T) {
|
||||||
|
a, i, _ := configTestAPI(t, false)
|
||||||
|
if _, err := a.s.DB.Exec("UPDATE instances SET uid='someone-else' WHERE id=?", i.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, action := range []string{"snapshots", "snapshots?id=pre-restore-" + strings.Repeat("a", 32), "backups?id=" + strings.Repeat("a", 32)} {
|
||||||
|
method := "DELETE"
|
||||||
|
if action == "snapshots" {
|
||||||
|
method = "GET"
|
||||||
|
}
|
||||||
|
if w := configCall(a, i, method, action, ""); w.Code != 404 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagementAuditFailure(t *testing.T) {
|
||||||
|
a, i, home := configTestAPI(t, false)
|
||||||
|
id := strings.Repeat("f", 32)
|
||||||
|
snapshot := filepath.Join(filepath.Dir(home), "pre-restore-"+id)
|
||||||
|
if err := os.Mkdir(snapshot, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := filepath.Join(a.root, "backups", i.UID, i.ID)
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
file := filepath.Join(dir, id+".dshbak")
|
||||||
|
configWrite(t, file, "encrypted")
|
||||||
|
if err := a.indexBackup(i, id, "manual", 2, 9); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.s.DB.Exec("DROP TABLE audit"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, action := range []string{"snapshots?id=pre-restore-" + id, "backups?id=" + id} {
|
||||||
|
if w := configCall(a, i, "DELETE", action, ""); w.Code != 500 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range []string{snapshot, file} {
|
||||||
|
if _, err := os.Stat(p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM backups WHERE id=?", id).Scan(&count); err != nil || count != 1 {
|
||||||
|
t.Fatal(count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotScanCancelled(t *testing.T) {
|
||||||
|
root, err := os.OpenRoot(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer root.Close()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err := snapshotBytes(ctx, root, "."); err != context.Canceled {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotDeletionReleasesSQLConnection(t *testing.T) {
|
||||||
|
a, i, _ := configTestAPI(t, false)
|
||||||
|
a.s.DB.SetMaxOpenConns(1)
|
||||||
|
entered, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
a.deleteSnapshot(w, i, "snapshot", func() error {
|
||||||
|
close(entered)
|
||||||
|
<-release
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
defer func() { close(release); <-done }()
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("removal did not start")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
var count int
|
||||||
|
if err := a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM audit WHERE action='snapshot.delete.started'").Scan(&count); err != nil || count != 1 {
|
||||||
|
t.Fatalf("SQL blocked during removal or initial audit not persisted: count=%d err=%v", count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotDeletionAuditOutcomes(t *testing.T) {
|
||||||
|
for _, mode := range []string{"success", "remove-failed", "start-failed", "completion-audit-failed", "failure-audit-failed"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
a, i, _ := configTestAPI(t, false)
|
||||||
|
if mode == "start-failed" {
|
||||||
|
if _, err := a.s.DB.Exec("DROP TABLE audit"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
called := false
|
||||||
|
a.deleteSnapshot(w, i, "snapshot", func() error {
|
||||||
|
called = true
|
||||||
|
if strings.Contains(mode, "audit-failed") {
|
||||||
|
if _, err := a.s.DB.Exec(`CREATE TRIGGER reject_terminal BEFORE INSERT ON audit WHEN NEW.action != 'snapshot.delete.started' BEGIN SELECT RAISE(FAIL, 'audit unavailable'); END`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "remove-failed" || mode == "failure-audit-failed" {
|
||||||
|
return os.ErrPermission
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
want := 500
|
||||||
|
if mode == "success" {
|
||||||
|
want = 200
|
||||||
|
}
|
||||||
|
if w.Code != want || called != (mode != "start-failed") {
|
||||||
|
t.Fatal(w.Code, w.Body.String(), called)
|
||||||
|
}
|
||||||
|
if mode == "start-failed" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, state := range []string{"started", "completed", "failed"} {
|
||||||
|
wantCount := 0
|
||||||
|
if state == "started" || (state == "completed" && mode == "success") || (state == "failed" && mode == "remove-failed") {
|
||||||
|
wantCount = 1
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := a.s.DB.QueryRow("SELECT count(*) FROM audit WHERE action=? AND uid=? AND target=? AND detail=?", "snapshot.delete."+state, i.UID, i.ID, "snapshot").Scan(&count); err != nil || count != wantCount {
|
||||||
|
t.Fatal(state, count, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManagementPathErrorClassification(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"unsafe", errUnsafeManagementPath, 400},
|
||||||
|
{"wrapped-unsafe", fmt.Errorf("path: %w", errUnsafeManagementPath), 400},
|
||||||
|
{"permission", &os.PathError{Op: "lstat", Path: "snapshot", Err: os.ErrPermission}, 500},
|
||||||
|
{"closed", os.ErrClosed, 500},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
managementPathError(w, tc.err)
|
||||||
|
if w.Code != tc.want {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
a, i, _ := configTestAPI(t, false)
|
||||||
|
base := t.TempDir()
|
||||||
|
configWrite(t, filepath.Join(base, "file"), "not a directory")
|
||||||
|
a.root = filepath.Join(base, "file", "child")
|
||||||
|
if w := configCall(a, i, "GET", "snapshots", ""); w.Code != 500 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
a.root = filepath.Join(base, "missing")
|
||||||
|
if w := configCall(a, i, "GET", "snapshots", ""); w.Code != 200 || strings.TrimSpace(w.Body.String()) != "[]" {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := configCall(a, i, "DELETE", "snapshots?id=pre-restore-"+strings.Repeat("a", 32), ""); w.Code != 404 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,10 @@ var backupIDPattern = regexp.MustCompile("^[a-f0-9]{32}$")
|
|||||||
var configBodySlots = make(chan struct{}, 4)
|
var configBodySlots = make(chan struct{}, 4)
|
||||||
|
|
||||||
func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, action string) {
|
func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, action string) {
|
||||||
|
if action == "snapshots" || (action == "backups" && r.Method == http.MethodDelete) {
|
||||||
|
a.backupManagement(w, r, i, action)
|
||||||
|
return
|
||||||
|
}
|
||||||
controller := http.NewResponseController(w)
|
controller := http.NewResponseController(w)
|
||||||
if r.Method != http.MethodGet || action != "backups" || r.URL.Query().Get("download") == "" {
|
if r.Method != http.MethodGet || action != "backups" || r.URL.Query().Get("download") == "" {
|
||||||
var flush func()
|
var flush func()
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWorkspaceDownloadFilename(t *testing.T) {
|
||||||
|
a, sid := runtimeTestAPI(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected Docker call") })
|
||||||
|
if err := a.s.AddInstance(store.Instance{ID: "one", UID: "owner"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
home := filepath.Join(a.root, "workspaces/owner/instances/one/home")
|
||||||
|
checkFilename := func(t *testing.T, w *httptest.ResponseRecorder, want string) {
|
||||||
|
t.Helper()
|
||||||
|
header := w.Header().Get("Content-Disposition")
|
||||||
|
mediaType, params, err := mime.ParseMediaType(header)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse Content-Disposition %q: %v", header, err)
|
||||||
|
}
|
||||||
|
if mediaType != "attachment" || params["filename"] != want {
|
||||||
|
t.Fatalf("Content-Disposition = %q, want attachment with filename %q", header, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const content = "0123456789\nunchanged 下载 content\x00\xff"
|
||||||
|
for _, name := range []string{"report.txt", "中文文件.txt", "report with spaces.txt", `report";100%.txt`, "README"} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
p := "parent/nested/" + name
|
||||||
|
file := filepath.Join(home, filepath.FromSlash(p))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(file), 0750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(file, []byte(content), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, partial := range []bool{false, true} {
|
||||||
|
r := runtimeRequest(http.MethodGet, "/api/ws/download?instance=one&path="+url.QueryEscape(p), "", sid)
|
||||||
|
wantStatus, wantBody := http.StatusOK, content
|
||||||
|
if partial {
|
||||||
|
r.Header.Set("Range", "bytes=2-6")
|
||||||
|
wantStatus, wantBody = http.StatusPartialContent, content[2:7]
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.workspace(w, r)
|
||||||
|
if w.Code != wantStatus {
|
||||||
|
t.Fatalf("partial=%v: status = %d, want %d: %s", partial, w.Code, wantStatus, w.Body.String())
|
||||||
|
}
|
||||||
|
checkFilename(t, w, name)
|
||||||
|
if w.Body.String() != wantBody {
|
||||||
|
t.Errorf("partial=%v: body = %q, want %q", partial, w.Body.String(), wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("directory zip", func(t *testing.T) {
|
||||||
|
const name = `资料 ";100%`
|
||||||
|
p := "parent/" + name
|
||||||
|
dir := filepath.Join(home, filepath.FromSlash(p))
|
||||||
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "entry.txt"), []byte(content), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.workspace(w, runtimeRequest(http.MethodGet, "/api/ws/download?instance=one&path="+url.QueryEscape(p), "", sid))
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
checkFilename(t, w, name+".zip")
|
||||||
|
archive, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(archive.File) != 1 || archive.File[0].Name != "entry.txt" {
|
||||||
|
t.Fatalf("unexpected archive entries: %v", archive.File)
|
||||||
|
}
|
||||||
|
r, err := archive.File[0].Open()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer r.Close()
|
||||||
|
got, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(got) != content {
|
||||||
|
t.Errorf("archive body = %q, want %q", got, content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -781,7 +781,7 @@ func (a *API) wsDownload(w http.ResponseWriter, r *http.Request, root *os.Root)
|
|||||||
fail(w, 400, "invalid file")
|
fail(w, 400, "invalid file")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Disposition", "attachment")
|
w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": path.Base(p)}))
|
||||||
http.ServeContent(w, r, path.Base(p), st.ModTime(), f)
|
http.ServeContent(w, r, path.Base(p), st.ModTime(), f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ func (a *API) instance(w http.ResponseWriter, r *http.Request) {
|
|||||||
case "profile-preview":
|
case "profile-preview":
|
||||||
a.profilePreview(w, r, i)
|
a.profilePreview(w, r, i)
|
||||||
return
|
return
|
||||||
case "profile", "backups", "restore":
|
case "profile", "backups", "restore", "snapshots":
|
||||||
a.config(w, r, i, action)
|
a.config(w, r, i, action)
|
||||||
return
|
return
|
||||||
case "upgrade":
|
case "upgrade":
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/><meta name="theme-color" media="(prefers-color-scheme: light)" content="#152b36"/><meta name="theme-color" media="(prefers-color-scheme: dark)" content="#101c22"/><link rel="icon" type="image/svg+xml" href="/favicon.svg"/><title>DSH · 工作台</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
<html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/><meta name="theme-color" content="#152b36"/><link rel="icon" type="image/svg+xml" href="/favicon.svg"/><title>DSH · 工作台</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { RuntimeWorkspace, useRuntimeInstances } from './RuntimeWorkspace';
|
|||||||
import { Button, buttonVariants } from './components/ui/button';
|
import { Button, buttonVariants } from './components/ui/button';
|
||||||
import { Input } from './components/ui/input';
|
import { Input } from './components/ui/input';
|
||||||
import { cn } from './lib/utils';
|
import { cn } from './lib/utils';
|
||||||
|
import { ThemeSettings } from './ThemeSettings';
|
||||||
|
|
||||||
const pages = [
|
const pages = [
|
||||||
{id: 'dsh', label: 'DSH', icon: SquareTerminal, description: '你的 DeepSeek Harness 工作台'},
|
{id: 'dsh', label: 'DSH', icon: SquareTerminal, description: '你的 DeepSeek Harness 工作台'},
|
||||||
@@ -121,6 +122,7 @@ function Console({user, onLogout, onPasswordChanged}: {user: User; onLogout: ()
|
|||||||
{page === 'workspace' && <Workspace runtime={runtime} />}
|
{page === 'workspace' && <Workspace runtime={runtime} />}
|
||||||
{page === 'admin' && user.admin && <Admin />}
|
{page === 'admin' && user.admin && <Admin />}
|
||||||
{page === 'settings' && <>
|
{page === 'settings' && <>
|
||||||
|
<ThemeSettings />
|
||||||
<Panel title="DSH 实例" action={<Button variant="ghost" size="sm" disabled={runtime.loading} onClick={() => void runtime.refresh()}>刷新</Button>}>
|
<Panel title="DSH 实例" action={<Button variant="ghost" size="sm" disabled={runtime.loading} onClick={() => void runtime.refresh()}>刷新</Button>}>
|
||||||
{runtime.error && <Alert>{runtime.error}<Button variant="outline" size="sm" className="ml-2" disabled={runtime.loading} onClick={() => void runtime.refresh()}>重试</Button></Alert>}
|
{runtime.error && <Alert>{runtime.error}<Button variant="outline" size="sm" className="ml-2" disabled={runtime.loading} onClick={() => void runtime.refresh()}>重试</Button></Alert>}
|
||||||
{runtime.loading && <p role="status" className="m-0 mb-3 text-[13px] text-muted-foreground">正在刷新实例…</p>}
|
{runtime.loading && <p role="status" className="m-0 mb-3 text-[13px] text-muted-foreground">正在刷新实例…</p>}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, expect, it, vi } from 'vitest';
|
import { beforeEach, expect, it, vi } from 'vitest';
|
||||||
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0}));
|
const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0, resourceCursor: 0, snapshots: [] as any[], refreshStorage: vi.fn(async () => {})}));
|
||||||
vi.mock('react', async original => ({
|
vi.mock('react', async original => ({
|
||||||
...await original<typeof import('react')>(),
|
...await original<typeof import('react')>(),
|
||||||
useState(value: unknown) {const i = state.cursor++; if (!(i in state.slots)) state.slots[i] = value; return [state.slots[i], (next: unknown) => {state.slots[i] = next;}];},
|
useState(value: unknown) {const i = state.cursor++; if (!(i in state.slots)) state.slots[i] = value; return [state.slots[i], (next: unknown) => {state.slots[i] = next;}];},
|
||||||
@@ -8,7 +8,7 @@ vi.mock('react', async original => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock('./ui', async original => ({
|
vi.mock('./ui', async original => ({
|
||||||
...await original<typeof import('./ui')>(),
|
...await original<typeof import('./ui')>(),
|
||||||
useResource: () => ({data: [{id: 'backup', created_at: '', size: 1}], loading: false, refresh: vi.fn(async () => {})}),
|
useResource: () => ({data: state.resourceCursor++ % 2 === 0 ? [{id: 'backup', created_at: '', size: 1}] : state.snapshots, loading: false, refresh: state.refreshStorage}),
|
||||||
}));
|
}));
|
||||||
import { BackupPanel } from './BackupPanel';
|
import { BackupPanel } from './BackupPanel';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
@@ -20,12 +20,13 @@ function nodes(root: any, predicate: (node: any) => boolean): any[] {
|
|||||||
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
|
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
|
||||||
}
|
}
|
||||||
const refresh = vi.fn(async () => {});
|
const refresh = vi.fn(async () => {});
|
||||||
function render() {state.cursor = 0; return BackupPanel({instance: {id: 'one'} as any, refresh});}
|
const onRestored = vi.fn(async () => {});
|
||||||
|
function render() {state.cursor = 0; state.resourceCursor = 0; return BackupPanel({instance: {id: 'one'} as any, refresh, onRestored});}
|
||||||
function editor() {
|
function editor() {
|
||||||
const child = nodes(render(), n => typeof n.type === 'function' && n.props.backup)[0];
|
const child = nodes(render(), n => typeof n.type === 'function' && n.props.backup)[0];
|
||||||
return child.type(child.props);
|
return child.type(child.props);
|
||||||
}
|
}
|
||||||
beforeEach(() => {state.slots = []; state.cursor = 0; vi.restoreAllMocks(); refresh.mockClear();});
|
beforeEach(() => {state.slots = []; state.cursor = 0; state.snapshots = []; state.refreshStorage.mockClear(); vi.restoreAllMocks(); refresh.mockClear(); onRestored.mockClear();});
|
||||||
it('retains restore password and shows failure inside the owning modal after confirmation', async () => {
|
it('retains restore password and shows failure inside the owning modal after confirmation', async () => {
|
||||||
const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('restore denied'));
|
const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('restore denied'));
|
||||||
nodes(render(), n => n.type === 'button' && Array.isArray(n.props.children) && n.props.children.includes('恢复'))[0].props.onClick();
|
nodes(render(), n => n.type === 'button' && Array.isArray(n.props.children) && n.props.children.includes('恢复'))[0].props.onClick();
|
||||||
@@ -38,4 +39,72 @@ it('retains restore password and shows failure inside the owning modal after con
|
|||||||
expect(nodes(modal, n => n.type === 'input')[0].props.value).toBe('original-password');
|
expect(nodes(modal, n => n.type === 'input')[0].props.value).toBe('original-password');
|
||||||
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/restore', 'POST', {backup_id: 'backup', password: 'original-password'});
|
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/restore', 'POST', {backup_id: 'backup', password: 'original-password'});
|
||||||
expect(refresh).not.toHaveBeenCalled();
|
expect(refresh).not.toHaveBeenCalled();
|
||||||
|
expect(onRestored).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
function button(label: string) {
|
||||||
|
return nodes(render(), n => n.type === 'button' && (n.props.children === label || Array.isArray(n.props.children) && n.props.children.includes(label)))[0];
|
||||||
|
}
|
||||||
|
it('requires confirmation for configuration deletion and retains download', async () => {
|
||||||
|
const request = vi.spyOn(api, 'request').mockResolvedValue({});
|
||||||
|
expect(nodes(render(), n => n.type === 'a')[0].props.href).toBe('/api/instances/one/backups?download=backup');
|
||||||
|
button('删除配置包').props.onClick();
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(false);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(request).not.toHaveBeenCalled();
|
||||||
|
button('删除配置包').props.onClick();
|
||||||
|
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/backups?id=backup', 'DELETE');
|
||||||
|
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(onRestored).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('shows snapshot bytes separately, deletes confirmed snapshots and refreshes after rejection', async () => {
|
||||||
|
state.snapshots = [{id: 'pre-restore-abc', size: 1048576, created_at: '', protected: false}];
|
||||||
|
const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('instance recovery required'));
|
||||||
|
expect(JSON.stringify(render())).toContain('1.0 MiB');
|
||||||
|
button('删除快照').props.onClick();
|
||||||
|
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/snapshots?id=pre-restore-abc', 'DELETE');
|
||||||
|
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(JSON.stringify(render())).toContain('instance recovery required');
|
||||||
|
expect(onRestored).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('disables protected snapshots and does not report failed size scans as zero', () => {
|
||||||
|
state.snapshots = [{id: 'pre-restore-abc', size: 0, created_at: '', protected: true, protection_reason: 'recovery required', size_error: 'tree limit'}];
|
||||||
|
expect(button('删除快照').props.disabled).toBe(true);
|
||||||
|
const text = JSON.stringify(render());
|
||||||
|
expect(text).toContain('recovery required');
|
||||||
|
expect(text).toContain('统计不完整');
|
||||||
|
expect(text).toContain('tree limit');
|
||||||
|
});
|
||||||
|
it('refreshes storage after successful snapshot deletion', async () => {
|
||||||
|
state.snapshots = [{id: 'pre-restore-abc', size: 1048576, created_at: '', protected: false}];
|
||||||
|
const request = vi.spyOn(api, 'request').mockResolvedValue({ok: true});
|
||||||
|
button('删除快照').props.onClick();
|
||||||
|
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/snapshots?id=pre-restore-abc', 'DELETE');
|
||||||
|
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(JSON.stringify(render())).toContain('快照已删除');
|
||||||
|
expect(onRestored).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('manual storage refresh does not reset the profile', async () => {
|
||||||
|
button('刷新备份与占用').props.onClick();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onRestored).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('only a successful restore invokes the profile reload callback', async () => {
|
||||||
|
vi.spyOn(api, 'request').mockResolvedValue({ok: true});
|
||||||
|
button('恢复').props.onClick();
|
||||||
|
nodes(editor(), n => n.type === 'form')[0].props.onSubmit({preventDefault: vi.fn()});
|
||||||
|
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
expect(onRestored).toHaveBeenCalledOnce();
|
||||||
|
expect(refresh).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
+46
-6
@@ -1,6 +1,6 @@
|
|||||||
import { useState, type FormEvent, type ReactNode } from 'react';
|
import { useState, type FormEvent, type ReactNode } from 'react';
|
||||||
import { Download, HardDriveDownload, RotateCcw, Upload } from 'lucide-react';
|
import { Download, HardDriveDownload, RotateCcw, Trash2, Upload } from 'lucide-react';
|
||||||
import { api, instancePath, type Instance, type Backup } from './api';
|
import { api, instancePath, type Instance, type Backup, type Snapshot } from './api';
|
||||||
import { ResourceState, Empty, bytes, date, useAction, useResource } from './ui';
|
import { ResourceState, Empty, bytes, date, useAction, useResource } from './ui';
|
||||||
import { useConfirm } from './components/ConfirmDialog';
|
import { useConfirm } from './components/ConfirmDialog';
|
||||||
import { AlertBox } from './components/ui/alert';
|
import { AlertBox } from './components/ui/alert';
|
||||||
@@ -9,14 +9,29 @@ import { buttonVariants } from './components/ui/button';
|
|||||||
import { inputClass } from './components/ui/input';
|
import { inputClass } from './components/ui/input';
|
||||||
import { cn } from './lib/utils';
|
import { cn } from './lib/utils';
|
||||||
|
|
||||||
export function BackupPanel({instance, refresh}: {instance: Instance; refresh: () => Promise<void>}) {
|
export function BackupPanel({instance, refresh, onRestored}: {instance: Instance; refresh: () => Promise<void>; onRestored: () => Promise<void>}) {
|
||||||
const base = instancePath(instance.id);
|
const base = instancePath(instance.id);
|
||||||
const backups = useResource(() => api.request<Backup[]>(base + '/backups'));
|
const backups = useResource(() => api.request<Backup[]>(base + '/backups'), base);
|
||||||
|
const snapshots = useResource(() => api.request<Snapshot[]>(base + '/snapshots'), base);
|
||||||
const action = useAction();
|
const action = useAction();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [importPassword, setImportPassword] = useState(''), [file, setFile] = useState<File>();
|
const [importPassword, setImportPassword] = useState(''), [file, setFile] = useState<File>();
|
||||||
const [restoreTarget, setRestoreTarget] = useState<Backup>();
|
const [restoreTarget, setRestoreTarget] = useState<Backup>();
|
||||||
|
async function refreshStorage() {
|
||||||
|
await Promise.all([backups.refresh(), snapshots.refresh(), refresh()]);
|
||||||
|
}
|
||||||
|
async function remove(item: Backup | Snapshot, snapshot: boolean) {
|
||||||
|
if (snapshot && (item as Snapshot).protected) return;
|
||||||
|
if (!await confirm.ask({
|
||||||
|
title: snapshot ? '删除完整 home 快照' : '删除配置备份', danger: true, confirmLabel: '永久删除',
|
||||||
|
body: `确认永久删除 ${item.id}?此操作不可撤销,不影响当前 home。${snapshot ? '删除后将无法使用此快照回滚。' : '请先下载需要保留的配置包;删除配置包不会清理完整快照。'}`,
|
||||||
|
})) return;
|
||||||
|
await action.run(async () => {
|
||||||
|
try { await api.request(base + (snapshot ? '/snapshots?id=' : '/backups?id=') + encodeURIComponent(item.id), 'DELETE'); }
|
||||||
|
finally { await refreshStorage(); }
|
||||||
|
}, snapshot ? '快照已删除,空间占用已刷新' : '配置备份已删除');
|
||||||
|
}
|
||||||
|
|
||||||
function createBackup(e: FormEvent) {
|
function createBackup(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -44,7 +59,10 @@ export function BackupPanel({instance, refresh}: {instance: Instance; refresh: (
|
|||||||
title: '恢复配置备份', danger: true, confirmLabel: '确认恢复',
|
title: '恢复配置备份', danger: true, confirmLabel: '确认恢复',
|
||||||
body: '确认覆盖当前配置?服务器将先保留当前 home 快照,随后按需停止实例并恢复原运行状态。',
|
body: '确认覆盖当前配置?服务器将先保留当前 home 快照,随后按需停止实例并恢复原运行状态。',
|
||||||
})) return false;
|
})) return false;
|
||||||
return action.run(async () => { await api.request(base + '/restore', 'POST', {backup_id: backup.id, password: originalPassword}); await refresh(); }, '配置恢复及插件验证完成,已恢复原运行状态。');
|
return action.run(async () => {
|
||||||
|
try { await api.request(base + '/restore', 'POST', {backup_id: backup.id, password: originalPassword}); await onRestored(); }
|
||||||
|
finally { await snapshots.refresh(); }
|
||||||
|
}, '配置恢复及插件验证完成,已恢复原运行状态。');
|
||||||
}
|
}
|
||||||
|
|
||||||
return <section aria-label="备份与恢复" className="grid gap-5">
|
return <section aria-label="备份与恢复" className="grid gap-5">
|
||||||
@@ -71,6 +89,8 @@ export function BackupPanel({instance, refresh}: {instance: Instance; refresh: (
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!restoreTarget && action.feedback}
|
{!restoreTarget && action.feedback}
|
||||||
|
<h3 className="m-0 text-sm font-semibold">配置备份(.dshbak)</h3>
|
||||||
|
<p className="m-0 text-xs text-muted-foreground">仅包含指定配置与资源,不包含完整 home 或依赖;下方大小为加密配置包大小。</p>
|
||||||
<ResourceState resource={backups} />
|
<ResourceState resource={backups} />
|
||||||
{backups.data?.length === 0 && <Empty>暂无配置备份</Empty>}
|
{backups.data?.length === 0 && <Empty>暂无配置备份</Empty>}
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -79,13 +99,33 @@ export function BackupPanel({instance, refresh}: {instance: Instance; refresh: (
|
|||||||
<p className="m-0 truncate font-mono text-xs">{b.id}</p>
|
<p className="m-0 truncate font-mono text-xs">{b.id}</p>
|
||||||
<p className="m-0 mt-0.5 text-xs text-muted-foreground">{date(b.created_at)} · {bytes(b.size)}</p>
|
<p className="m-0 mt-0.5 text-xs text-muted-foreground">{date(b.created_at)} · {bytes(b.size)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<a className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} href={'/api' + base + '/backups?download=' + encodeURIComponent(b.id)}><Download aria-hidden />下载</a>
|
<a className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} href={'/api' + base + '/backups?download=' + encodeURIComponent(b.id)}><Download aria-hidden />下载</a>
|
||||||
<button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} disabled={action.busy} onClick={() => setRestoreTarget(b)}><RotateCcw aria-hidden />恢复</button>
|
<button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} disabled={action.busy} onClick={() => setRestoreTarget(b)}><RotateCcw aria-hidden />恢复</button>
|
||||||
|
<button className={cn(buttonVariants({variant: 'destructive', size: 'sm'}))} disabled={action.busy} onClick={() => void remove(b, false)}><Trash2 aria-hidden />删除配置包</button>
|
||||||
</div>
|
</div>
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section aria-label="完整 home 快照" className="grid gap-3 border-t border-border pt-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h3 className="m-0 text-sm font-semibold">完整 home 快照</h3>
|
||||||
|
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy || snapshots.loading || backups.loading} onClick={() => void refreshStorage()}>刷新备份与占用</button>
|
||||||
|
</div>
|
||||||
|
<AlertBox variant="info">升级或恢复前保留的完整 home 副本,包含依赖及工作文件,会计入空间配额;不包含在上面的配置包大小中。快照暂不支持网页下载,需要导出时请联系管理员。删除不可撤销,待恢复实例的快照受保护。</AlertBox>
|
||||||
|
<ResourceState resource={snapshots} />
|
||||||
|
{snapshots.data && !snapshots.error && <p className="m-0 text-sm">快照占用(文件字节数):{snapshots.data.some(s => s.size_error) ? '统计不完整,请查看各项错误' : bytes(snapshots.data.reduce((total, s) => total + s.size, 0))}</p>}
|
||||||
|
{snapshots.data?.length === 0 && <Empty>暂无完整 home 快照</Empty>}
|
||||||
|
{snapshots.data?.map(s => <div key={s.id} className="flex flex-wrap items-center gap-3 rounded-lg border border-border bg-card px-3.5 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="m-0 break-all font-mono text-xs">{s.id}</p>
|
||||||
|
<p className="m-0 mt-1 text-xs text-muted-foreground">{date(s.created_at)} · {s.size_error ? '大小统计失败:' + s.size_error : bytes(s.size)}</p>
|
||||||
|
{s.protected && <p className="m-0 mt-1 text-xs text-destructive">受保护:{s.protection_reason || '实例需要恢复,请联系管理员'}</p>}
|
||||||
|
</div>
|
||||||
|
<button className={cn(buttonVariants({variant: 'destructive', size: 'sm'}))} disabled={action.busy || s.protected} onClick={() => void remove(s, true)}><Trash2 aria-hidden />删除快照</button>
|
||||||
|
</div>)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{restoreTarget && <RestoreDialog backup={restoreTarget} busy={action.busy} feedback={action.feedback} onClose={() => setRestoreTarget(undefined)} onSubmit={async password => {
|
{restoreTarget && <RestoreDialog backup={restoreTarget} busy={action.busy} feedback={action.feedback} onClose={() => setRestoreTarget(undefined)} onSubmit={async password => {
|
||||||
const ok = await restore(restoreTarget, password);
|
const ok = await restore(restoreTarget, password);
|
||||||
if (ok) setRestoreTarget(undefined);
|
if (ok) setRestoreTarget(undefined);
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
slots: [] as any[], cursor: 0, resourceCursor: 0, resources: [] as any[],
|
||||||
|
}));
|
||||||
|
vi.mock('react', async original => ({
|
||||||
|
...await original<typeof import('react')>(),
|
||||||
|
useState(value: unknown) {
|
||||||
|
const slots = state.slots, i = state.cursor++;
|
||||||
|
if (!(i in slots)) slots[i] = value;
|
||||||
|
return [slots[i], (next: unknown) => {slots[i] = typeof next === 'function' ? next(slots[i]) : next;}];
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock('./ui', async original => ({
|
||||||
|
...await original<typeof import('./ui')>(),
|
||||||
|
useResource: () => state.resources[state.resourceCursor++],
|
||||||
|
useAction: () => ({busy: false, feedback: null}),
|
||||||
|
}));
|
||||||
|
vi.mock('./components/ConfirmDialog', () => ({useConfirm: () => ({element: null})}));
|
||||||
|
vi.mock('./ProfileEditor', () => ({ProfileEditor: () => null}));
|
||||||
|
|
||||||
|
import { Instances } from './Instances';
|
||||||
|
import { ProfileEditor } from './ProfileEditor';
|
||||||
|
import { BackupPanel } from './BackupPanel';
|
||||||
|
|
||||||
|
function nodes(root: any, predicate: (node: any) => boolean): any[] {
|
||||||
|
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
|
||||||
|
if (!root || typeof root !== 'object') return [];
|
||||||
|
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('preserves the unsaved Profile draft on backup refresh and reloads it only after restore', async () => {
|
||||||
|
const instance = {id: 'one', name: 'Test instance', status: 'stopped', created_at: '', version: 'test'};
|
||||||
|
const instances = {data: [instance], loading: false, refresh: vi.fn(async () => {})};
|
||||||
|
const usage = {data: {used_bytes: 1, quota_bytes: 100}, loading: false, refresh: vi.fn(async () => {})};
|
||||||
|
const profile = {
|
||||||
|
data: {content: 'name: original'}, loading: false,
|
||||||
|
refresh: vi.fn(async () => {profile.data = {content: 'name: restored'};}),
|
||||||
|
};
|
||||||
|
const parentSlots: any[] = [], detailSlots: any[] = [];
|
||||||
|
function renderParent() {
|
||||||
|
state.slots = parentSlots; state.cursor = 0; state.resourceCursor = 0;
|
||||||
|
state.resources = [instances, usage];
|
||||||
|
return Instances();
|
||||||
|
}
|
||||||
|
function renderDetail() {
|
||||||
|
const detail = nodes(renderParent(), n => typeof n.type === 'function' && n.props.instance?.id === instance.id)[0];
|
||||||
|
expect(detail).toBeDefined();
|
||||||
|
state.slots = detailSlots; state.cursor = 0; state.resourceCursor = 0;
|
||||||
|
state.resources = [{data: []}, profile, {data: undefined}, {data: undefined}];
|
||||||
|
return detail.type(detail.props);
|
||||||
|
}
|
||||||
|
const editor = (tree: any) => nodes(tree, n => n.type === ProfileEditor && n.props.onChange)[0];
|
||||||
|
const backup = (tree: any) => nodes(tree, n => n.type === BackupPanel)[0];
|
||||||
|
const save = (tree: any) => nodes(tree, n => n.type === 'button' && n.props.children === '保存并启动')[0];
|
||||||
|
|
||||||
|
nodes(renderParent(), n => n.type === 'button' && n.props.children === '管理')[0].props.onClick();
|
||||||
|
let tree = renderDetail();
|
||||||
|
expect(editor(tree).props.value).toBe('name: original');
|
||||||
|
expect(save(tree).props.disabled).toBe(true);
|
||||||
|
editor(tree).props.onChange('name: unsaved draft');
|
||||||
|
tree = renderDetail();
|
||||||
|
expect(editor(tree).props.value).toBe('name: unsaved draft');
|
||||||
|
expect(save(tree).props.disabled).toBe(false);
|
||||||
|
|
||||||
|
await backup(tree).props.refresh();
|
||||||
|
expect(instances.refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(usage.refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(profile.refresh).not.toHaveBeenCalled();
|
||||||
|
tree = renderDetail();
|
||||||
|
expect(editor(tree).props.value).toBe('name: unsaved draft');
|
||||||
|
expect(save(tree).props.disabled).toBe(false);
|
||||||
|
|
||||||
|
await backup(tree).props.onRestored();
|
||||||
|
expect(profile.refresh).toHaveBeenCalledOnce();
|
||||||
|
expect(instances.refresh).toHaveBeenCalledTimes(2);
|
||||||
|
expect(usage.refresh).toHaveBeenCalledTimes(2);
|
||||||
|
tree = renderDetail();
|
||||||
|
expect(editor(tree).props.value).toBe('name: restored');
|
||||||
|
expect(save(tree).props.disabled).toBe(true);
|
||||||
|
});
|
||||||
@@ -100,7 +100,7 @@ export function Instances() {
|
|||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
</Panel>
|
</Panel>
|
||||||
{selected && <InstanceDetail key={selected.id} instance={instances.data?.find(i => i.id === selected.id) || selected} close={() => setSelected(undefined)} refresh={instances.refresh} />}
|
{selected && <InstanceDetail key={selected.id} instance={instances.data?.find(i => i.id === selected.id) || selected} close={() => setSelected(undefined)} refresh={async () => { await Promise.all([instances.refresh(), usage.refresh()]); }} />}
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ function InstanceDetail({instance, close, refresh}: {instance: Instance; close:
|
|||||||
</>}
|
</>}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="backups">
|
<TabsContent value="backups">
|
||||||
<BackupPanel instance={instance} refresh={async () => { await profile.refresh(); setContent(undefined); await refresh(); }} />
|
<BackupPanel instance={instance} refresh={refresh} onRestored={async () => { await profile.refresh(); setContent(undefined); await refresh(); }} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="upgrade">
|
<TabsContent value="upgrade">
|
||||||
<form className="grid max-w-xl gap-4" onSubmit={async e => {
|
<form className="grid max-w-xl gap-4" onSubmit={async e => {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Panel } from './ui';
|
||||||
|
import { Button } from './components/ui/button';
|
||||||
|
import { setThemePreference, useThemePreference, type ThemePreference } from './nightMode';
|
||||||
|
|
||||||
|
const options: {value: ThemePreference; label: string}[] = [
|
||||||
|
{value: 'light', label: '日间'},
|
||||||
|
{value: 'dark', label: '夜间'},
|
||||||
|
{value: 'auto', label: '自动(跟随系统)'},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ThemeSettings() {
|
||||||
|
const preference = useThemePreference();
|
||||||
|
return <Panel title="外观">
|
||||||
|
<div role="group" aria-label="工作台主题" className="flex flex-wrap gap-2">
|
||||||
|
{options.map(option => <Button key={option.value} type="button"
|
||||||
|
variant={preference === option.value ? 'default' : 'outline'}
|
||||||
|
aria-pressed={preference === option.value} onClick={() => setThemePreference(option.value)}>
|
||||||
|
{option.label}
|
||||||
|
</Button>)}
|
||||||
|
</div>
|
||||||
|
<p className="mb-0 mt-3 text-[13px] text-muted-foreground">仅在此浏览器保存,适用于工作台、登录页和文件编辑器。自动模式随系统实时切换;内嵌 DSH 的主题独立管理,不受此设置影响。</p>
|
||||||
|
</Panel>;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ export interface Entry { name: string; path?: string; is_dir: boolean; size: num
|
|||||||
export interface Usage { used_bytes: number; quota_bytes: number }
|
export interface Usage { used_bytes: number; quota_bytes: number }
|
||||||
export interface TextFile { content: string; version: string }
|
export interface TextFile { content: string; version: string }
|
||||||
export interface Backup { id: string; created_at: string; size: number }
|
export interface Backup { id: string; created_at: string; size: number }
|
||||||
|
export interface Snapshot { id: string; created_at: string; size: number; protected: boolean; protection_reason: string; size_error?: string }
|
||||||
export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string }
|
export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string }
|
||||||
export function createApi(fetcher: typeof fetch = fetch) {
|
export function createApi(fetcher: typeof fetch = fetch) {
|
||||||
let csrf = '';
|
let csrf = '';
|
||||||
|
|||||||
@@ -3,4 +3,7 @@ import { createRoot } from "react-dom/client";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { RuntimeRenew } from "./RuntimeRenew";
|
import { RuntimeRenew } from "./RuntimeRenew";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
import { initializeTheme } from "./nightMode";
|
||||||
|
const disposeTheme = initializeTheme();
|
||||||
|
if (import.meta.hot) import.meta.hot.dispose(disposeTheme);
|
||||||
createRoot(document.getElementById("root")!).render(<React.StrictMode>{window.location.hash.startsWith("#runtime-renew?") ? <RuntimeRenew /> : <App />}</React.StrictMode>);
|
createRoot(document.getElementById("root")!).render(<React.StrictMode>{window.location.hash.startsWith("#runtime-renew?") ? <RuntimeRenew /> : <App />}</React.StrictMode>);
|
||||||
|
|||||||
+86
-27
@@ -1,32 +1,91 @@
|
|||||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
const hooks = vi.hoisted(() => ({slots: [] as any[], cursor: 0, effects: [] as (() => void)[]}));
|
const hooks = vi.hoisted(() => ({cleanups: [] as (() => void)[], notify: vi.fn()}));
|
||||||
vi.mock('react', async importOriginal => ({
|
vi.mock('react', async original => ({
|
||||||
...await importOriginal<typeof import('react')>(),
|
...await original<typeof import('react')>(),
|
||||||
useRef(value: unknown) { const i = hooks.cursor++; return hooks.slots[i] ??= {current:value}; },
|
useSyncExternalStore(subscribe: (fn: () => void) => () => void, snapshot: () => unknown) {
|
||||||
useState(value: unknown) { const i = hooks.cursor++; if (!(i in hooks.slots)) hooks.slots[i] = value; return [hooks.slots[i], (v: unknown) => { hooks.slots[i] = v; }]; },
|
hooks.cleanups.push(subscribe(hooks.notify));
|
||||||
useEffect(fn: () => (() => void) | undefined, deps: unknown[]) {
|
return snapshot();
|
||||||
const i = hooks.cursor++, previous = hooks.slots[i];
|
|
||||||
if (!previous || deps.some((v, n) => v !== previous.deps[n])) hooks.effects.push(() => { previous?.cleanup?.(); hooks.slots[i] = {deps, cleanup:fn()}; });
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
import { useNightMode } from './nightMode';
|
import { initializeTheme, setThemePreference, themeStorageKey, useNightMode, useThemePreference } from './nightMode';
|
||||||
|
import { ThemeSettings } from './ThemeSettings';
|
||||||
const media = {matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn()};
|
let cleanup: (() => void) | undefined;
|
||||||
const render = () => { hooks.cursor = 0; const result = useNightMode(); hooks.effects.splice(0).forEach(effect => effect()); return result; };
|
let media: EventTarget & {matches: boolean};
|
||||||
beforeEach(() => { hooks.slots = []; hooks.cursor = 0; hooks.effects = []; media.matches = false; vi.clearAllMocks(); vi.stubGlobal('window', {matchMedia: vi.fn(() => media)}); });
|
let win: EventTarget & {localStorage: {getItem: ReturnType<typeof vi.fn>; setItem: ReturnType<typeof vi.fn>}; matchMedia: ReturnType<typeof vi.fn>};
|
||||||
afterEach(() => { hooks.slots.forEach(s => s?.cleanup?.()); vi.unstubAllGlobals(); });
|
let dataset: {theme?: string};
|
||||||
|
let meta: {setAttribute: ReturnType<typeof vi.fn>};
|
||||||
it('reflects the current system color scheme', () => {
|
let saved: string | null;
|
||||||
media.matches = true;
|
beforeEach(() => {
|
||||||
expect(render()).toBe(true);
|
saved = null;
|
||||||
expect(media.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
media = Object.assign(new EventTarget(), {matches: false});
|
||||||
|
win = Object.assign(new EventTarget(), {localStorage: {getItem: vi.fn(() => saved), setItem: vi.fn((_key, value) => {saved = value;})}, matchMedia: vi.fn(() => media)});
|
||||||
|
dataset = {}; meta = {setAttribute: vi.fn()};
|
||||||
|
vi.stubGlobal('window', win);
|
||||||
|
vi.stubGlobal('document', {documentElement: {dataset}, querySelector: vi.fn(() => meta)});
|
||||||
|
hooks.notify.mockClear();
|
||||||
});
|
});
|
||||||
|
afterEach(() => {cleanup?.(); hooks.cleanups.splice(0).forEach(fn => fn()); vi.unstubAllGlobals();});
|
||||||
it('updates when the system scheme changes and unsubscribes on cleanup', () => {
|
function start() {cleanup = initializeTheme();}
|
||||||
expect(render()).toBe(false);
|
function os(dark: boolean) {media.matches = dark; media.dispatchEvent(new Event('change'));}
|
||||||
const listener = media.addEventListener.mock.calls[0][1] as (event: MediaQueryListEvent) => void;
|
function storage(value: string | null, key: string | null = themeStorageKey, area: unknown = win.localStorage) {
|
||||||
listener({matches: true} as MediaQueryListEvent);
|
win.dispatchEvent(Object.assign(new Event('storage'), {key, newValue: value, storageArea: area}));
|
||||||
expect(render()).toBe(true);
|
}
|
||||||
hooks.slots.forEach(s => s?.cleanup?.());
|
it('defaults to auto and applies OS changes to DOM, metadata and Monaco', () => {
|
||||||
expect(media.removeEventListener).toHaveBeenCalledWith('change', listener);
|
start(); expect(useThemePreference()).toBe('auto'); expect(useNightMode()).toBe(false);
|
||||||
|
os(true); expect(useNightMode()).toBe(true); expect(hooks.notify).toHaveBeenCalled();
|
||||||
|
expect(dataset.theme).toBe('dark');
|
||||||
|
expect(meta.setAttribute).toHaveBeenLastCalledWith('content', '#101c22');
|
||||||
|
os(false); expect(dataset.theme).toBe('light');
|
||||||
|
expect(meta.setAttribute).toHaveBeenLastCalledWith('content', '#152b36');
|
||||||
|
});
|
||||||
|
it.each(['light', 'dark'] as const)('persists %s and overrides either OS scheme across initialization', value => {
|
||||||
|
start(); setThemePreference(value);
|
||||||
|
for (const system of [true, false]) {os(system); expect(useNightMode()).toBe(value === 'dark');}
|
||||||
|
expect(win.localStorage.setItem).toHaveBeenCalledWith(themeStorageKey, value);
|
||||||
|
cleanup!(); start(); expect(useThemePreference()).toBe(value); expect(dataset.theme).toBe(value);
|
||||||
|
setThemePreference('auto'); os(true); expect(useNightMode()).toBe(true); expect(saved).toBe('auto');
|
||||||
|
});
|
||||||
|
it.each([null, '', 'invalid', 'DARK', 'auto'])('treats stored %s as auto', value => {
|
||||||
|
saved = value; media.matches = true; start();
|
||||||
|
expect(useThemePreference()).toBe('auto'); expect(dataset.theme).toBe('dark');
|
||||||
|
});
|
||||||
|
it('synchronizes tabs including removal, clear and invalid values without writing back', () => {
|
||||||
|
start(); storage('dark'); expect(useNightMode()).toBe(true);
|
||||||
|
storage('light'); expect(useThemePreference()).toBe('light'); os(true); expect(useNightMode()).toBe(false);
|
||||||
|
for (const value of [null, 'bad', 'auto']) {storage(value); expect(useThemePreference()).toBe('auto'); expect(useNightMode()).toBe(true);}
|
||||||
|
storage('light'); storage(null, null); expect(useThemePreference()).toBe('auto');
|
||||||
|
storage('light', 'unrelated'); storage('light', themeStorageKey, {});
|
||||||
|
expect(useThemePreference()).toBe('auto'); expect(win.localStorage.setItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('keeps manual choices usable with blocked storage', () => {
|
||||||
|
Object.defineProperty(win, 'localStorage', {get() {throw new Error('blocked');}});
|
||||||
|
start(); expect(useThemePreference()).toBe('auto');
|
||||||
|
setThemePreference('dark'); os(false); expect(useNightMode()).toBe(true);
|
||||||
|
setThemePreference('light'); os(true); expect(useNightMode()).toBe(false);
|
||||||
|
setThemePreference('auto'); expect(useNightMode()).toBe(true);
|
||||||
|
});
|
||||||
|
it('keeps a selection when storage writes fail', () => {
|
||||||
|
start(); win.localStorage.setItem.mockImplementation(() => {throw new Error('quota');});
|
||||||
|
setThemePreference('dark'); expect(dataset.theme).toBe('dark');
|
||||||
|
});
|
||||||
|
it('initializes once and cleans up global listeners and hook subscriptions', () => {
|
||||||
|
const removeMedia = vi.spyOn(media, 'removeEventListener');
|
||||||
|
const removeWindow = vi.spyOn(win, 'removeEventListener');
|
||||||
|
start(); expect(initializeTheme()).toBe(cleanup); expect(win.matchMedia).toHaveBeenCalledOnce();
|
||||||
|
useNightMode(); hooks.cleanups.splice(0).forEach(fn => fn());
|
||||||
|
hooks.notify.mockClear(); setThemePreference('light'); expect(hooks.notify).not.toHaveBeenCalled();
|
||||||
|
cleanup!(); cleanup = undefined;
|
||||||
|
expect(removeMedia).toHaveBeenCalledWith('change', expect.any(Function));
|
||||||
|
expect(removeWindow).toHaveBeenCalledWith('storage', expect.any(Function));
|
||||||
|
os(true); storage('dark'); expect(dataset.theme).toBe('light');
|
||||||
|
});
|
||||||
|
it('settings buttons expose one pressed choice and explain DSH independence', () => {
|
||||||
|
start(); const buttons = () => ThemeSettings().props.children[0].props.children;
|
||||||
|
expect(buttons().map((b: any) => b.props['aria-pressed'])).toEqual([false, false, true]);
|
||||||
|
for (const [index, value] of ['light', 'dark', 'auto'].entries()) {
|
||||||
|
buttons()[index].props.onClick(); expect(useThemePreference()).toBe(value);
|
||||||
|
expect(buttons().filter((b: any) => b.props['aria-pressed'])).toHaveLength(1);
|
||||||
|
expect(buttons()[index].props['aria-pressed']).toBe(true);
|
||||||
|
}
|
||||||
|
expect(JSON.stringify(ThemeSettings())).toContain('内嵌 DSH 的主题独立管理');
|
||||||
});
|
});
|
||||||
|
|||||||
+54
-12
@@ -1,16 +1,58 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
const query = () => window.matchMedia('(prefers-color-scheme: dark)');
|
export type ThemePreference = 'light' | 'dark' | 'auto';
|
||||||
|
export const themeStorageKey = 'dsh.console.theme';
|
||||||
|
let preference: ThemePreference = 'auto';
|
||||||
|
let dark = false;
|
||||||
|
let media: MediaQueryList | undefined;
|
||||||
|
let dispose: (() => void) | undefined;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
const normalize = (value: string | null): ThemePreference => value === 'light' || value === 'dark' ? value : 'auto';
|
||||||
|
|
||||||
/** Tracks the OS dark-scheme preference, matching the automatic night mode of embedded DSH. */
|
function update() {
|
||||||
|
dark = preference === 'dark' || (preference === 'auto' && !!media?.matches);
|
||||||
|
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
|
||||||
|
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', dark ? '#101c22' : '#152b36');
|
||||||
|
listeners.forEach(listener => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeTheme() {
|
||||||
|
if (dispose) return dispose;
|
||||||
|
try { preference = normalize(window.localStorage.getItem(themeStorageKey)); } catch { preference = 'auto'; }
|
||||||
|
media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const storage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== themeStorageKey && event.key !== null) return;
|
||||||
|
try { if (event.storageArea !== window.localStorage) return; } catch { return; }
|
||||||
|
preference = normalize(event.key === null ? null : event.newValue);
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
media.addEventListener('change', update);
|
||||||
|
window.addEventListener('storage', storage);
|
||||||
|
update();
|
||||||
|
const currentMedia = media;
|
||||||
|
dispose = () => {
|
||||||
|
currentMedia.removeEventListener('change', update);
|
||||||
|
window.removeEventListener('storage', storage);
|
||||||
|
dispose = undefined;
|
||||||
|
};
|
||||||
|
return dispose;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setThemePreference(value: ThemePreference) {
|
||||||
|
preference = value;
|
||||||
|
try { window.localStorage.setItem(themeStorageKey, value); } catch { /* Keep the selection for this page when storage is blocked. */ }
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(listener: () => void) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => { listeners.delete(listener); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThemePreference() {
|
||||||
|
return useSyncExternalStore(subscribe, () => preference);
|
||||||
|
}
|
||||||
|
|
||||||
export function useNightMode() {
|
export function useNightMode() {
|
||||||
const [dark, setDark] = useState(query().matches);
|
return useSyncExternalStore(subscribe, () => dark);
|
||||||
useEffect(() => {
|
|
||||||
const media = query();
|
|
||||||
const update = (event: MediaQueryListEvent) => setDark(event.matches);
|
|
||||||
setDark(media.matches);
|
|
||||||
media.addEventListener('change', update);
|
|
||||||
return () => media.removeEventListener('change', update);
|
|
||||||
}, []);
|
|
||||||
return dark;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-6
@@ -51,15 +51,11 @@
|
|||||||
--touch-min: 2.75rem;
|
--touch-min: 2.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Automatic night mode: follow the OS color scheme, same as embedded DSH.
|
|
||||||
Dark values override the @theme variables above; utilities keep working
|
|
||||||
because they reference the variables at computed-value time. */
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
:root[data-theme="dark"] {
|
||||||
:root {
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--color-background: #101c22;
|
--color-background: #101c22;
|
||||||
--color-foreground: #d9e5e9;
|
--color-foreground: #d9e5e9;
|
||||||
@@ -91,7 +87,6 @@
|
|||||||
--shadow-dialog: 0 24px 80px rgb(0 0 0 / 0.55);
|
--shadow-dialog: 0 24px 80px rgb(0 0 0 / 0.55);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
|
|||||||
Reference in New Issue
Block a user