diff --git a/frontend/public/locales/en-US/dashboard.json b/frontend/public/locales/en-US/dashboard.json index f5ec706b..420976dc 100644 --- a/frontend/public/locales/en-US/dashboard.json +++ b/frontend/public/locales/en-US/dashboard.json @@ -1406,6 +1406,11 @@ "shareLink": "Share links", "shareLinkNum": "{{num}} (<0>View)", "blobType": "Type", + "referenceCount": "Reference count", + "awaitingRecycle": "Awaiting recycle", + "refCountGt": "Greater than", + "refCountLt": "Less than", + "refCountEq": "Equal to", "noEntities": "No Blobs", "blobs": "Blobs", "creator": "Creator", diff --git a/frontend/public/locales/zh-CN/dashboard.json b/frontend/public/locales/zh-CN/dashboard.json index 8dd50cb6..5f5cda26 100644 --- a/frontend/public/locales/zh-CN/dashboard.json +++ b/frontend/public/locales/zh-CN/dashboard.json @@ -1406,6 +1406,11 @@ "shareLink": "分享链接", "shareLinkNum": "{{num}} 个 (<0>查看)", "blobType": "类型", + "referenceCount": "引用次数", + "awaitingRecycle": "等待回收", + "refCountGt": "大于", + "refCountLt": "小于", + "refCountEq": "等于", "noEntities": "没有 Blob", "blobs": "Blobs", "creator": "创建者", diff --git a/frontend/src/component/Admin/Entity/EntityFilterPopover.tsx b/frontend/src/component/Admin/Entity/EntityFilterPopover.tsx index cfadd437..0f9891ca 100644 --- a/frontend/src/component/Admin/Entity/EntityFilterPopover.tsx +++ b/frontend/src/component/Admin/Entity/EntityFilterPopover.tsx @@ -14,6 +14,8 @@ export interface EntityFilterPopoverProps extends PopoverProps { setOwner: (owner: string) => void; type?: EntityType; setType: (type?: EntityType) => void; + refCount: string; + setRefCount: (refCount: string) => void; clearFilters: () => void; } @@ -24,6 +26,8 @@ const EntityFilterPopover = ({ setOwner, type, setType, + refCount, + setRefCount, clearFilters, onClose, open, @@ -35,6 +39,8 @@ const EntityFilterPopover = ({ const [localStoragePolicy, setLocalStoragePolicy] = useState(storagePolicy); const [localOwner, setLocalOwner] = useState(owner); const [localType, setLocalType] = useState(type); + const [localRefCountOp, setLocalRefCountOp] = useState(""); + const [localRefCountValue, setLocalRefCountValue] = useState(""); // Initialize local state when popup opens useEffect(() => { @@ -42,6 +48,9 @@ const EntityFilterPopover = ({ setLocalStoragePolicy(storagePolicy); setLocalOwner(owner); setLocalType(type); + const [op, value] = refCount.split(":"); + setLocalRefCountOp(refCount ? op : ""); + setLocalRefCountValue(value ?? ""); } }, [open]); @@ -50,6 +59,9 @@ const EntityFilterPopover = ({ setStoragePolicy(localStoragePolicy); setOwner(localOwner); setType(localType); + setRefCount( + localRefCountOp == "" ? "" : localRefCountOp == "stale" ? "stale" : `${localRefCountOp}:${localRefCountValue || "0"}`, + ); onClose?.({}, "backdropClick"); }; @@ -58,6 +70,8 @@ const EntityFilterPopover = ({ setLocalStoragePolicy(""); setLocalOwner(""); setLocalType(undefined); + setLocalRefCountOp(""); + setLocalRefCountValue(""); clearFilters(); onClose?.({}, "backdropClick"); }; @@ -128,6 +142,43 @@ const EntityFilterPopover = ({ + + + setLocalRefCountOp(e.target.value)} + sx={{ minWidth: 140 }} + > + + {t("user.all")}} slotProps={{ primary: { variant: "body2" } }} /> + + + + + + + + + + + + + + + {localRefCountOp != "" && localRefCountOp != "stale" && ( + setLocalRefCountValue(e.target.value.replace(/\D/g, ""))} + placeholder="0" + size="small" + inputProps={{ inputMode: "numeric" }} + sx={{ width: 80 }} + /> + )} + + + { const { t } = useTranslation("dashboard"); @@ -58,6 +59,7 @@ const EntitySetting = () => { const [storagePolicy, setStoragePolicy] = useQueryState(StoragePolicyQuery, { defaultValue: "" }); const [user, setUser] = useQueryState(UserQuery, { defaultValue: "" }); const [type, setType] = useQueryState(TypeQuery, { defaultValue: "" }); + const [refCount, setRefCount] = useQueryState(RefCountQuery, { defaultValue: "" }); const [count, setCount] = useState(0); const [selected, setSelected] = useState([]); const filterPopupState = usePopupState({ @@ -79,11 +81,12 @@ const EntitySetting = () => { setStoragePolicy(""); setUser(""); setType(""); - }, [setStoragePolicy, setUser, setType]); + setRefCount(""); + }, [setStoragePolicy, setUser, setType, setRefCount]); useEffect(() => { fetchEntities(); - }, [page, pageSize, orderBy, orderDirection, storagePolicy, user, type]); + }, [page, pageSize, orderBy, orderDirection, storagePolicy, user, type, refCount]); const fetchEntities = () => { setLoading(true); @@ -109,6 +112,10 @@ const EntitySetting = () => { params.conditions!.entity_type = type; } + if (refCount) { + params.conditions!.entity_ref_count = refCount; + } + dispatch(getEntityList(params)) .then((res) => { setEntities(res.entities); @@ -202,6 +209,8 @@ const EntitySetting = () => { setOwner={setUser} type={type !== "" ? parseInt(type) : undefined} setType={(type) => setType(type !== undefined ? type.toString() : "")} + refCount={refCount} + setRefCount={setRefCount} clearFilters={clearFilters} /> diff --git a/inventory/entity_refcount_test.go b/inventory/entity_refcount_test.go new file mode 100644 index 00000000..71923dcb --- /dev/null +++ b/inventory/entity_refcount_test.go @@ -0,0 +1,79 @@ +package inventory + +import ( + "context" + "testing" + + "github.com/cloudreve/Cloudreve/v4/ent/enttest" + "github.com/cloudreve/Cloudreve/v4/ent/storagepolicy" + "github.com/cloudreve/Cloudreve/v4/pkg/conf" + "github.com/stretchr/testify/require" +) + +func TestParseReferenceCountFilter(t *testing.T) { + f, err := ParseReferenceCountFilter("stale") + require.NoError(t, err) + require.True(t, f.Stale) + + f, err = ParseReferenceCountFilter("gt:2") + require.NoError(t, err) + require.Equal(t, "gt", f.Op) + require.Equal(t, 2, f.Value) + + f, err = ParseReferenceCountFilter("eq:0") + require.NoError(t, err) + require.Equal(t, "eq", f.Op) + + for _, bad := range []string{"", "gt", "xx:1", "gt:abc", "1"} { + _, err := ParseReferenceCountFilter(bad) + require.Error(t, err, "expr %q should be rejected", bad) + } +} + +func TestListEntitiesReferenceCountFilter(t *testing.T) { + client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared") + t.Cleanup(func() { require.NoError(t, client.Close()) }) + ctx := context.Background() + + p := client.StoragePolicy.Create().SetName("p").SetType("local"). + SetStatus(storagepolicy.StatusActive).SaveX(ctx) + mk := func(name string, ref int) { + client.Entity.Create().SetType(0).SetSource(name).SetSize(1). + SetReferenceCount(ref).SetStoragePolicyEntities(p.ID).SaveX(ctx) + } + mk("a", 0) + mk("b", -1) + mk("c", 1) + mk("d", 3) + + fc := NewFileClient(client, conf.SQLite3DB, nil) + args := &ListEntityParameters{PaginationArgs: &PaginationArgs{Page: 0, PageSize: 50}} + + res, err := fc.ListEntities(ctx, args) + require.NoError(t, err) + require.Equal(t, 4, res.TotalItems) + + stale, _ := ParseReferenceCountFilter("stale") + res, err = fc.ListEntities(ctx, &ListEntityParameters{ + PaginationArgs: args.PaginationArgs, ReferenceCount: stale}) + require.NoError(t, err) + require.Equal(t, 2, res.TotalItems) + + gt, _ := ParseReferenceCountFilter("gt:1") + res, err = fc.ListEntities(ctx, &ListEntityParameters{ + PaginationArgs: args.PaginationArgs, ReferenceCount: gt}) + require.NoError(t, err) + require.Equal(t, 1, res.TotalItems) + + lt, _ := ParseReferenceCountFilter("lt:1") + res, err = fc.ListEntities(ctx, &ListEntityParameters{ + PaginationArgs: args.PaginationArgs, ReferenceCount: lt}) + require.NoError(t, err) + require.Equal(t, 2, res.TotalItems) + + eq, _ := ParseReferenceCountFilter("eq:1") + res, err = fc.ListEntities(ctx, &ListEntityParameters{ + PaginationArgs: args.PaginationArgs, ReferenceCount: eq}) + require.NoError(t, err) + require.Equal(t, 1, res.TotalItems) +} diff --git a/inventory/file.go b/inventory/file.go index cc019cb9..de559b40 100644 --- a/inventory/file.go +++ b/inventory/file.go @@ -3,6 +3,8 @@ package inventory import ( "context" "fmt" + "strconv" + "strings" "time" "entgo.io/ent/dialect/sql" @@ -90,6 +92,16 @@ type ( EntityType *types.EntityType UserID int StoragePolicyID int + ReferenceCount *ReferenceCountFilter + } + + // ReferenceCountFilter compares entity reference_count against a value. + // Stale selects entities awaiting recycle (reference_count <= 0) and + // ignores Value. + ReferenceCountFilter struct { + Op string + Value int + Stale bool } ListEntityResult struct { @@ -1213,6 +1225,25 @@ func (f *fileClient) FlattenListFiles(ctx context.Context, args *FlattenListFile }, nil } +// ParseReferenceCountFilter parses a reference-count filter expression: +// "stale" for entities awaiting recycle, or "gt:N" / "lt:N" / "eq:N". +func ParseReferenceCountFilter(expr string) (*ReferenceCountFilter, error) { + if expr == "stale" { + return &ReferenceCountFilter{Stale: true}, nil + } + + op, valStr, ok := strings.Cut(expr, ":") + if !ok || (op != "gt" && op != "lt" && op != "eq") { + return nil, fmt.Errorf("invalid reference count filter %q", expr) + } + + v, err := strconv.Atoi(valStr) + if err != nil { + return nil, fmt.Errorf("invalid reference count filter %q: %w", expr, err) + } + return &ReferenceCountFilter{Op: op, Value: v}, nil +} + func (f *fileClient) ListEntities(ctx context.Context, args *ListEntityParameters) (*ListEntityResult, error) { query := f.client.Entity.Query() if args.EntityType != nil { @@ -1227,6 +1258,19 @@ func (f *fileClient) ListEntities(ctx context.Context, args *ListEntityParameter query = query.Where(entity.StoragePolicyEntities(args.StoragePolicyID)) } + if args.ReferenceCount != nil { + switch { + case args.ReferenceCount.Stale: + query = query.Where(entity.ReferenceCountLTE(0)) + case args.ReferenceCount.Op == "gt": + query = query.Where(entity.ReferenceCountGT(args.ReferenceCount.Value)) + case args.ReferenceCount.Op == "lt": + query = query.Where(entity.ReferenceCountLT(args.ReferenceCount.Value)) + case args.ReferenceCount.Op == "eq": + query = query.Where(entity.ReferenceCountEQ(args.ReferenceCount.Value)) + } + } + query.Order(getEntityOrderOption(args)...) // Count total items diff --git a/service/admin/file.go b/service/admin/file.go index e7c690f0..ffb26bcf 100644 --- a/service/admin/file.go +++ b/service/admin/file.go @@ -398,6 +398,9 @@ const ( entityUserCondition = "entity_user" entityPolicyCondition = "entity_policy" entityTypeCondition = "entity_type" + // entityRefCountCondition filters by reference count: "stale" (<=0, i.e. + // awaiting recycle) or "gt:N" / "lt:N" / "eq:N" comparisons. + entityRefCountCondition = "entity_ref_count" ) func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error) { @@ -412,6 +415,7 @@ func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error) policyID int err error entityType *types.EntityType + refCount *inventory.ReferenceCountFilter ) if s.Conditions[entityUserCondition] != "" { @@ -438,6 +442,13 @@ func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error) entityType = &t } + if s.Conditions[entityRefCountCondition] != "" { + refCount, err = inventory.ParseReferenceCountFilter(s.Conditions[entityRefCountCondition]) + if err != nil { + return nil, serializer.NewError(serializer.CodeParamErr, "Invalid reference count filter", err) + } + } + res, err := fileClient.ListEntities(ctx, &inventory.ListEntityParameters{ PaginationArgs: &inventory.PaginationArgs{ Page: s.Page - 1, @@ -448,6 +459,7 @@ func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error) UserID: userID, StoragePolicyID: policyID, EntityType: entityType, + ReferenceCount: refCount, }) if err != nil {