Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- **存储策略与策略组生命周期** — 首次 setup 创建的存储策略和默认策略组不再按固定 ID 作为永久系统对象;解除 blob、上传 session、策略组项以及用户/团队绑定等引用后可删除首条或最后一条默认策略,删除最后一个默认策略组会使系统回到 `needs_storage`,重新配置默认存储拓扑后恢复 `ready`,不会静默清空业务绑定。默认切换、删除与重新 setup 使用稳定数据库锁协调多 Primary,现有数据保护保持不变。

## [v0.5.1] - 2026-08-20

### Changed
Expand Down
4 changes: 2 additions & 2 deletions developer-docs/en/api/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Current notes:
- `POST /admin/policies/{id}/promote-s3-driver` currently supports promoting a generic `s3` policy to `tencent_cos`. The body must include the target driver and current endpoint / bucket, for example `{ "target_driver_type": "tencent_cos", "endpoint": "https://bucket-1250000000.cos.ap-guangzhou.myqcloud.com", "bucket": "bucket-1250000000" }`. Promotion is rejected unless the bucket stays unchanged, there are no active upload sessions for the policy, and the target driver validates the endpoint / bucket combination.
- `GET /admin/policies` supports `limit`, `offset`, `sort_by`, `sort_order`
- `GET /admin/policies/{id}/capacity` returns `StoragePolicyCapacityInfo`; local returns filesystem capacity, S3-compatible and Azure Blob are explicitly unsupported, OneDrive reads Microsoft Graph drive quota, and remote forwards follower capacity status
- `DELETE /admin/policies/{id}?force=true` only cleans upload sessions that still reference the policy. Existing blobs or policy-group references still block deletion. If temp objects or multipart uploads need delayed cleanup, a `storage_policy_temp_cleanup` task is created.
- `DELETE /admin/policies/{id}?force=true` only cleans upload sessions that still reference the policy. Existing blobs or policy-group references still block deletion. Deleting the last default policy returns the system to `needs_storage`; if temp objects or multipart uploads need delayed cleanup, a `storage_policy_temp_cleanup` task is created.

### Storage connection tests

Expand Down Expand Up @@ -500,7 +500,7 @@ The admin folder-policy request is `{ "policy_id": 12 }`; `{ "policy_id": null }
| `DELETE` | `/admin/policy-groups/{id}` | Delete policy group |
| `POST` | `/admin/policy-groups/{id}/migrate-assignments` | Migrate user and team policy group bindings by updating `policy_group_id` |

Policy groups define storage policy selection for users and teams. They are rejected from deletion while still referenced. Migration responses report `affected_users`, `affected_teams`, and `migrated_assignments`.
Policy groups define storage policy selection for users and teams. A group configuration still requires at least one policy item. A policy group with user or team bindings must be migrated before deletion, including the last default group; once deletion succeeds, deleting the last default group returns the system to `needs_storage` without clearing bindings. Other referenced groups remain protected. Migration responses report `affected_users`, `affected_teams`, and `migrated_assignments`.

## Users

Expand Down
4 changes: 2 additions & 2 deletions developer-docs/zh-CN/api/admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@
- S3-compatible 和 Azure Blob 驱动明确返回 `StorageErrorKind::Unsupported`,服务层转换成 `unsupported` 状态,不伪造 bucket / account 容量
- OneDrive 驱动通过 Microsoft Graph drive quota 返回容量信息
- Remote 驱动通过 follower 内部协议 `/internal/storage/capacity` 转发当前远端存储目标的容量能力
- `DELETE /admin/policies/{id}` 支持 `?force=true`;这只会强制清理仍引用该策略的上传 session,仍有 blob 或策略组项引用时照样拒绝删除。若清理后还有临时对象或 multipart upload 需要延后处理,会创建 `storage_policy_temp_cleanup` 后台任务
- `DELETE /admin/policies/{id}` 支持 `?force=true`;这只会强制清理仍引用该策略的上传 session,仍有 blob 或策略组项引用时照样拒绝删除。删除最后一个默认策略后系统会回到 `needs_storage`;若清理后还有临时对象或 multipart upload 需要延后处理,会创建 `storage_policy_temp_cleanup` 后台任务

### 存储连接测试

Expand Down Expand Up @@ -538,7 +538,7 @@ POST /api/v1/admin/policies/action

当前实现注意点:

- 策略组至少要包含一个策略项
- 策略组配置仍需至少包含一个策略项;删除最后一个默认策略组会使系统回到 `needs_storage`
- 同一组里 `policy_id` 和 `priority` 都不能重复
- `is_default = true` 的组必须保持启用
- 已被用户或团队绑定的策略组不能直接删掉;被绑定时也不能随便禁用
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/admin/storage-policies.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,8 @@ AsterDrive 会缓存缩略图和媒体信息等派生结果,避免每次查看

## 日常维护

- 至少保留一条可用的默认存储策略
- 至少保留一个启用中的默认策略组
- 删除最后一个默认策略或策略组后,系统会回到 `needs_storage`;重新创建并配置默认对象后才能恢复上传
- 已有文件仍按原策略读取;需要搬迁数据时使用存储迁移流程,不要直接改动已有策略的落点
- 保存前先做一次连接测试
- 给不同用户/团队分配不同存储路线时,到 `管理 -> 用户` 或 `管理 -> 团队` 里绑策略组
- 接入外部后端时优先看 [存储后端](/admin/storage-backends/) 里的具体教程
4 changes: 2 additions & 2 deletions docs/src/content/docs/en/admin/storage-policies.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,8 @@ Migration moves file objects and reference relationships known to AsterDrive; it

## Routine Maintenance

- Keep at least one usable default storage policy
- Keep at least one enabled default policy group
- Deleting the last default policy or policy group returns the system to `needs_storage`; create and configure a new default before uploads resume
- Existing files remain bound to their original policy; use the storage migration workflow when moving data instead of changing an existing policy's location
- Run a connection test before saving
- To assign different storage routes to different users/teams, bind policy groups under `Admin -> Users` or `Admin -> Teams`
- When connecting an external backend, start with the matching tutorial under [Storage Backends](/en/admin/storage-backends/)
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ describe("PolicyGroupsTable", () => {
expect(mockState.onOpenEdit).toHaveBeenNthCalledWith(2, defaultGroup);
expect(mockState.onOpenMigration).toHaveBeenCalledWith(primaryGroup);
expect(mockState.onRequestDelete).toHaveBeenCalledWith(1);
expect(deleteButtons[1]).toBeDisabled();
expect(deleteButtons[1]).toBeEnabled();
});

it("keeps disabled action tooltips on fixed-size triggers", () => {
Expand All @@ -378,7 +378,7 @@ describe("PolicyGroupsTable", () => {
});

expect(migrationButton).toBeDisabled();
expect(deleteButton).toBeDisabled();
expect(deleteButton).toBeEnabled();
expect(migrationButton.parentElement).toHaveClass(
"inline-flex",
"size-8",
Expand All @@ -393,8 +393,8 @@ describe("PolicyGroupsTable", () => {
screen.getByText("policy_group_migration_unavailable"),
).toBeInTheDocument();
expect(
screen.getByText("policy_group_delete_default_blocked"),
).toBeInTheDocument();
screen.queryByText("policy_group_delete_default_blocked"),
).toBeNull();
});

it("updates pagination state through the footer controls", () => {
Expand Down
7 changes: 1 addition & 6 deletions frontend-panel/src/components/admin/PolicyGroupsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,19 +275,14 @@ function PolicyGroupActions({
onClick={() => onRequestDelete(group.id)}
aria-label={deleteLabel}
title={deleteLabel}
disabled={group.is_default || isDeleting}
disabled={isDeleting}
>
<Icon
name={isDeleting ? "Spinner" : "Trash"}
className={`size-3.5 ${isDeleting ? "animate-spin" : ""}`}
/>
</Button>
</TooltipTrigger>
{group.is_default ? (
<TooltipContent>
{t("policy_group_delete_default_blocked")}
</TooltipContent>
) : null}
</Tooltip>
</div>
</TooltipProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ describe("PoliciesTable", () => {
expect(screen.getByText(/mode: mode/)).toBeVisible();
});

it("supports row keyboard/edit/delete behavior and protected/deleting states", () => {
it("supports row keyboard/edit/delete behavior and deleting states", () => {
const { props } = renderTable({ deletingPolicyId: 2 });
const rows = screen.getAllByRole("row");
fireEvent.click(rows[1]);
Expand All @@ -229,7 +229,7 @@ describe("PoliciesTable", () => {
const deleteButtons = buttons.filter((button) =>
button.getAttribute("aria-label"),
);
expect(deleteButtons[0]).toBeDisabled();
expect(deleteButtons[0]).toBeEnabled();
expect(deleteButtons[1]).toBeDisabled();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,7 @@ import type { AdminPolicySortBy } from "@/types/adminSort";
import type { StorageConnectorDescriptor, StoragePolicy } from "@/types/api";
import { policyConnectorSelection } from "../storage-policy-dialog/connectionNormalization";
import type { ConnectorFormValue } from "../storage-policy-dialog/formTypes";
import {
getStorageConnectorBadgePresentation,
PROTECTED_POLICY_ID,
} from "./policyPresentation";
import { getStorageConnectorBadgePresentation } from "./policyPresentation";

interface PoliciesTableProps {
deletingPolicyId: number | null;
Expand Down Expand Up @@ -216,12 +213,8 @@ export function PoliciesTable({
className={`${ADMIN_ICON_BUTTON_CLASS} text-destructive`}
onClick={() => onDeletePolicy(policy.id)}
aria-label={deleteLabel}
title={
policy.id === PROTECTED_POLICY_ID
? t("initial_policy_delete_blocked")
: deleteLabel
}
disabled={policy.id === PROTECTED_POLICY_ID || isDeleting}
title={deleteLabel}
disabled={isDeleting}
>
<Icon
name={isDeleting ? "Spinner" : "Trash"}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import { describe, expect, it } from "vitest";
import {
getStorageConnectorBadgePresentation,
PROTECTED_POLICY_ID,
} from "./policyPresentation";
import { getStorageConnectorBadgePresentation } from "./policyPresentation";

describe("policyPresentation", () => {
it("renders a connector-owned RGB color without connector id branches", () => {
expect(PROTECTED_POLICY_ID).toBe(1);
const presentation = getStorageConnectorBadgePresentation({
red: 16,
green: 185,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import type { CSSProperties } from "react";
import type { StorageConnectorBadgeRgb } from "@/types/api";

export const PROTECTED_POLICY_ID = 1;

const DEFAULT_BADGE_RGB: StorageConnectorBadgeRgb = {
red: 113,
green: 113,
Expand Down
7 changes: 3 additions & 4 deletions frontend-panel/src/i18n/locales/en/admin/policies.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"unset_default": "Unset default",
"undo_changes": "Discard Changes",
"save_changes": "Save Changes",
"delete_policy_desc": "This will not delete stored files, but new uploads using this policy will fail.",
"delete_policy_desc": "Stored files remain untouched. If this is the last default policy, storage setup returns to incomplete and new uploads pause until a new default is configured.",
"force_delete_policy": "Force Delete Policy",
"force_delete_policy_desc": "This policy is still referenced by unfinished uploads. Continuing will clean up those upload sessions and temporary objects; deletion will still be rejected if stored file blobs use this policy.",
"force_delete_policy_confirm": "Clean Up and Delete",
Expand Down Expand Up @@ -128,11 +128,10 @@
"policy_assigned": "Policy assigned",
"assignment_removed": "Assignment removed",
"storage_policy_assignments_desc": "Choose the default policy group for this user's uploads. Only one group can be bound at a time.",
"initial_policy_delete_blocked": "The built-in policy with ID 1 cannot be deleted.",
"assignment_updated": "Assignment updated",
"default_selected": "Default",
"default_policy_required": "A default policy must always exist while assignments remain.",
"default_policy_remove_blocked": "Set another policy as default before removing the current default policy.",
"default_policy_required": "Create and assign a default policy before uploads resume.",
"default_policy_remove_blocked": "Deleting the current default policy pauses uploads until storage setup is completed again.",
"access_key": "Access Key",
"base_url": "Base URL",
"namespace": "Namespace",
Expand Down
3 changes: 1 addition & 2 deletions frontend-panel/src/i18n/locales/en/admin/policy-groups.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"create_policy_group": "Create Policy Group",
"edit_policy_group": "Edit Policy Group",
"delete_policy_group": "Delete Policy Group",
"delete_policy_group_desc": "This will remove the group and its routing rules. Assigned users and teams must be moved first.",
"delete_policy_group_desc": "This will remove the group and its routing rules. Migrate assigned users and teams first, including for the last default group; deleting it reopens storage setup without clearing bindings.",
"policy_group_dialog_desc": "Define an ordered rule set that maps uploads to storage policies by file size.",
"policy_group_name_required": "Policy group name is required.",
"policy_group_default_requires_enabled": "A default policy group must remain enabled.",
Expand All @@ -67,6 +67,5 @@
"policy_group_rule_policy_duplicate": "A storage policy can only appear once in the same group.",
"policy_group_rule_size_invalid": "File size limits must be valid non-negative numbers.",
"policy_group_rule_range_invalid": "The max file size must be greater than the min file size.",
"policy_group_delete_default_blocked": "Set another group as default before deleting the current default group.",
"policy_group_no_policies_available": "Create at least one storage policy before creating a policy group."
}
7 changes: 3 additions & 4 deletions frontend-panel/src/i18n/locales/zh/admin/policies.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"unset_default": "取消默认",
"undo_changes": "撤销更改",
"save_changes": "保存更改",
"delete_policy_desc": "不会删除已存储的文件,但使用此策略的新上传将失败。",
"delete_policy_desc": "已存储文件不会受影响。如果这是最后一个默认策略,存储 setup 会回到未完成状态,重新配置默认策略前新上传会暂停。",
"force_delete_policy": "强制删除策略",
"force_delete_policy_desc": "该策略仍有关联的未完成上传。继续后会清理这些上传会话和临时对象;如果已有文件 blob 仍在使用该策略,删除仍会被拒绝。",
"force_delete_policy_confirm": "清理并删除",
Expand Down Expand Up @@ -128,11 +128,10 @@
"policy_assigned": "已分配存储策略",
"assignment_removed": "已移除策略分配",
"storage_policy_assignments_desc": "为用户指定上传默认使用的策略组;当前仅支持绑定一个策略组。",
"initial_policy_delete_blocked": "ID 为 1 的内置策略不可删除。",
"assignment_updated": "策略分配已更新",
"default_selected": "默认策略",
"default_policy_required": "存在策略分配时必须始终保留一个默认策略。",
"default_policy_remove_blocked": "请先将其他策略设为默认,再删除当前默认策略。",
"default_policy_required": "请先创建并分配默认策略,上传流程才会恢复。",
"default_policy_remove_blocked": "删除当前默认策略后,上传会暂停,直到重新完成存储 setup。",
"access_key": "访问密钥",
"base_url": "基础地址",
"namespace": "命名空间",
Expand Down
3 changes: 1 addition & 2 deletions frontend-panel/src/i18n/locales/zh/admin/policy-groups.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"create_policy_group": "创建策略组",
"edit_policy_group": "编辑策略组",
"delete_policy_group": "删除策略组",
"delete_policy_group_desc": "这会删除整个策略组及其路由规则。若仍有用户绑定,需要先迁移绑定关系。",
"delete_policy_group_desc": "这会删除整个策略组及其路由规则。包括最后一个默认策略组在内,都需要先迁移用户和团队绑定;删除默认组只会重新打开存储 setup,不会清空绑定。",
"policy_group_dialog_desc": "定义一组有序规则,按文件大小把上传分配到不同存储策略。",
"policy_group_name_required": "必须填写策略组名称。",
"policy_group_default_requires_enabled": "默认策略组必须保持启用。",
Expand All @@ -67,6 +67,5 @@
"policy_group_rule_policy_duplicate": "同一个存储策略在一个策略组里只能出现一次。",
"policy_group_rule_size_invalid": "文件大小限制必须是合法的非负数字。",
"policy_group_rule_range_invalid": "最大文件大小必须大于最小文件大小。",
"policy_group_delete_default_blocked": "请先把其他策略组设为默认,再删除当前默认策略组。",
"policy_group_no_policies_available": "请先创建至少一个存储策略,再创建策略组。"
}
13 changes: 8 additions & 5 deletions frontend-panel/src/pages/admin/AdminPolicyGroupsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -620,8 +620,8 @@ describe("AdminPolicyGroupsPage", () => {
screen.getByText("formatted:2026-03-28T00:00:00Z"),
).toBeInTheDocument();
expect(
screen.getByText("policy_group_delete_default_blocked"),
).toBeInTheDocument();
screen.queryByText("policy_group_delete_default_blocked"),
).toBeNull();

fireEvent.click(screen.getByText("Default Group"));

Expand Down Expand Up @@ -710,7 +710,7 @@ describe("AdminPolicyGroupsPage", () => {
).toBeInTheDocument();
});

it("prevents deleting the default group from the table", async () => {
it("allows deleting the default group from the table", async () => {
mockState.groupItems = [
createGroup({
name: "System Default",
Expand All @@ -723,11 +723,14 @@ describe("AdminPolicyGroupsPage", () => {
const deleteButton = await screen.findByRole("button", {
name: "delete_policy_group",
});
expect(deleteButton).toBeDisabled();
expect(deleteButton).toBeEnabled();

fireEvent.click(deleteButton);

expect(mockState.deleteGroup).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "core:delete" }));
await waitFor(() => {
expect(mockState.deleteGroup).toHaveBeenCalledWith(1);
});
expect(
screen.queryByText('delete_policy_group "System Default"?'),
).not.toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { PROTECTED_POLICY_ID } from "@/components/admin/admin-policies-page/policyPresentation";
import { handleApiError } from "@/hooks/useApiError";
import { useConfirmDialog } from "@/hooks/useConfirmDialog";
import {
Expand Down Expand Up @@ -130,7 +129,6 @@ export function useStoragePolicyListController() {
};

const handleDelete = async (id: number, options?: DeletePolicyQuery) => {
if (id === PROTECTED_POLICY_ID) return;
await runWithDeletingPolicy(id, async () => {
try {
if (options) {
Expand Down Expand Up @@ -170,7 +168,6 @@ export function useStoragePolicyListController() {
await handleDelete(id, { force: true });
});
const requestDeleteConfirm = (id: number) => {
if (id === PROTECTED_POLICY_ID) return;
requestConfirm(id);
};

Expand Down
5 changes: 4 additions & 1 deletion src/db/repository/policy_group_repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,10 @@ pub async fn delete_group_items_by_group<C: ConnectionTrait>(db: &C, group_id: i
Ok(result.rows_affected)
}

pub async fn count_group_items_by_policy(db: &DatabaseConnection, policy_id: i64) -> Result<u64> {
pub async fn count_group_items_by_policy<C: ConnectionTrait>(
db: &C,
policy_id: i64,
) -> Result<u64> {
StoragePolicyGroupItem::find()
.filter(storage_policy_group_item::Column::PolicyId.eq(policy_id))
.count(db)
Expand Down
Loading
Loading