feat(settings): show categorized storage usage on demand
This commit is contained in:
1 parent
ac10de0466
commit
56145dba72
14 files changed
+440
-5
No files matched your search
@@ -38,6 +38,7 @@ type Server struct {
|
||||
// 串行化设置的"读取-合并-写入"
|
||||
settingsMu sync.Mutex
|
||||
// ThemesDir 指向数据目录中的主题目录;为空时只有内置主题。
|
||||
DataDir string
|
||||
ThemesDir string
|
||||
PluginsDir string
|
||||
FontsDir string
|
||||
@@ -93,6 +94,7 @@ func (s *Server) routes() []route {
|
||||
{"GET /api/v1/raw/{path...}", authed, s.raw},
|
||||
{"GET /api/v1/trash", authed, s.trashList},
|
||||
{"DELETE /api/v1/trash", authed, s.trashClear},
|
||||
{"GET /api/v1/storage", sessionOnly, s.getStorage},
|
||||
{"POST /api/v1/trash/{id}/restore", authed, s.trashRestore},
|
||||
{"DELETE /api/v1/trash/{id}", authed, s.trashPurge},
|
||||
{"GET /api/v1/events", authed, s.events},
|
||||
|
||||
@@ -128,6 +128,7 @@ func newFixture(t *testing.T) *fixture {
|
||||
t.Cleanup(func() { idx.Close() })
|
||||
hub := events.NewHub()
|
||||
server := New(v, am, st, hub, idx)
|
||||
server.DataDir = dir
|
||||
server.ThemesDir = filepath.Join(dir, "themes")
|
||||
server.PluginsDir = filepath.Join(dir, "plugins")
|
||||
server.FontsDir = filepath.Join(dir, "fonts")
|
||||
|
||||
@@ -2185,6 +2185,38 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/storage": {
|
||||
"get": {
|
||||
"summary": "按需统计存储空间",
|
||||
"description": "只接受登录会话。统计数据目录和实际笔记库的普通文件字节,嵌套去重,不跟随内部符号链接;附件/回收站是笔记库的子项,totalSize不重复累加。calculatedAt为统计完成时间(Unix毫秒)。扫描失败返回错误,不返回部分结果。",
|
||||
"tags": [
|
||||
"设置"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"cookie": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "统计结果",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StorageUsage"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "只接受登录会话"
|
||||
},
|
||||
"500": {
|
||||
"description": "扫描失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -2429,6 +2461,61 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"StorageUsage": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"totalSize",
|
||||
"categories",
|
||||
"assetsSize",
|
||||
"trashSize",
|
||||
"calculatedAt"
|
||||
],
|
||||
"properties": {
|
||||
"totalSize": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"assetsSize": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"trashSize": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"calculatedAt": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"size"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"vault",
|
||||
"databases",
|
||||
"fonts",
|
||||
"themes",
|
||||
"plugins",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"size": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type storageCategory struct {
|
||||
ID string `json:"id"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type storageUsage struct {
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
Categories []storageCategory `json:"categories"`
|
||||
AssetsSize int64 `json:"assetsSize"`
|
||||
TrashSize int64 `json:"trashSize"`
|
||||
CalculatedAt int64 `json:"calculatedAt"`
|
||||
}
|
||||
|
||||
func (s *Server) getStorage(w http.ResponseWriter, r *http.Request) {
|
||||
usage, err := scanStorage(r.Context(), s.DataDir, s.vault.Root())
|
||||
if err != nil {
|
||||
internalError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, usage)
|
||||
}
|
||||
|
||||
// 只统计普通文件逻辑字节,不跟随内部符号链接;外置仓库单独扫描。
|
||||
// 两个根互相嵌套时,各自由更具体的根负责,避免重复计算。
|
||||
func scanStorage(ctx context.Context, dataDir, vaultDir string) (storageUsage, error) {
|
||||
usage := storageUsage{Categories: []storageCategory{{ID: "vault"}, {ID: "databases"}, {ID: "fonts"}, {ID: "themes"}, {ID: "plugins"}, {ID: "other"}}}
|
||||
if dataDir == "" {
|
||||
return usage, fmt.Errorf("未配置应用数据目录")
|
||||
}
|
||||
dataRoot, err := filepath.EvalSymlinks(dataDir)
|
||||
if err != nil {
|
||||
return usage, err
|
||||
}
|
||||
dataRoot, err = filepath.Abs(dataRoot)
|
||||
if err != nil {
|
||||
return usage, err
|
||||
}
|
||||
vaultRoot, err := filepath.Abs(vaultDir)
|
||||
if err != nil {
|
||||
return usage, err
|
||||
}
|
||||
appCategory := func(rel string) int {
|
||||
top := strings.Split(filepath.ToSlash(rel), "/")[0]
|
||||
switch top {
|
||||
case "rawmark.db", "rawmark.db-wal", "rawmark.db-shm", "index.db", "index.db-wal", "index.db-shm":
|
||||
return 1
|
||||
case "fonts":
|
||||
return 2
|
||||
case "themes":
|
||||
return 3
|
||||
case "plugins":
|
||||
return 4
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
}
|
||||
walk := func(root, skip string, isVault bool) error {
|
||||
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if walkErr != nil {
|
||||
if path != root && errors.Is(walkErr, fs.ErrNotExist) {
|
||||
return nil // 文件可能在扫描期间被移动或删除。
|
||||
}
|
||||
return walkErr
|
||||
}
|
||||
if path != root && path == skip && entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if entry.IsDir() || entry.Type()&fs.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
category := 0
|
||||
if !isVault || dataRoot == vaultRoot {
|
||||
category = appCategory(rel)
|
||||
if isVault && category == 5 {
|
||||
category = 0
|
||||
}
|
||||
}
|
||||
size := info.Size()
|
||||
usage.TotalSize += size
|
||||
usage.Categories[category].Size += size
|
||||
if category == 0 {
|
||||
parts := strings.Split(filepath.ToSlash(rel), "/")
|
||||
if len(parts) >= 3 && parts[0] == ".rawmark" && parts[1] == "trash" {
|
||||
usage.TrashSize += size
|
||||
} else {
|
||||
for _, part := range parts[:len(parts)-1] {
|
||||
if part == "assets" || strings.HasSuffix(part, ".assets") {
|
||||
usage.AssetsSize += size
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if dataRoot != vaultRoot {
|
||||
if err := walk(dataRoot, vaultRoot, false); err != nil {
|
||||
return storageUsage{}, err
|
||||
}
|
||||
}
|
||||
if err := walk(vaultRoot, dataRoot, true); err != nil {
|
||||
return storageUsage{}, err
|
||||
}
|
||||
usage.CalculatedAt = time.Now().UnixMilli()
|
||||
return usage, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStorageCountsOwnedRootsOnce(t *testing.T) {
|
||||
for _, layout := range []string{"nested-vault", "external-vault", "nested-data", "same-root"} {
|
||||
t.Run(layout, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
data, vault := filepath.Join(root, "data"), filepath.Join(root, "data", "vault")
|
||||
switch layout {
|
||||
case "external-vault":
|
||||
vault = filepath.Join(root, "external")
|
||||
case "nested-data":
|
||||
vault, data = root, filepath.Join(root, "app")
|
||||
case "same-root":
|
||||
vault = data
|
||||
}
|
||||
write := func(base, rel string, size int) {
|
||||
t.Helper()
|
||||
path := filepath.Join(base, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, make([]byte, size), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(vault, "note.md", 5)
|
||||
write(vault, "dir/assets/a.png", 7)
|
||||
write(vault, "old.assets/b.png", 11)
|
||||
write(vault, ".rawmark/trash/id/meta.json", 13)
|
||||
write(vault, ".rawmark/trash/id/assets/a.png", 17)
|
||||
write(data, "rawmark.db", 19)
|
||||
write(data, "index.db-wal", 23)
|
||||
write(data, "fonts/a/file.woff2", 29)
|
||||
write(data, "themes/a/theme.css", 31)
|
||||
write(data, "plugins/a/main.js", 37)
|
||||
write(data, "misc.bin", 41)
|
||||
outside := t.TempDir()
|
||||
write(outside, "outside.md", 1000)
|
||||
if err := os.Symlink(outside, filepath.Join(vault, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
usage, err := scanStorage(context.Background(), data, vault)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]int64{"vault": 53, "databases": 42, "fonts": 29, "themes": 31, "plugins": 37, "other": 41}
|
||||
if layout == "same-root" {
|
||||
want["vault"] += 41
|
||||
want["other"] = 0
|
||||
}
|
||||
for _, category := range usage.Categories {
|
||||
if category.Size != want[category.ID] {
|
||||
t.Errorf("%s = %d, want %d", category.ID, category.Size, want[category.ID])
|
||||
}
|
||||
}
|
||||
if usage.TotalSize != 233 || usage.AssetsSize != 18 || usage.TrashSize != 30 || usage.CalculatedAt <= 0 {
|
||||
t.Fatalf("总量或子项不符: %+v", usage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStorageDoesNotReturnPartialSuccess(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if _, err := scanStorage(context.Background(), root, filepath.Join(root, "missing")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("仓库缺失应报错: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := scanStorage(ctx, root, root); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("请求取消应停止扫描: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -34,13 +34,16 @@ func TestTrashRetentionSettingsValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashClearAuthorization(t *testing.T) {
|
||||
func TestStorageAndTrashClearAuthorization(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.write("a.md", "content")
|
||||
decode[vault.TrashItem](t, f.do(f.admin, "DELETE", "/api/v1/files/a.md", "", nil))
|
||||
for _, scope := range []string{"read", "write"} {
|
||||
token := decode[struct{ Token string }](t, f.do(f.admin, "POST", "/api/v1/tokens", `{"name":"test","scope":"`+scope+`"}`, nil)).Token
|
||||
headers := map[string]string{"Authorization": "Bearer " + token}
|
||||
if code := f.status(&http.Client{}, "GET", "/api/v1/storage", "", headers); code != 403 {
|
||||
t.Fatalf("存储统计仅接受会话: %d", code)
|
||||
}
|
||||
if scope == "read" {
|
||||
if code := f.status(&http.Client{}, "DELETE", "/api/v1/trash", "", headers); code != 403 {
|
||||
t.Fatalf("只读不能清空: %d", code)
|
||||
@@ -52,6 +55,13 @@ func TestTrashClearAuthorization(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
usage := decode[storageUsage](t, f.do(f.admin, "GET", "/api/v1/storage", "", nil))
|
||||
if usage.TotalSize <= 0 || len(usage.Categories) != 6 {
|
||||
t.Fatalf("会话统计: %+v", usage)
|
||||
}
|
||||
if code := f.status(&http.Client{}, "GET", "/api/v1/storage", "", nil); code != 401 {
|
||||
t.Fatalf("匿名不能查看: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupUsesSavedRetentionAndPreservesInvalidEntries(t *testing.T) {
|
||||
|
||||
Reference in new issue
Block a user