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>View0>)",
"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>查看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 {