mirror of https://github.com/usememos/memos
feat(stats): admin instance resource statistics
parent
cd4f28ae10
commit
ea0625da45
@ -0,0 +1,165 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
"github.com/usememos/memos/store"
|
||||
)
|
||||
|
||||
const instanceStatsCacheTTL = 60 * time.Second
|
||||
|
||||
// instanceStatsCache is a single-value, mutex-guarded cache for InstanceStats.
|
||||
type instanceStatsCache struct {
|
||||
mu sync.Mutex
|
||||
value *v1pb.InstanceStats
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
func (c *instanceStatsCache) get() (*v1pb.InstanceStats, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.value == nil || time.Now().After(c.expiry) {
|
||||
return nil, false
|
||||
}
|
||||
return c.value, true
|
||||
}
|
||||
|
||||
func (c *instanceStatsCache) set(v *v1pb.InstanceStats, ttl time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.value = v
|
||||
c.expiry = time.Now().Add(ttl)
|
||||
}
|
||||
|
||||
// GetInstanceStats returns resource usage statistics. Admin only.
|
||||
func (s *APIV1Service) GetInstanceStats(ctx context.Context, _ *v1pb.GetInstanceStatsRequest) (*v1pb.InstanceStats, error) {
|
||||
user, err := s.fetchCurrentUser(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "user not authenticated")
|
||||
}
|
||||
if user.Role != store.RoleAdmin {
|
||||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
|
||||
if cached, ok := s.instanceStatsCache.get(); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
stats, err := s.computeInstanceStats(ctx)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to compute instance stats: %v", err)
|
||||
}
|
||||
s.instanceStatsCache.set(stats, instanceStatsCacheTTL)
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// computeInstanceStats runs all stat subqueries in parallel and assembles the result.
|
||||
// Per-subtask failures degrade to -1 sentinel values; only a total failure (every
|
||||
// subtask errored) is propagated as an error.
|
||||
func (s *APIV1Service) computeInstanceStats(ctx context.Context) (*v1pb.InstanceStats, error) {
|
||||
stats := &v1pb.InstanceStats{
|
||||
Database: &v1pb.InstanceStats_DatabaseStats{
|
||||
Driver: s.Profile.Driver,
|
||||
SizeBytes: -1,
|
||||
},
|
||||
LocalStorageBytes: -1,
|
||||
GeneratedTime: timestamppb.Now(),
|
||||
}
|
||||
|
||||
type result struct {
|
||||
name string
|
||||
err error
|
||||
}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
results []result
|
||||
record = func(name string, err error) {
|
||||
mu.Lock()
|
||||
results = append(results, result{name, err})
|
||||
mu.Unlock()
|
||||
}
|
||||
)
|
||||
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
|
||||
g.Go(func() error {
|
||||
size, err := s.Store.GetDriver().GetDatabaseSize(gctx)
|
||||
if err != nil {
|
||||
record("database_size", err)
|
||||
return nil
|
||||
}
|
||||
stats.Database.SizeBytes = size
|
||||
return nil
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
size, err := walkLocalStorage(s.Profile.Data)
|
||||
if err != nil {
|
||||
record("local_storage", err)
|
||||
return nil
|
||||
}
|
||||
stats.LocalStorageBytes = size
|
||||
return nil
|
||||
})
|
||||
|
||||
_ = g.Wait()
|
||||
|
||||
for _, r := range results {
|
||||
slog.Warn("instance stats subtask failed", slog.String("subtask", r.name), slog.String("err", r.err.Error()))
|
||||
}
|
||||
|
||||
const totalSubtasks = 2
|
||||
if len(results) == totalSubtasks {
|
||||
return nil, errors.New("all instance stats subtasks failed")
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// walkLocalStorage returns the recursive size of dir in bytes.
|
||||
// Symlinks are not followed; per-entry errors below the root are ignored
|
||||
// (the walk continues). An error accessing the root itself is returned.
|
||||
func walkLocalStorage(dir string) (int64, error) {
|
||||
if dir == "" {
|
||||
return -1, errors.New("empty data directory")
|
||||
}
|
||||
var total int64
|
||||
err := filepath.WalkDir(dir, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
if path == dir {
|
||||
// Root itself is inaccessible — abort the walk.
|
||||
return walkErr
|
||||
}
|
||||
// Ignore per-entry errors (e.g. permission denied on a single file).
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
// Ignore stat errors on individual entries; continue the walk.
|
||||
return nil //nolint:nilerr
|
||||
}
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return -1, errors.Wrap(err, "walk failed")
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWalkLocalStorage_SumsFileSizes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0o600)) // 5
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "b.txt"), []byte("world!"), 0o600)) // 6
|
||||
sub := filepath.Join(dir, "sub")
|
||||
require.NoError(t, os.Mkdir(sub, 0o700))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(sub, "c.txt"), []byte("xx"), 0o600)) // 2
|
||||
|
||||
size, err := walkLocalStorage(dir)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(13), size)
|
||||
}
|
||||
|
||||
func TestWalkLocalStorage_EmptyDir(t *testing.T) {
|
||||
size, err := walkLocalStorage("")
|
||||
require.Error(t, err)
|
||||
require.Equal(t, int64(-1), size)
|
||||
}
|
||||
|
||||
func TestWalkLocalStorage_NonexistentDir(t *testing.T) {
|
||||
size, err := walkLocalStorage(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
require.Error(t, err)
|
||||
require.Equal(t, int64(-1), size)
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||
)
|
||||
|
||||
func TestGetInstanceStats_HappyPath(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
admin, err := ts.CreateHostUser(ctx, "admin1")
|
||||
require.NoError(t, err)
|
||||
adminCtx := ts.CreateUserContext(ctx, admin.ID)
|
||||
|
||||
resp, err := ts.Service.GetInstanceStats(adminCtx, &v1pb.GetInstanceStatsRequest{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
|
||||
require.NotNil(t, resp.Database)
|
||||
require.Equal(t, "sqlite", resp.Database.Driver)
|
||||
require.Greater(t, resp.Database.SizeBytes, int64(0))
|
||||
|
||||
require.GreaterOrEqual(t, resp.LocalStorageBytes, int64(0))
|
||||
}
|
||||
|
||||
func TestGetInstanceStats_NonAdminDenied(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
// Need an admin to exist (otherwise instance is uninitialized).
|
||||
admin, err := ts.CreateHostUser(ctx, "admin1")
|
||||
require.NoError(t, err)
|
||||
_ = admin
|
||||
|
||||
regular, err := ts.CreateRegularUser(ctx, "alice")
|
||||
require.NoError(t, err)
|
||||
regularCtx := ts.CreateUserContext(ctx, regular.ID)
|
||||
|
||||
_, err = ts.Service.GetInstanceStats(regularCtx, &v1pb.GetInstanceStatsRequest{})
|
||||
require.Error(t, err)
|
||||
st, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, codes.PermissionDenied, st.Code())
|
||||
}
|
||||
|
||||
func TestGetInstanceStats_Cache(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ts := NewTestService(t)
|
||||
defer ts.Cleanup()
|
||||
|
||||
admin, err := ts.CreateHostUser(ctx, "admin1")
|
||||
require.NoError(t, err)
|
||||
adminCtx := ts.CreateUserContext(ctx, admin.ID)
|
||||
|
||||
first, err := ts.Service.GetInstanceStats(adminCtx, &v1pb.GetInstanceStatsRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := ts.Service.GetInstanceStats(adminCtx, &v1pb.GetInstanceStatsRequest{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Cache hit: same pointer (the cache returns the stored *InstanceStats directly).
|
||||
require.Same(t, first, second)
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetDatabaseSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
ts := NewTestingStore(ctx, t)
|
||||
defer ts.Close()
|
||||
|
||||
size, err := ts.GetDriver().GetDatabaseSize(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, size, int64(0), "expected database size > 0")
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { RefreshCwIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { instanceKeys, useInstanceStats } from "@/hooks/useInstanceQueries";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import SettingGroup from "./SettingGroup";
|
||||
import { SettingList, SettingListItem, SettingPanel } from "./SettingList";
|
||||
import SettingSection from "./SettingSection";
|
||||
|
||||
const formatBytes = (bytes: number | bigint): string => {
|
||||
const n = typeof bytes === "bigint" ? Number(bytes) : bytes;
|
||||
if (n < 0) return "—";
|
||||
if (n === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.min(units.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
||||
return `${(n / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
};
|
||||
|
||||
const formatRelativeTime = (date: Date): string => {
|
||||
const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000));
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
};
|
||||
|
||||
const renderBytes = (value: bigint | number | undefined, unknown: string): string => {
|
||||
if (value === undefined) return unknown;
|
||||
const n = typeof value === "bigint" ? Number(value) : value;
|
||||
if (n < 0) return unknown;
|
||||
return formatBytes(n);
|
||||
};
|
||||
|
||||
const StatValue = ({ value }: { value: string }) => (
|
||||
<span className="block min-w-0 max-w-full break-all text-right font-mono text-sm tabular-nums text-foreground">{value}</span>
|
||||
);
|
||||
|
||||
const StatRow = ({ label, value }: { label: string; value: string }) => (
|
||||
<SettingListItem label={label} controlClassName="w-full justify-end sm:w-auto">
|
||||
<StatValue value={value} />
|
||||
</SettingListItem>
|
||||
);
|
||||
|
||||
const ResourceStatsSection = () => {
|
||||
const t = useTranslate();
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, isError, isFetching } = useInstanceStats();
|
||||
|
||||
const unknown = t("setting.resource-stats.unknown");
|
||||
const generatedTime = data?.generatedTime
|
||||
? t("setting.resource-stats.last-updated", {
|
||||
ago: formatRelativeTime(new Date(Number(data.generatedTime.seconds) * 1000)),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<SettingSection
|
||||
title={t("setting.resource-stats.title")}
|
||||
description={t("setting.resource-stats.description")}
|
||||
actions={
|
||||
<>
|
||||
{generatedTime ? <span className="text-xs text-muted-foreground">{generatedTime}</span> : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isFetching}
|
||||
onClick={() => void queryClient.invalidateQueries({ queryKey: instanceKeys.stats() })}
|
||||
>
|
||||
<RefreshCwIcon className="mr-1 size-4" />
|
||||
{t("setting.resource-stats.refresh")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isError ? <div className="text-destructive text-sm">{t("setting.resource-stats.load-error")}</div> : null}
|
||||
|
||||
{isLoading && !data ? (
|
||||
<SettingPanel>
|
||||
<div className="px-3 py-3 text-sm text-muted-foreground">…</div>
|
||||
</SettingPanel>
|
||||
) : null}
|
||||
|
||||
{data ? (
|
||||
<>
|
||||
<SettingGroup title={t("setting.resource-stats.database.title")}>
|
||||
<SettingList>
|
||||
<StatRow label={t("setting.resource-stats.database.driver")} value={data.database?.driver || unknown} />
|
||||
<StatRow label={t("setting.resource-stats.database.size")} value={renderBytes(data.database?.sizeBytes, unknown)} />
|
||||
</SettingList>
|
||||
</SettingGroup>
|
||||
|
||||
<SettingGroup title={t("setting.resource-stats.local-storage.title")} showSeparator>
|
||||
<SettingList>
|
||||
<StatRow label={t("setting.resource-stats.local-storage.size")} value={renderBytes(data.localStorageBytes, unknown)} />
|
||||
</SettingList>
|
||||
</SettingGroup>
|
||||
</>
|
||||
) : null}
|
||||
</SettingSection>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResourceStatsSection;
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue