feat(admin): reference-count filters for blob listing (#3063)

- entity_ref_count condition: "stale" selects blobs awaiting recycle
  (reference_count <= 0); gt:N / lt:N / eq:N for explicit comparisons
- Entity filter popover gains a reference-count section with operator
  select and value input (en-US, zh-CN)

Authored By: TDvorak <info@tdvorak.dev>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent b810beeff5
commit 7433c770db

@ -1406,6 +1406,11 @@
"shareLink": "Share links", "shareLink": "Share links",
"shareLinkNum": "{{num}} (<0>View</0>)", "shareLinkNum": "{{num}} (<0>View</0>)",
"blobType": "Type", "blobType": "Type",
"referenceCount": "Reference count",
"awaitingRecycle": "Awaiting recycle",
"refCountGt": "Greater than",
"refCountLt": "Less than",
"refCountEq": "Equal to",
"noEntities": "No Blobs", "noEntities": "No Blobs",
"blobs": "Blobs", "blobs": "Blobs",
"creator": "Creator", "creator": "Creator",

@ -1406,6 +1406,11 @@
"shareLink": "分享链接", "shareLink": "分享链接",
"shareLinkNum": "{{num}} 个 (<0>查看</0>)", "shareLinkNum": "{{num}} 个 (<0>查看</0>)",
"blobType": "类型", "blobType": "类型",
"referenceCount": "引用次数",
"awaitingRecycle": "等待回收",
"refCountGt": "大于",
"refCountLt": "小于",
"refCountEq": "等于",
"noEntities": "没有 Blob", "noEntities": "没有 Blob",
"blobs": "Blobs", "blobs": "Blobs",
"creator": "创建者", "creator": "创建者",

@ -14,6 +14,8 @@ export interface EntityFilterPopoverProps extends PopoverProps {
setOwner: (owner: string) => void; setOwner: (owner: string) => void;
type?: EntityType; type?: EntityType;
setType: (type?: EntityType) => void; setType: (type?: EntityType) => void;
refCount: string;
setRefCount: (refCount: string) => void;
clearFilters: () => void; clearFilters: () => void;
} }
@ -24,6 +26,8 @@ const EntityFilterPopover = ({
setOwner, setOwner,
type, type,
setType, setType,
refCount,
setRefCount,
clearFilters, clearFilters,
onClose, onClose,
open, open,
@ -35,6 +39,8 @@ const EntityFilterPopover = ({
const [localStoragePolicy, setLocalStoragePolicy] = useState(storagePolicy); const [localStoragePolicy, setLocalStoragePolicy] = useState(storagePolicy);
const [localOwner, setLocalOwner] = useState(owner); const [localOwner, setLocalOwner] = useState(owner);
const [localType, setLocalType] = useState(type); const [localType, setLocalType] = useState(type);
const [localRefCountOp, setLocalRefCountOp] = useState("");
const [localRefCountValue, setLocalRefCountValue] = useState("");
// Initialize local state when popup opens // Initialize local state when popup opens
useEffect(() => { useEffect(() => {
@ -42,6 +48,9 @@ const EntityFilterPopover = ({
setLocalStoragePolicy(storagePolicy); setLocalStoragePolicy(storagePolicy);
setLocalOwner(owner); setLocalOwner(owner);
setLocalType(type); setLocalType(type);
const [op, value] = refCount.split(":");
setLocalRefCountOp(refCount ? op : "");
setLocalRefCountValue(value ?? "");
} }
}, [open]); }, [open]);
@ -50,6 +59,9 @@ const EntityFilterPopover = ({
setStoragePolicy(localStoragePolicy); setStoragePolicy(localStoragePolicy);
setOwner(localOwner); setOwner(localOwner);
setType(localType); setType(localType);
setRefCount(
localRefCountOp == "" ? "" : localRefCountOp == "stale" ? "stale" : `${localRefCountOp}:${localRefCountValue || "0"}`,
);
onClose?.({}, "backdropClick"); onClose?.({}, "backdropClick");
}; };
@ -58,6 +70,8 @@ const EntityFilterPopover = ({
setLocalStoragePolicy(""); setLocalStoragePolicy("");
setLocalOwner(""); setLocalOwner("");
setLocalType(undefined); setLocalType(undefined);
setLocalRefCountOp("");
setLocalRefCountValue("");
clearFilters(); clearFilters();
onClose?.({}, "backdropClick"); onClose?.({}, "backdropClick");
}; };
@ -128,6 +142,43 @@ const EntityFilterPopover = ({
</DenseSelect> </DenseSelect>
</SettingForm> </SettingForm>
<SettingForm title={t("file.referenceCount")} noContainer lgWidth={12}>
<Stack direction="row" spacing={1}>
<DenseSelect
displayEmpty
value={localRefCountOp}
onChange={(e) => setLocalRefCountOp(e.target.value)}
sx={{ minWidth: 140 }}
>
<SquareMenuItem value="">
<ListItemText primary={<em>{t("user.all")}</em>} slotProps={{ primary: { variant: "body2" } }} />
</SquareMenuItem>
<SquareMenuItem value="stale">
<ListItemText primary={t("file.awaitingRecycle")} slotProps={{ primary: { variant: "body2" } }} />
</SquareMenuItem>
<SquareMenuItem value="gt">
<ListItemText primary={t("file.refCountGt")} slotProps={{ primary: { variant: "body2" } }} />
</SquareMenuItem>
<SquareMenuItem value="lt">
<ListItemText primary={t("file.refCountLt")} slotProps={{ primary: { variant: "body2" } }} />
</SquareMenuItem>
<SquareMenuItem value="eq">
<ListItemText primary={t("file.refCountEq")} slotProps={{ primary: { variant: "body2" } }} />
</SquareMenuItem>
</DenseSelect>
{localRefCountOp != "" && localRefCountOp != "stale" && (
<DenseFilledTextField
value={localRefCountValue}
onChange={(e) => setLocalRefCountValue(e.target.value.replace(/\D/g, ""))}
placeholder="0"
size="small"
inputProps={{ inputMode: "numeric" }}
sx={{ width: 80 }}
/>
)}
</Stack>
</SettingForm>
<SettingForm title={t("file.storagePolicy")} noContainer lgWidth={12}> <SettingForm title={t("file.storagePolicy")} noContainer lgWidth={12}>
<SinglePolicySelectionInput <SinglePolicySelectionInput
value={localStoragePolicy == "" ? -1 : parseInt(localStoragePolicy)} value={localStoragePolicy == "" ? -1 : parseInt(localStoragePolicy)}

@ -39,6 +39,7 @@ import EntityRow from "./EntityRow";
export const StoragePolicyQuery = "storage_policy"; export const StoragePolicyQuery = "storage_policy";
export const UserQuery = "user"; export const UserQuery = "user";
export const TypeQuery = "type"; export const TypeQuery = "type";
export const RefCountQuery = "ref_count";
const EntitySetting = () => { const EntitySetting = () => {
const { t } = useTranslation("dashboard"); const { t } = useTranslation("dashboard");
@ -58,6 +59,7 @@ const EntitySetting = () => {
const [storagePolicy, setStoragePolicy] = useQueryState(StoragePolicyQuery, { defaultValue: "" }); const [storagePolicy, setStoragePolicy] = useQueryState(StoragePolicyQuery, { defaultValue: "" });
const [user, setUser] = useQueryState(UserQuery, { defaultValue: "" }); const [user, setUser] = useQueryState(UserQuery, { defaultValue: "" });
const [type, setType] = useQueryState(TypeQuery, { defaultValue: "" }); const [type, setType] = useQueryState(TypeQuery, { defaultValue: "" });
const [refCount, setRefCount] = useQueryState(RefCountQuery, { defaultValue: "" });
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
const [selected, setSelected] = useState<readonly number[]>([]); const [selected, setSelected] = useState<readonly number[]>([]);
const filterPopupState = usePopupState({ const filterPopupState = usePopupState({
@ -79,11 +81,12 @@ const EntitySetting = () => {
setStoragePolicy(""); setStoragePolicy("");
setUser(""); setUser("");
setType(""); setType("");
}, [setStoragePolicy, setUser, setType]); setRefCount("");
}, [setStoragePolicy, setUser, setType, setRefCount]);
useEffect(() => { useEffect(() => {
fetchEntities(); fetchEntities();
}, [page, pageSize, orderBy, orderDirection, storagePolicy, user, type]); }, [page, pageSize, orderBy, orderDirection, storagePolicy, user, type, refCount]);
const fetchEntities = () => { const fetchEntities = () => {
setLoading(true); setLoading(true);
@ -109,6 +112,10 @@ const EntitySetting = () => {
params.conditions!.entity_type = type; params.conditions!.entity_type = type;
} }
if (refCount) {
params.conditions!.entity_ref_count = refCount;
}
dispatch(getEntityList(params)) dispatch(getEntityList(params))
.then((res) => { .then((res) => {
setEntities(res.entities); setEntities(res.entities);
@ -202,6 +209,8 @@ const EntitySetting = () => {
setOwner={setUser} setOwner={setUser}
type={type !== "" ? parseInt(type) : undefined} type={type !== "" ? parseInt(type) : undefined}
setType={(type) => setType(type !== undefined ? type.toString() : "")} setType={(type) => setType(type !== undefined ? type.toString() : "")}
refCount={refCount}
setRefCount={setRefCount}
clearFilters={clearFilters} clearFilters={clearFilters}
/> />

@ -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)
}

@ -3,6 +3,8 @@ package inventory
import ( import (
"context" "context"
"fmt" "fmt"
"strconv"
"strings"
"time" "time"
"entgo.io/ent/dialect/sql" "entgo.io/ent/dialect/sql"
@ -90,6 +92,16 @@ type (
EntityType *types.EntityType EntityType *types.EntityType
UserID int UserID int
StoragePolicyID 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 { ListEntityResult struct {
@ -1213,6 +1225,25 @@ func (f *fileClient) FlattenListFiles(ctx context.Context, args *FlattenListFile
}, nil }, 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) { func (f *fileClient) ListEntities(ctx context.Context, args *ListEntityParameters) (*ListEntityResult, error) {
query := f.client.Entity.Query() query := f.client.Entity.Query()
if args.EntityType != nil { if args.EntityType != nil {
@ -1227,6 +1258,19 @@ func (f *fileClient) ListEntities(ctx context.Context, args *ListEntityParameter
query = query.Where(entity.StoragePolicyEntities(args.StoragePolicyID)) 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)...) query.Order(getEntityOrderOption(args)...)
// Count total items // Count total items

@ -398,6 +398,9 @@ const (
entityUserCondition = "entity_user" entityUserCondition = "entity_user"
entityPolicyCondition = "entity_policy" entityPolicyCondition = "entity_policy"
entityTypeCondition = "entity_type" 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) { func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error) {
@ -412,6 +415,7 @@ func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error)
policyID int policyID int
err error err error
entityType *types.EntityType entityType *types.EntityType
refCount *inventory.ReferenceCountFilter
) )
if s.Conditions[entityUserCondition] != "" { if s.Conditions[entityUserCondition] != "" {
@ -438,6 +442,13 @@ func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error)
entityType = &t 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{ res, err := fileClient.ListEntities(ctx, &inventory.ListEntityParameters{
PaginationArgs: &inventory.PaginationArgs{ PaginationArgs: &inventory.PaginationArgs{
Page: s.Page - 1, Page: s.Page - 1,
@ -448,6 +459,7 @@ func (s *AdminListService) Entities(c *gin.Context) (*ListEntityResponse, error)
UserID: userID, UserID: userID,
StoragePolicyID: policyID, StoragePolicyID: policyID,
EntityType: entityType, EntityType: entityType,
ReferenceCount: refCount,
}) })
if err != nil { if err != nil {

Loading…
Cancel
Save