mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
feat(storage): redesign backend management
This commit is contained in:
@@ -125,6 +125,21 @@ pnpm storage:backfill -- --sqlite zpan.db --apply
|
||||
|
||||
The backfill recalculates all eight storage categories from `matters` and `image_hostings`. It is an operator command, not part of the application runtime or deployment lifecycle.
|
||||
|
||||
### Storage enabled/status backfill
|
||||
|
||||
After applying migrations 0066 through 0069, convert legacy storage status values into
|
||||
the `enabled` flag and the `unknown`/`healthy`/`unhealthy` health model:
|
||||
|
||||
```sh
|
||||
pnpm storage-status:backfill -- --d1 zpan-db --remote
|
||||
pnpm storage-status:backfill -- --d1 zpan-db --remote --apply
|
||||
|
||||
pnpm storage-status:backfill -- --sqlite zpan.db
|
||||
pnpm storage-status:backfill -- --sqlite zpan.db --apply
|
||||
```
|
||||
|
||||
Run this once before deploying the application version that reads health status.
|
||||
|
||||
### Turso (libSQL) migrate path
|
||||
|
||||
When deploying the Node/Docker image against a Turso (libSQL) database, set `TURSO_DATABASE_URL` (and `TURSO_AUTH_TOKEN` for remote URLs) before running `db:migrate`. `drizzle.config.ts` detects the env var and switches to the `turso` dialect automatically:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `storages` ADD `enabled` integer DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE `storages` ADD `status_checked_at` integer;
|
||||
@@ -0,0 +1,28 @@
|
||||
PRAGMA defer_foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE TABLE `__new_storages` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`provider` text DEFAULT '' NOT NULL,
|
||||
`bucket` text NOT NULL,
|
||||
`endpoint` text NOT NULL,
|
||||
`region` text DEFAULT 'auto' NOT NULL,
|
||||
`access_key` text NOT NULL,
|
||||
`secret_key` text NOT NULL,
|
||||
`file_path` text DEFAULT '' NOT NULL,
|
||||
`custom_host` text DEFAULT '',
|
||||
`capacity` integer DEFAULT 0 NOT NULL,
|
||||
`egress_credit_billing_enabled` integer DEFAULT false NOT NULL,
|
||||
`egress_credit_unit_bytes` integer DEFAULT 104857600 NOT NULL,
|
||||
`egress_credit_per_unit` integer DEFAULT 1 NOT NULL,
|
||||
`force_path_style` integer DEFAULT true NOT NULL,
|
||||
`used` integer DEFAULT 0 NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`status` text DEFAULT 'untested' NOT NULL,
|
||||
`status_checked_at` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_storages`("id", "provider", "bucket", "endpoint", "region", "access_key", "secret_key", "file_path", "custom_host", "capacity", "egress_credit_billing_enabled", "egress_credit_unit_bytes", "egress_credit_per_unit", "force_path_style", "used", "enabled", "status", "status_checked_at", "created_at", "updated_at") SELECT "id", "provider", "bucket", "endpoint", "region", "access_key", "secret_key", "file_path", "custom_host", "capacity", "egress_credit_billing_enabled", "egress_credit_unit_bytes", "egress_credit_per_unit", "force_path_style", "used", "enabled", "status", "status_checked_at", "created_at", "updated_at" FROM `storages`;--> statement-breakpoint
|
||||
DROP TABLE `storages`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_storages` RENAME TO `storages`;--> statement-breakpoint
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `storages` ADD `status_reason` text;
|
||||
@@ -0,0 +1,29 @@
|
||||
PRAGMA defer_foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE TABLE `__new_storages` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`provider` text DEFAULT '' NOT NULL,
|
||||
`bucket` text NOT NULL,
|
||||
`endpoint` text NOT NULL,
|
||||
`region` text DEFAULT 'auto' NOT NULL,
|
||||
`access_key` text NOT NULL,
|
||||
`secret_key` text NOT NULL,
|
||||
`file_path` text DEFAULT '' NOT NULL,
|
||||
`custom_host` text DEFAULT '',
|
||||
`capacity` integer DEFAULT 0 NOT NULL,
|
||||
`egress_credit_billing_enabled` integer DEFAULT false NOT NULL,
|
||||
`egress_credit_unit_bytes` integer DEFAULT 104857600 NOT NULL,
|
||||
`egress_credit_per_unit` integer DEFAULT 1 NOT NULL,
|
||||
`force_path_style` integer DEFAULT true NOT NULL,
|
||||
`used` integer DEFAULT 0 NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`status` text DEFAULT 'unknown' NOT NULL,
|
||||
`status_reason` text,
|
||||
`status_checked_at` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_storages`("id", "provider", "bucket", "endpoint", "region", "access_key", "secret_key", "file_path", "custom_host", "capacity", "egress_credit_billing_enabled", "egress_credit_unit_bytes", "egress_credit_per_unit", "force_path_style", "used", "enabled", "status", "status_reason", "status_checked_at", "created_at", "updated_at") SELECT "id", "provider", "bucket", "endpoint", "region", "access_key", "secret_key", "file_path", "custom_host", "capacity", "egress_credit_billing_enabled", "egress_credit_unit_bytes", "egress_credit_per_unit", "force_path_style", "used", "enabled", "status", "status_reason", "status_checked_at", "created_at", "updated_at" FROM `storages`;--> statement-breakpoint
|
||||
DROP TABLE `storages`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_storages` RENAME TO `storages`;--> statement-breakpoint
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -456,6 +456,34 @@
|
||||
"when": 1784827364022,
|
||||
"tag": "0065_storage-usage-breakdowns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 66,
|
||||
"version": "6",
|
||||
"when": 1784839030057,
|
||||
"tag": "0066_add-storage-enabled-health-fields",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 67,
|
||||
"version": "6",
|
||||
"when": 1784839039887,
|
||||
"tag": "0067_storage-health-status-default",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 68,
|
||||
"version": "6",
|
||||
"when": 1784842514010,
|
||||
"tag": "0068_add-storage-status-reason",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 69,
|
||||
"version": "6",
|
||||
"when": 1784842525068,
|
||||
"tag": "0069_storage-health-status-vocabulary",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
"seed:preview-admin": "TMPDIR=/tmp node --env-file-if-exists=.dev.vars --import tsx scripts/seed-preview-admin.ts",
|
||||
"stats:backfill": "tsx scripts/backfill-admin-stats.ts",
|
||||
"storage:backfill": "tsx scripts/backfill-storage-usage.ts",
|
||||
"storage-status:backfill": "tsx scripts/backfill-storage-enabled-status.ts",
|
||||
"typecheck": "tsc --noEmit -p server/tsconfig.json && tsc --noEmit -p src/tsconfig.json",
|
||||
"test": "vitest run --project unit --project integration",
|
||||
"test:cf": "vitest run --project cloudflare",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import Database from 'better-sqlite3'
|
||||
|
||||
type Target =
|
||||
| { kind: 'sqlite'; path: string }
|
||||
| { kind: 'd1'; database: string; remote: boolean; env?: string }
|
||||
|
||||
interface Options {
|
||||
apply: boolean
|
||||
target: Target
|
||||
}
|
||||
|
||||
export const STORAGE_ENABLED_STATUS_BACKFILL_SQL = `
|
||||
UPDATE storages
|
||||
SET
|
||||
enabled = CASE
|
||||
WHEN status IN ('disabled', 'inactive') THEN 0
|
||||
WHEN status = 'active' THEN 1
|
||||
ELSE enabled
|
||||
END,
|
||||
status = CASE WHEN status IN ('failed', 'cors') THEN 'unhealthy' ELSE 'unknown' END,
|
||||
status_reason = CASE
|
||||
WHEN status = 'cors' THEN 'cors'
|
||||
WHEN status = 'failed' THEN 'unknown'
|
||||
ELSE NULL
|
||||
END,
|
||||
status_checked_at = CASE WHEN status IN ('failed', 'cors') THEN status_checked_at ELSE NULL END
|
||||
WHERE status IN ('active', 'disabled', 'inactive', 'untested', 'failed', 'cors');
|
||||
`.trim()
|
||||
|
||||
const SUMMARY_SQL = `
|
||||
SELECT json_object(
|
||||
'total', COUNT(*),
|
||||
'legacy', COALESCE(SUM(CASE WHEN status IN ('active', 'disabled', 'inactive', 'untested', 'failed', 'cors') THEN 1 ELSE 0 END), 0),
|
||||
'enabled', COALESCE(SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END), 0),
|
||||
'disabled', COALESCE(SUM(CASE WHEN enabled = 0 THEN 1 ELSE 0 END), 0)
|
||||
) AS summary
|
||||
FROM storages;
|
||||
`.trim()
|
||||
|
||||
function parseOptions(argv: string[]): Options {
|
||||
const sqliteIndex = argv.indexOf('--sqlite')
|
||||
const d1Index = argv.indexOf('--d1')
|
||||
if ((sqliteIndex >= 0) === (d1Index >= 0)) usage()
|
||||
if (sqliteIndex >= 0) {
|
||||
const path = argv[sqliteIndex + 1]
|
||||
if (!path) usage()
|
||||
return { apply: argv.includes('--apply'), target: { kind: 'sqlite', path } }
|
||||
}
|
||||
const database = argv[d1Index + 1]
|
||||
if (!database) usage()
|
||||
const envIndex = argv.indexOf('--env')
|
||||
return {
|
||||
apply: argv.includes('--apply'),
|
||||
target: {
|
||||
kind: 'd1',
|
||||
database,
|
||||
remote: argv.includes('--remote'),
|
||||
env: envIndex >= 0 ? argv[envIndex + 1] : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function usage(): never {
|
||||
throw new Error(
|
||||
'Usage: pnpm storage-status:backfill -- (--sqlite <path> | --d1 <database> [--remote] [--env <name>]) [--apply]',
|
||||
)
|
||||
}
|
||||
|
||||
function d1Args(target: Extract<Target, { kind: 'd1' }>): string[] {
|
||||
return [
|
||||
'exec',
|
||||
'wrangler',
|
||||
'd1',
|
||||
'execute',
|
||||
target.database,
|
||||
target.remote ? '--remote' : '--local',
|
||||
...(target.env ? ['--env', target.env] : []),
|
||||
]
|
||||
}
|
||||
|
||||
function executeD1(target: Extract<Target, { kind: 'd1' }>, sql: string, json = false): string {
|
||||
return execFileSync('pnpm', [...d1Args(target), '--command', sql, ...(json ? ['--json'] : [])], {
|
||||
encoding: 'utf8',
|
||||
stdio: json ? 'pipe' : 'inherit',
|
||||
}) as string
|
||||
}
|
||||
|
||||
function summary(target: Target): Record<string, number> {
|
||||
if (target.kind === 'd1') {
|
||||
const payload = JSON.parse(executeD1(target, SUMMARY_SQL, true)) as Array<{
|
||||
results?: Array<{ summary?: string }>
|
||||
}>
|
||||
const value = payload.flatMap((entry) => entry.results ?? []).find((row) => row.summary)?.summary
|
||||
if (!value) throw new Error('storage_status_backfill_summary_missing')
|
||||
return JSON.parse(value) as Record<string, number>
|
||||
}
|
||||
const db = new Database(target.path, { readonly: true })
|
||||
try {
|
||||
return JSON.parse((db.prepare(SUMMARY_SQL).get() as { summary: string }).summary) as Record<string, number>
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
function apply(target: Target): void {
|
||||
if (target.kind === 'd1') {
|
||||
executeD1(target, STORAGE_ENABLED_STATUS_BACKFILL_SQL)
|
||||
return
|
||||
}
|
||||
const db = new Database(target.path)
|
||||
try {
|
||||
db.exec(STORAGE_ENABLED_STATUS_BACKFILL_SQL)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const before = summary(options.target)
|
||||
console.log(JSON.stringify({ mode: options.apply ? 'apply' : 'dry-run', before }, null, 2))
|
||||
if (!options.apply) return
|
||||
apply(options.target)
|
||||
const after = summary(options.target)
|
||||
if (after.legacy !== 0) throw new Error(`storage_status_backfill_failed:${JSON.stringify(after)}`)
|
||||
console.log(JSON.stringify({ mode: 'complete', before, after }, null, 2))
|
||||
}
|
||||
|
||||
if (process.argv[1]?.endsWith('backfill-storage-enabled-status.ts')) main()
|
||||
@@ -108,7 +108,10 @@ function makeStorage(overrides: Partial<Storage> = {}): Storage {
|
||||
egressCreditPerUnit: 1,
|
||||
forcePathStyle: true,
|
||||
used: 0,
|
||||
status: 'active',
|
||||
enabled: true,
|
||||
status: 'healthy',
|
||||
statusReason: null,
|
||||
statusCheckedAt: '2026-01-01',
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
}
|
||||
|
||||
@@ -35,7 +35,10 @@ describe('[CF] storage usage projection invariant', () => {
|
||||
customHost: '',
|
||||
capacity: 0,
|
||||
used: 0,
|
||||
status: 'active',
|
||||
enabled: true,
|
||||
status: 'unknown',
|
||||
statusReason: null,
|
||||
statusCheckedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('createStorage', () => {
|
||||
expect(result.capacity).toBe(1073741824)
|
||||
})
|
||||
|
||||
it('initialises used to 0 and status to active', async () => {
|
||||
it('initialises enabled health state', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await createStorageRepo(db).create({
|
||||
bucket: 'my-bucket',
|
||||
@@ -81,7 +81,10 @@ describe('createStorage', () => {
|
||||
capacity: 0,
|
||||
})
|
||||
expect(result.used).toBe(0)
|
||||
expect(result.status).toBe('active')
|
||||
expect(result.enabled).toBe(true)
|
||||
expect(result.status).toBe('unknown')
|
||||
expect(result.statusReason).toBeNull()
|
||||
expect(result.statusCheckedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('persists the created row to the database', async () => {
|
||||
@@ -100,7 +103,7 @@ describe('createStorage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateStorage', () => {
|
||||
describe('replaceStorage and patchStorage', () => {
|
||||
async function seed(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
|
||||
return createStorageRepo(db).create({
|
||||
bucket: 'original-bucket',
|
||||
@@ -115,26 +118,15 @@ describe('updateStorage', () => {
|
||||
|
||||
it('returns null when storage does not exist', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await createStorageRepo(db).update('nonexistent', { bucket: 'new-bucket' })
|
||||
const result = await createStorageRepo(db).patch('nonexistent', { bucket: 'new-bucket' })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps existing values for fields not included in update', async () => {
|
||||
it('replaces all editable fields', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await seed(db)
|
||||
const updated = await createStorageRepo(db).update(created.id, {})
|
||||
expect(updated?.bucket).toBe('original-bucket')
|
||||
expect(updated?.region).toBe('us-east-1')
|
||||
expect(updated?.accessKey).toBe('AKID')
|
||||
expect(updated?.secretKey).toBe('SECRET')
|
||||
expect(updated?.customHost).toBe('https://cdn.original.com')
|
||||
expect(updated?.capacity).toBe(500)
|
||||
})
|
||||
|
||||
it('applies all provided optional fields', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await seed(db)
|
||||
const updated = await createStorageRepo(db).update(created.id, {
|
||||
const updated = await createStorageRepo(db).replace(created.id, {
|
||||
provider: 'r2',
|
||||
bucket: 'new-bucket',
|
||||
endpoint: 'https://r2.example.com',
|
||||
region: 'auto',
|
||||
@@ -142,7 +134,11 @@ describe('updateStorage', () => {
|
||||
secretKey: 'NEW_SECRET',
|
||||
customHost: 'https://cdn.new.com',
|
||||
capacity: 1000,
|
||||
status: 'disabled',
|
||||
forcePathStyle: false,
|
||||
egressCreditBillingEnabled: false,
|
||||
egressCreditUnitBytes: 1024,
|
||||
egressCreditPerUnit: 2,
|
||||
enabled: false,
|
||||
})
|
||||
expect(updated?.bucket).toBe('new-bucket')
|
||||
expect(updated?.endpoint).toBe('https://r2.example.com')
|
||||
@@ -151,23 +147,38 @@ describe('updateStorage', () => {
|
||||
expect(updated?.secretKey).toBe('NEW_SECRET')
|
||||
expect(updated?.customHost).toBe('https://cdn.new.com')
|
||||
expect(updated?.capacity).toBe(1000)
|
||||
expect(updated?.status).toBe('disabled')
|
||||
expect(updated?.enabled).toBe(false)
|
||||
expect(updated?.status).toBe('unknown')
|
||||
expect(updated?.statusReason).toBeNull()
|
||||
})
|
||||
|
||||
it('updates only status leaving all other fields intact', async () => {
|
||||
it('patches enabled without changing health', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await seed(db)
|
||||
const updated = await createStorageRepo(db).update(created.id, { status: 'disabled' })
|
||||
expect(updated?.status).toBe('disabled')
|
||||
const healthy = await createStorageRepo(db).patch(created.id, { status: 'healthy' })
|
||||
const updated = await createStorageRepo(db).patch(created.id, { enabled: false })
|
||||
expect(healthy?.statusCheckedAt).not.toBeNull()
|
||||
expect(updated?.enabled).toBe(false)
|
||||
expect(updated?.status).toBe('healthy')
|
||||
expect(updated?.bucket).toBe('original-bucket')
|
||||
})
|
||||
|
||||
it('resets health when connection settings change', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await seed(db)
|
||||
await createStorageRepo(db).patch(created.id, { status: 'healthy' })
|
||||
const updated = await createStorageRepo(db).patch(created.id, { endpoint: 'https://new.example.com' })
|
||||
expect(updated?.status).toBe('unknown')
|
||||
expect(updated?.statusReason).toBeNull()
|
||||
expect(updated?.statusCheckedAt).toBeNull()
|
||||
})
|
||||
|
||||
it('updates the updatedAt timestamp', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await seed(db)
|
||||
const before = created.updatedAt.getTime()
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
const updated = await createStorageRepo(db).update(created.id, { bucket: 'new-bucket' })
|
||||
const updated = await createStorageRepo(db).patch(created.id, { bucket: 'new-bucket' })
|
||||
expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual(before)
|
||||
})
|
||||
})
|
||||
@@ -228,7 +239,7 @@ describe('getStorage', () => {
|
||||
describe('selectStorage', () => {
|
||||
async function seedActive(
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['db'],
|
||||
opts: { capacity?: number; used?: number; status?: string; bucket?: string } = {},
|
||||
opts: { capacity?: number; used?: number; enabled?: boolean; bucket?: string } = {},
|
||||
) {
|
||||
const created = await createStorageRepo(db).create({
|
||||
bucket: opts.bucket ?? 'b',
|
||||
@@ -238,9 +249,9 @@ describe('selectStorage', () => {
|
||||
secretKey: 'S',
|
||||
capacity: opts.capacity ?? 0,
|
||||
})
|
||||
if (opts.used !== undefined || opts.status !== undefined) {
|
||||
if (opts.used !== undefined || opts.enabled !== undefined) {
|
||||
await db.run(
|
||||
sql`UPDATE storages SET used = ${opts.used ?? created.used}, status = ${opts.status ?? created.status} WHERE id = ${created.id}`,
|
||||
sql`UPDATE storages SET used = ${opts.used ?? created.used}, enabled = ${(opts.enabled ?? created.enabled) ? 1 : 0} WHERE id = ${created.id}`,
|
||||
)
|
||||
}
|
||||
return createStorageRepo(db).get(created.id)
|
||||
@@ -267,7 +278,7 @@ describe('selectStorage', () => {
|
||||
|
||||
it('rejects a requested inactive storage', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await seedActive(db, { status: 'disabled' })
|
||||
const created = await seedActive(db, { enabled: false })
|
||||
await expect(createStorageRepo(db).select(created?.id)).rejects.toThrow('No available storage')
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,15 @@ import type { Database } from '../../platform/interface'
|
||||
import type { StorageRecord, StorageRepo } from '../../usecases/ports'
|
||||
|
||||
type StorageRow = typeof storages.$inferSelect
|
||||
const CONNECTION_FIELDS = [
|
||||
'provider',
|
||||
'bucket',
|
||||
'endpoint',
|
||||
'region',
|
||||
'accessKey',
|
||||
'secretKey',
|
||||
'forcePathStyle',
|
||||
] as const
|
||||
|
||||
function toRecord(row: StorageRow): StorageRecord {
|
||||
return row as StorageRecord
|
||||
@@ -45,7 +54,10 @@ export function createStorageRepo(db: Database): StorageRepo {
|
||||
egressCreditPerUnit: input.egressCreditPerUnit ?? 1,
|
||||
forcePathStyle: input.forcePathStyle ?? true,
|
||||
used: 0,
|
||||
status: 'active',
|
||||
enabled: true,
|
||||
status: 'unknown',
|
||||
statusReason: null,
|
||||
statusCheckedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
@@ -58,25 +70,38 @@ export function createStorageRepo(db: Database): StorageRepo {
|
||||
return rows[0]?.count ?? 0
|
||||
},
|
||||
|
||||
async update(id, input) {
|
||||
async replace(id, input) {
|
||||
const existing = await getRow(id)
|
||||
if (!existing) return null
|
||||
|
||||
const now = new Date()
|
||||
const connectionChanged = CONNECTION_FIELDS.some((field) => input[field] !== existing[field])
|
||||
const updated = {
|
||||
provider: input.provider ?? existing.provider,
|
||||
bucket: input.bucket ?? existing.bucket,
|
||||
endpoint: input.endpoint ?? existing.endpoint,
|
||||
region: input.region ?? existing.region,
|
||||
accessKey: input.accessKey ?? existing.accessKey,
|
||||
secretKey: input.secretKey ?? existing.secretKey,
|
||||
customHost: input.customHost ?? existing.customHost,
|
||||
capacity: input.capacity ?? existing.capacity,
|
||||
egressCreditBillingEnabled: input.egressCreditBillingEnabled ?? existing.egressCreditBillingEnabled,
|
||||
egressCreditUnitBytes: input.egressCreditUnitBytes ?? existing.egressCreditUnitBytes,
|
||||
egressCreditPerUnit: input.egressCreditPerUnit ?? existing.egressCreditPerUnit,
|
||||
forcePathStyle: input.forcePathStyle ?? existing.forcePathStyle,
|
||||
status: input.status ?? existing.status,
|
||||
...input,
|
||||
customHost: input.customHost ?? '',
|
||||
...(connectionChanged ? { status: 'unknown', statusReason: null, statusCheckedAt: null } : {}),
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
await db.update(storages).set(updated).where(eq(storages.id, id))
|
||||
return toRecord({ ...existing, ...updated })
|
||||
},
|
||||
|
||||
async patch(id, input) {
|
||||
const existing = await getRow(id)
|
||||
if (!existing) return null
|
||||
|
||||
const now = new Date()
|
||||
const connectionChanged = CONNECTION_FIELDS.some(
|
||||
(field) => input[field] !== undefined && input[field] !== existing[field],
|
||||
)
|
||||
const updated = {
|
||||
...input,
|
||||
...(input.customHost === undefined ? {} : { customHost: input.customHost }),
|
||||
...(connectionChanged ? { status: 'unknown', statusReason: null, statusCheckedAt: null } : {}),
|
||||
...(input.status === undefined || connectionChanged
|
||||
? {}
|
||||
: { statusReason: input.statusReason ?? null, statusCheckedAt: now }),
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
@@ -105,7 +130,7 @@ export function createStorageRepo(db: Database): StorageRepo {
|
||||
.where(
|
||||
and(
|
||||
id ? eq(storages.id, id) : undefined,
|
||||
eq(storages.status, 'active'),
|
||||
eq(storages.enabled, true),
|
||||
or(eq(storages.capacity, 0), lt(storages.used, storages.capacity)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -81,3 +81,132 @@ describe('migration 0022_kind_storm.sql', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('migration 0067_storage-health-status-default.sql', () => {
|
||||
const migrationPath = join(process.cwd(), 'migrations/0067_storage-health-status-default.sql')
|
||||
const migration = readFileSync(migrationPath, 'utf-8')
|
||||
|
||||
it('rebuilds storages inside a transaction while preserving foreign key references', () => {
|
||||
const db = new Database(':memory:')
|
||||
|
||||
try {
|
||||
db.pragma('foreign_keys = ON')
|
||||
db.exec(`
|
||||
CREATE TABLE storages (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
provider TEXT DEFAULT '' NOT NULL,
|
||||
bucket TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
region TEXT DEFAULT 'auto' NOT NULL,
|
||||
access_key TEXT NOT NULL,
|
||||
secret_key TEXT NOT NULL,
|
||||
file_path TEXT DEFAULT '' NOT NULL,
|
||||
custom_host TEXT DEFAULT '',
|
||||
capacity INTEGER DEFAULT 0 NOT NULL,
|
||||
egress_credit_billing_enabled INTEGER DEFAULT 0 NOT NULL,
|
||||
egress_credit_unit_bytes INTEGER DEFAULT 104857600 NOT NULL,
|
||||
egress_credit_per_unit INTEGER DEFAULT 1 NOT NULL,
|
||||
force_path_style INTEGER DEFAULT 1 NOT NULL,
|
||||
used INTEGER DEFAULT 0 NOT NULL,
|
||||
enabled INTEGER DEFAULT 1 NOT NULL,
|
||||
status TEXT DEFAULT 'active' NOT NULL,
|
||||
status_checked_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE matters (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
storage_id TEXT NOT NULL REFERENCES storages(id)
|
||||
);
|
||||
INSERT INTO storages (
|
||||
id, bucket, endpoint, access_key, secret_key, status, created_at, updated_at
|
||||
) VALUES (
|
||||
'storage-1', 'bucket', 'https://s3.example.com', 'key', 'secret', 'active', 1, 1
|
||||
);
|
||||
INSERT INTO matters (id, storage_id) VALUES ('matter-1', 'storage-1');
|
||||
`)
|
||||
|
||||
db.exec('BEGIN')
|
||||
for (const statement of migration.split('--> statement-breakpoint')) db.exec(statement)
|
||||
db.exec('COMMIT')
|
||||
|
||||
expect(db.prepare('SELECT storage_id FROM matters').pluck().get()).toBe('storage-1')
|
||||
expect(db.pragma('foreign_key_check')).toEqual([])
|
||||
db.exec(`
|
||||
INSERT INTO storages (
|
||||
id, bucket, endpoint, access_key, secret_key, created_at, updated_at
|
||||
) VALUES (
|
||||
'storage-2', 'bucket-2', 'https://s3.example.com', 'key', 'secret', 2, 2
|
||||
)
|
||||
`)
|
||||
expect(db.prepare("SELECT status FROM storages WHERE id = 'storage-2'").pluck().get()).toBe('untested')
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('migration 0069_storage-health-status-vocabulary.sql', () => {
|
||||
const migrationPath = join(process.cwd(), 'migrations/0069_storage-health-status-vocabulary.sql')
|
||||
const migration = readFileSync(migrationPath, 'utf-8')
|
||||
|
||||
it('changes the default with status_reason present and preserves foreign key references', () => {
|
||||
const db = new Database(':memory:')
|
||||
|
||||
try {
|
||||
db.pragma('foreign_keys = ON')
|
||||
db.exec(`
|
||||
CREATE TABLE storages (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
provider TEXT DEFAULT '' NOT NULL,
|
||||
bucket TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
region TEXT DEFAULT 'auto' NOT NULL,
|
||||
access_key TEXT NOT NULL,
|
||||
secret_key TEXT NOT NULL,
|
||||
file_path TEXT DEFAULT '' NOT NULL,
|
||||
custom_host TEXT DEFAULT '',
|
||||
capacity INTEGER DEFAULT 0 NOT NULL,
|
||||
egress_credit_billing_enabled INTEGER DEFAULT 0 NOT NULL,
|
||||
egress_credit_unit_bytes INTEGER DEFAULT 104857600 NOT NULL,
|
||||
egress_credit_per_unit INTEGER DEFAULT 1 NOT NULL,
|
||||
force_path_style INTEGER DEFAULT 1 NOT NULL,
|
||||
used INTEGER DEFAULT 0 NOT NULL,
|
||||
enabled INTEGER DEFAULT 1 NOT NULL,
|
||||
status TEXT DEFAULT 'untested' NOT NULL,
|
||||
status_reason TEXT,
|
||||
status_checked_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE matters (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
storage_id TEXT NOT NULL REFERENCES storages(id)
|
||||
);
|
||||
INSERT INTO storages (
|
||||
id, bucket, endpoint, access_key, secret_key, status, created_at, updated_at
|
||||
) VALUES (
|
||||
'storage-1', 'bucket', 'https://s3.example.com', 'key', 'secret', 'healthy', 1, 1
|
||||
);
|
||||
INSERT INTO matters (id, storage_id) VALUES ('matter-1', 'storage-1');
|
||||
`)
|
||||
|
||||
db.exec('BEGIN')
|
||||
for (const statement of migration.split('--> statement-breakpoint')) db.exec(statement)
|
||||
db.exec('COMMIT')
|
||||
|
||||
expect(db.prepare('SELECT storage_id FROM matters').pluck().get()).toBe('storage-1')
|
||||
expect(db.pragma('foreign_key_check')).toEqual([])
|
||||
db.exec(`
|
||||
INSERT INTO storages (
|
||||
id, bucket, endpoint, access_key, secret_key, created_at, updated_at
|
||||
) VALUES (
|
||||
'storage-2', 'bucket-2', 'https://s3.example.com', 'key', 'secret', 2, 2
|
||||
)
|
||||
`)
|
||||
expect(db.prepare("SELECT status FROM storages WHERE id = 'storage-2'").pluck().get()).toBe('unknown')
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+4
-1
@@ -76,7 +76,10 @@ export const storages = sqliteTable('storages', {
|
||||
egressCreditPerUnit: integer('egress_credit_per_unit').notNull().default(1),
|
||||
forcePathStyle: integer('force_path_style', { mode: 'boolean' }).notNull().default(true),
|
||||
used: integer('used').notNull().default(0),
|
||||
status: text('status').notNull().default('active'),
|
||||
enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
|
||||
status: text('status').notNull().default('unknown'),
|
||||
statusReason: text('status_reason'),
|
||||
statusCheckedAt: integer('status_checked_at', { mode: 'timestamp_ms' }),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
})
|
||||
|
||||
@@ -35,7 +35,9 @@ describe('Admin overview API', () => {
|
||||
customHost: '',
|
||||
capacity: 1000,
|
||||
used: 400,
|
||||
status: 'active',
|
||||
enabled: true,
|
||||
status: 'healthy',
|
||||
statusCheckedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
@@ -100,7 +100,7 @@ const validStorage = {
|
||||
|
||||
async function insertStorage(
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['db'],
|
||||
opts: { id?: string; metered?: boolean; capacity?: number; used?: number; status?: string } = {},
|
||||
opts: { id?: string; metered?: boolean; capacity?: number; used?: number; enabled?: boolean } = {},
|
||||
) {
|
||||
const now = Date.now()
|
||||
const metered = opts.metered ? 1 : 0
|
||||
@@ -108,13 +108,13 @@ async function insertStorage(
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (
|
||||
id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
|
||||
capacity, used, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
|
||||
capacity, used, enabled, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
|
||||
egress_credit_per_unit, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
${id}, ${validStorage.bucket},
|
||||
${validStorage.endpoint}, ${validStorage.region}, ${validStorage.accessKey}, ${validStorage.secretKey},
|
||||
'', '', ${opts.capacity ?? 0}, ${opts.used ?? 0}, ${opts.status ?? 'active'}, ${metered}, ${100 * 1024 ** 2}, 1, ${now}, ${now}
|
||||
'', '', ${opts.capacity ?? 0}, ${opts.used ?? 0}, ${(opts.enabled ?? true) ? 1 : 0}, 'untested', ${metered}, ${100 * 1024 ** 2}, 1, ${now}, ${now}
|
||||
)
|
||||
`)
|
||||
}
|
||||
@@ -728,7 +728,7 @@ describe('Objects API', () => {
|
||||
it('POST /api/objects with ineligible storageId fails before draft/session creation', async () => {
|
||||
for (const storage of [
|
||||
{ id: 'missing' },
|
||||
{ id: 'inactive', status: 'disabled' },
|
||||
{ id: 'inactive', enabled: false },
|
||||
{ id: 'full', capacity: 1, used: 1 },
|
||||
]) {
|
||||
const { app, db } = await createTestApp()
|
||||
|
||||
@@ -423,7 +423,7 @@ describe('Storage audit events', () => {
|
||||
const { id: storageId } = (await createRes.json()) as { id: string }
|
||||
|
||||
const updateRes = await app.request(`/api/site/storages/${storageId}`, {
|
||||
method: 'PUT',
|
||||
method: 'PATCH',
|
||||
headers: { ...admin, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bucket: 'updated-bucket' }),
|
||||
})
|
||||
|
||||
@@ -41,6 +41,18 @@ const validStorage = {
|
||||
secretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
}
|
||||
|
||||
const validReplacement = {
|
||||
provider: '',
|
||||
...validStorage,
|
||||
customHost: '',
|
||||
capacity: 0,
|
||||
forcePathStyle: true,
|
||||
egressCreditBillingEnabled: false,
|
||||
egressCreditUnitBytes: 104857600,
|
||||
egressCreditPerUnit: 1,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
describe('[CF] Admin Storages API', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
const app = await buildApp()
|
||||
@@ -80,7 +92,9 @@ describe('[CF] Admin Storages API', () => {
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.status).toBe('active')
|
||||
expect(body.enabled).toBe(true)
|
||||
expect(body.status).toBe('unknown')
|
||||
expect(body.statusReason).toBeNull()
|
||||
expect(body.id).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -141,7 +155,7 @@ describe('[CF] Admin Storages API', () => {
|
||||
const res = await app.request(`/api/site/storages/${created.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bucket: 'updated-cf-bucket' }),
|
||||
body: JSON.stringify({ ...validReplacement, bucket: 'updated-cf-bucket' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
|
||||
@@ -13,6 +13,17 @@ const validStorage = {
|
||||
secretKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
||||
}
|
||||
|
||||
const validReplacement = {
|
||||
...validStorage,
|
||||
customHost: '',
|
||||
capacity: 0,
|
||||
forcePathStyle: true,
|
||||
egressCreditBillingEnabled: false,
|
||||
egressCreditUnitBytes: 104857600,
|
||||
egressCreditPerUnit: 1,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
describe('Admin Storages API', () => {
|
||||
it('returns 401 without auth [spec: storages/auth-required]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
@@ -57,7 +68,10 @@ describe('Admin Storages API', () => {
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.provider).toBe('aws-s3')
|
||||
expect(body.bucket).toBe('test-bucket')
|
||||
expect(body.status).toBe('active')
|
||||
expect(body.enabled).toBe(true)
|
||||
expect(body.status).toBe('unknown')
|
||||
expect(body.statusReason).toBeNull()
|
||||
expect(body.statusCheckedAt).toBeNull()
|
||||
expect(body.capacity).toBe(0)
|
||||
expect(body.forcePathStyle).toBe(true)
|
||||
expect(body.used).toBe(0)
|
||||
@@ -155,7 +169,7 @@ describe('Admin Storages API', () => {
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('PUT /:id updates a storage [spec: storages/update]', async () => {
|
||||
it('PUT /:id fully replaces a storage [spec: storages/update]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
@@ -169,26 +183,93 @@ describe('Admin Storages API', () => {
|
||||
const res = await app.request(`/api/site/storages/${created.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bucket: 'updated-bucket', status: 'disabled', forcePathStyle: false }),
|
||||
body: JSON.stringify({ ...validReplacement, bucket: 'updated-bucket', forcePathStyle: false, enabled: false }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Record<string, unknown>
|
||||
expect(body.bucket).toBe('updated-bucket')
|
||||
expect(body.status).toBe('disabled')
|
||||
expect(body.enabled).toBe(false)
|
||||
expect(body.status).toBe('unknown')
|
||||
expect(body.forcePathStyle).toBe(false)
|
||||
})
|
||||
|
||||
it('PUT /:id rejects partial payloads', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/site/storages/any', {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bucket: 'partial' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('PUT /:id returns 404 for missing storage', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/site/storages/nonexistent', {
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bucket: 'nope' }),
|
||||
body: JSON.stringify(validReplacement),
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('PATCH /:id updates enabled and health independently', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const createRes = await app.request('/api/site/storages', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(validStorage),
|
||||
})
|
||||
const created = (await createRes.json()) as { id: string }
|
||||
|
||||
const disabledRes = await app.request(`/api/site/storages/${created.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
})
|
||||
expect(disabledRes.status).toBe(200)
|
||||
expect(await disabledRes.json()).toMatchObject({ enabled: false, status: 'unknown', statusReason: null })
|
||||
|
||||
const healthRes = await app.request(`/api/site/storages/${created.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'unhealthy', statusReason: 'network_error' }),
|
||||
})
|
||||
expect(healthRes.status).toBe(200)
|
||||
const health = (await healthRes.json()) as {
|
||||
enabled: boolean
|
||||
status: string
|
||||
statusReason: string | null
|
||||
statusCheckedAt: string | null
|
||||
}
|
||||
expect(health.enabled).toBe(false)
|
||||
expect(health.status).toBe('unhealthy')
|
||||
expect(health.statusReason).toBe('network_error')
|
||||
expect(health.statusCheckedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('PATCH /:id rejects empty payloads and health mixed with connection settings', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
for (const body of [
|
||||
{},
|
||||
{ status: 'healthy', bucket: 'mixed' },
|
||||
{ status: 'unhealthy' },
|
||||
{ status: 'healthy', statusReason: 'cors' },
|
||||
{ statusReason: 'cors' },
|
||||
]) {
|
||||
const res = await app.request('/api/site/storages/any', {
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
}
|
||||
})
|
||||
|
||||
it('PUT /:id/egress-billing updates storage credits billing [spec: storages/egress-billing]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedBusinessLicense(db)
|
||||
@@ -319,7 +400,7 @@ async function insertStorage(
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['db'],
|
||||
opts: {
|
||||
id: string
|
||||
status?: string
|
||||
enabled?: boolean
|
||||
capacity?: number
|
||||
used?: number
|
||||
createdAt?: number
|
||||
@@ -328,10 +409,10 @@ async function insertStorage(
|
||||
const now = opts.createdAt ?? Date.now()
|
||||
const capacity = opts.capacity ?? 0
|
||||
const used = opts.used ?? 0
|
||||
const status = opts.status ?? 'active'
|
||||
const enabled = (opts.enabled ?? true) ? 1 : 0
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES (${opts.id}, 'bucket', 'https://s3.example.com', 'us-east-1', 'key', 'secret', '$UID/$RAW_NAME', '', ${capacity}, ${used}, ${status}, ${now}, ${now})
|
||||
INSERT INTO storages (id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, enabled, status, created_at, updated_at)
|
||||
VALUES (${opts.id}, 'bucket', 'https://s3.example.com', 'us-east-1', 'key', 'secret', '$UID/$RAW_NAME', '', ${capacity}, ${used}, ${enabled}, 'unknown', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -381,8 +462,8 @@ describe('selectStorage service', () => {
|
||||
|
||||
it('ignores disabled storages', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db, { id: 's1', status: 'disabled', capacity: 0, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', status: 'active', capacity: 0, createdAt: 2 })
|
||||
await insertStorage(db, { id: 's1', enabled: false, capacity: 0, createdAt: 1 })
|
||||
await insertStorage(db, { id: 's2', enabled: true, capacity: 0, createdAt: 2 })
|
||||
|
||||
const storage = await createStorageRepo(db).select()
|
||||
expect(storage.id).toBe('s2')
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { createStorageSchema, pageSchema, updateStorageEgressBillingSchema, updateStorageSchema } from '@shared/schemas'
|
||||
import {
|
||||
createStorageSchema,
|
||||
pageSchema,
|
||||
patchStorageSchema,
|
||||
replaceStorageSchema,
|
||||
updateStorageEgressBillingSchema,
|
||||
} from '@shared/schemas'
|
||||
import { requireAdmin } from '../../middleware/auth'
|
||||
import type { Env } from '../../middleware/platform'
|
||||
import { type StorageRecord, storageNotFound } from '../../usecases/ports'
|
||||
@@ -8,7 +14,8 @@ import {
|
||||
deleteStorage,
|
||||
getStorage,
|
||||
listStorages,
|
||||
updateStorage,
|
||||
patchStorage,
|
||||
replaceStorage,
|
||||
updateStorageEgressBilling,
|
||||
} from '../../usecases/site/storage'
|
||||
import { errorResponse, jsonBody, jsonContent } from '../openapi'
|
||||
@@ -33,7 +40,12 @@ const storageSchema = z
|
||||
egressCreditPerUnit: z.number().int(),
|
||||
forcePathStyle: z.boolean(),
|
||||
used: z.number().int(),
|
||||
status: z.string(),
|
||||
enabled: z.boolean(),
|
||||
status: z.enum(['unknown', 'healthy', 'unhealthy']),
|
||||
statusReason: z
|
||||
.enum(['cors', 'authentication_failed', 'permission_denied', 'bucket_not_found', 'network_error', 'unknown'])
|
||||
.nullable(),
|
||||
statusCheckedAt: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
@@ -42,7 +54,12 @@ const storageSchema = z
|
||||
type StorageDTO = z.infer<typeof storageSchema>
|
||||
|
||||
function toStorageDTO(s: StorageRecord): StorageDTO {
|
||||
return { ...s, createdAt: s.createdAt.toISOString(), updatedAt: s.updatedAt.toISOString() }
|
||||
return {
|
||||
...s,
|
||||
statusCheckedAt: s.statusCheckedAt?.toISOString() ?? null,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
updatedAt: s.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const storageListSchema = pageSchema(storageSchema, 'StorageList')
|
||||
@@ -85,14 +102,29 @@ const getStorageRoute = createRoute({
|
||||
},
|
||||
})
|
||||
|
||||
const updateStorageRoute = createRoute({
|
||||
operationId: 'updateStorage',
|
||||
summary: 'Update storage',
|
||||
const replaceStorageRoute = createRoute({
|
||||
operationId: 'replaceStorage',
|
||||
summary: 'Replace storage',
|
||||
tags: ['Storages'],
|
||||
method: 'put',
|
||||
path: '/{id}',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { params: z.object({ id: z.string() }), ...jsonBody(updateStorageSchema) },
|
||||
request: { params: z.object({ id: z.string() }), ...jsonBody(replaceStorageSchema) },
|
||||
responses: {
|
||||
200: jsonContent(storageSchema, 'Replaced storage'),
|
||||
402: errorResponse('Feature not available'),
|
||||
404: errorResponse('Storage not found'),
|
||||
},
|
||||
})
|
||||
|
||||
const patchStorageRoute = createRoute({
|
||||
operationId: 'patchStorage',
|
||||
summary: 'Patch storage',
|
||||
tags: ['Storages'],
|
||||
method: 'patch',
|
||||
path: '/{id}',
|
||||
middleware: [requireAdmin] as const,
|
||||
request: { params: z.object({ id: z.string() }), ...jsonBody(patchStorageSchema) },
|
||||
responses: {
|
||||
200: jsonContent(storageSchema, 'Updated storage'),
|
||||
402: errorResponse('Feature not available'),
|
||||
@@ -148,8 +180,16 @@ const storages = new OpenAPIHono<Env>()
|
||||
if (!storage) throw storageNotFound()
|
||||
return c.json(toStorageDTO(storage), 200)
|
||||
})
|
||||
.openapi(updateStorageRoute, async (c) => {
|
||||
const result = await updateStorage(c.get('deps'), {
|
||||
.openapi(replaceStorageRoute, async (c) => {
|
||||
const result = await replaceStorage(c.get('deps'), {
|
||||
id: c.req.valid('param').id,
|
||||
input: c.req.valid('json'),
|
||||
})
|
||||
if (!result.ok) throw result.error
|
||||
return c.json(toStorageDTO(result.storage), 200)
|
||||
})
|
||||
.openapi(patchStorageRoute, async (c) => {
|
||||
const result = await patchStorage(c.get('deps'), {
|
||||
id: c.req.valid('param').id,
|
||||
input: c.req.valid('json'),
|
||||
})
|
||||
|
||||
@@ -89,6 +89,7 @@ const STANDARD_AUDIT_ROUTES: AuditRoute[] = [
|
||||
}),
|
||||
responseResourceRoute('POST', '/api/site/storages', 'storage_create', 'storage', 'bucket'),
|
||||
responseResourceRoute('PUT', '/api/site/storages/:storageId', 'storage_update', 'storage', 'bucket', 'storageId'),
|
||||
responseResourceRoute('PATCH', '/api/site/storages/:storageId', 'storage_update', 'storage', 'bucket', 'storageId'),
|
||||
responseResourceRoute(
|
||||
'PUT',
|
||||
'/api/site/storages/:storageId/egress-billing',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { STORAGE_ENABLED_STATUS_BACKFILL_SQL } from '../../scripts/backfill-storage-enabled-status'
|
||||
|
||||
describe('storage enabled/status backfill', () => {
|
||||
it('converts legacy statuses and can be rerun', () => {
|
||||
const db = new Database(':memory:')
|
||||
db.exec(`
|
||||
CREATE TABLE storages (
|
||||
id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL,
|
||||
status_reason TEXT,
|
||||
status_checked_at INTEGER
|
||||
);
|
||||
INSERT INTO storages (id, status, status_checked_at) VALUES
|
||||
('active', 'active', 1),
|
||||
('cors', 'cors', 2),
|
||||
('disabled', 'disabled', 2),
|
||||
('failed', 'failed', 3),
|
||||
('inactive', 'inactive', 3),
|
||||
('untested', 'untested', 4),
|
||||
('healthy', 'healthy', 4);
|
||||
INSERT INTO storages (id, enabled, status, status_checked_at)
|
||||
VALUES ('disabled-untested', 0, 'untested', 5);
|
||||
`)
|
||||
|
||||
db.exec(STORAGE_ENABLED_STATUS_BACKFILL_SQL)
|
||||
db.exec(STORAGE_ENABLED_STATUS_BACKFILL_SQL)
|
||||
|
||||
expect(
|
||||
db
|
||||
.prepare(
|
||||
'SELECT id, enabled, status, status_reason AS reason, status_checked_at AS checkedAt FROM storages ORDER BY id',
|
||||
)
|
||||
.all(),
|
||||
).toEqual([
|
||||
{ id: 'active', enabled: 1, status: 'unknown', reason: null, checkedAt: null },
|
||||
{ id: 'cors', enabled: 1, status: 'unhealthy', reason: 'cors', checkedAt: 2 },
|
||||
{ id: 'disabled', enabled: 0, status: 'unknown', reason: null, checkedAt: null },
|
||||
{ id: 'disabled-untested', enabled: 0, status: 'unknown', reason: null, checkedAt: null },
|
||||
{ id: 'failed', enabled: 1, status: 'unhealthy', reason: 'unknown', checkedAt: 3 },
|
||||
{ id: 'healthy', enabled: 1, status: 'healthy', reason: null, checkedAt: 4 },
|
||||
{ id: 'inactive', enabled: 0, status: 'unknown', reason: null, checkedAt: null },
|
||||
{ id: 'untested', enabled: 1, status: 'unknown', reason: null, checkedAt: null },
|
||||
])
|
||||
db.close()
|
||||
})
|
||||
})
|
||||
@@ -175,7 +175,10 @@ const APP_SCHEMA_SQL = `
|
||||
custom_host TEXT DEFAULT '',
|
||||
capacity INTEGER NOT NULL DEFAULT 0,
|
||||
used INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'unknown',
|
||||
status_reason TEXT,
|
||||
status_checked_at INTEGER,
|
||||
egress_credit_billing_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
egress_credit_unit_bytes INTEGER NOT NULL DEFAULT 104857600,
|
||||
egress_credit_per_unit INTEGER NOT NULL DEFAULT 1,
|
||||
|
||||
@@ -43,7 +43,10 @@ function storage(overrides: Partial<Storage> = {}): Storage {
|
||||
egressCreditUnitBytes: 100,
|
||||
egressCreditPerUnit: 1,
|
||||
used: 400,
|
||||
status: 'active',
|
||||
enabled: true,
|
||||
status: 'healthy',
|
||||
statusReason: null,
|
||||
statusCheckedAt: now.toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
...overrides,
|
||||
|
||||
@@ -14,10 +14,11 @@ export async function getAdminOverview(deps: Deps, now = new Date()): Promise<Ad
|
||||
id: storage.id,
|
||||
provider: storage.provider,
|
||||
bucket: storage.bucket,
|
||||
enabled: storage.enabled,
|
||||
status: storage.status,
|
||||
used: storage.used,
|
||||
capacity: storage.capacity,
|
||||
writable: storage.status === 'active' && (storage.capacity === 0 || storage.used < storage.capacity),
|
||||
writable: storage.enabled && (storage.capacity === 0 || storage.used < storage.capacity),
|
||||
}))
|
||||
const writableStorages = storageItems.filter((storage) => storage.writable).length
|
||||
const onlineDownloaders = downloaders.filter((downloader) => downloader.enabled && downloader.status === 'online')
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { CreateStorageInput, UpdateStorageInput } from '@shared/schemas'
|
||||
import type { CreateStorageInput, PatchStorageInput, ReplaceStorageInput } from '@shared/schemas'
|
||||
import type { Storage } from '@shared/types'
|
||||
|
||||
// Server-side record: the shared DTO, but timestamps stay as Date until the http
|
||||
// layer serializes them. Drizzle row types never cross this boundary.
|
||||
export type StorageRecord = Omit<Storage, 'createdAt' | 'updatedAt'> & {
|
||||
export type StorageRecord = Omit<Storage, 'createdAt' | 'updatedAt' | 'statusCheckedAt'> & {
|
||||
statusCheckedAt: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
@@ -15,7 +16,8 @@ export interface StorageRepo {
|
||||
get(id: string): Promise<StorageRecord | null>
|
||||
create(input: CreateStorageInput): Promise<StorageRecord>
|
||||
count(): Promise<number>
|
||||
update(id: string, input: UpdateStorageInput): Promise<StorageRecord | null>
|
||||
replace(id: string, input: ReplaceStorageInput): Promise<StorageRecord | null>
|
||||
patch(id: string, input: PatchStorageInput): Promise<StorageRecord | null>
|
||||
delete(id: string): Promise<DeleteStorageResult>
|
||||
// Picks the oldest active storage with available capacity (uploads land here),
|
||||
// or validates and returns the requested storage against the same eligibility.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FREE_STORAGE_LIMIT } from '@shared/constants'
|
||||
import type { CreateStorageInput } from '@shared/schemas'
|
||||
import type { CreateStorageInput, ReplaceStorageInput } from '@shared/schemas'
|
||||
import type { BindingState } from '@shared/types'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LicenseBindingRepo, StorageRecord, StorageRepo } from '../ports'
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
deleteStorage,
|
||||
getStorage,
|
||||
listStorages,
|
||||
patchStorage,
|
||||
replaceStorage,
|
||||
type StorageDeps,
|
||||
updateStorage,
|
||||
updateStorageEgressBilling,
|
||||
} from './storage'
|
||||
|
||||
@@ -37,13 +38,29 @@ const validInput: CreateStorageInput = {
|
||||
secretKey: 's',
|
||||
} as CreateStorageInput
|
||||
|
||||
const validReplacement: ReplaceStorageInput = {
|
||||
provider: '',
|
||||
bucket: 'new-b',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'us-east-1',
|
||||
accessKey: 'k',
|
||||
secretKey: 's',
|
||||
capacity: 0,
|
||||
forcePathStyle: true,
|
||||
egressCreditBillingEnabled: false,
|
||||
egressCreditUnitBytes: 104857600,
|
||||
egressCreditPerUnit: 1,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
function makeDeps(storages: Partial<StorageRepo> = {}) {
|
||||
const repo: StorageRepo = {
|
||||
list: async () => ({ items: [], total: 0 }),
|
||||
get: async () => null,
|
||||
create: async () => sampleStorage,
|
||||
count: async () => 0,
|
||||
update: async () => null,
|
||||
replace: async () => null,
|
||||
patch: async () => null,
|
||||
delete: async () => 'ok',
|
||||
select: async () => sampleStorage,
|
||||
...storages,
|
||||
@@ -135,20 +152,20 @@ describe('storage usecase', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateStorage', () => {
|
||||
it('updates the storage', async () => {
|
||||
describe('replaceStorage', () => {
|
||||
it('replaces the storage', async () => {
|
||||
edition(COMMUNITY)
|
||||
const update = vi.fn(async () => sampleStorage)
|
||||
const { deps } = makeDeps({ update })
|
||||
const out = await updateStorage(deps, { id: 'st-1', input: { bucket: 'new-b' } })
|
||||
const replace = vi.fn(async () => sampleStorage)
|
||||
const { deps } = makeDeps({ replace })
|
||||
const out = await replaceStorage(deps, { id: 'st-1', input: validReplacement })
|
||||
expect(out).toEqual({ ok: true, storage: sampleStorage })
|
||||
expect(update).toHaveBeenCalledWith('st-1', { bucket: 'new-b' })
|
||||
expect(replace).toHaveBeenCalledWith('st-1', validReplacement)
|
||||
})
|
||||
|
||||
it('returns not_found for a missing storage', async () => {
|
||||
edition(COMMUNITY)
|
||||
const { deps } = makeDeps({ update: async () => null })
|
||||
const out = await updateStorage(deps, { id: 'x', input: { bucket: 'new-b' } })
|
||||
const { deps } = makeDeps({ replace: async () => null })
|
||||
const out = await replaceStorage(deps, { id: 'x', input: validReplacement })
|
||||
expect(out.ok).toBe(false)
|
||||
if (!out.ok) {
|
||||
expect(out.error).toBeInstanceOf(AppError)
|
||||
@@ -159,11 +176,11 @@ describe('storage usecase', () => {
|
||||
|
||||
it('gates egress-credit billing before the existence check (402 over 404)', async () => {
|
||||
edition(PRO) // lacks quota_store
|
||||
const update = vi.fn(async () => null)
|
||||
const { deps } = makeDeps({ update })
|
||||
const out = await updateStorage(deps, {
|
||||
const replace = vi.fn(async () => null)
|
||||
const { deps } = makeDeps({ replace })
|
||||
const out = await replaceStorage(deps, {
|
||||
id: 'x',
|
||||
input: { egressCreditBillingEnabled: true },
|
||||
input: { ...validReplacement, egressCreditBillingEnabled: true },
|
||||
})
|
||||
expect(out.ok).toBe(false)
|
||||
if (!out.ok) {
|
||||
@@ -172,21 +189,32 @@ describe('storage usecase', () => {
|
||||
expect(out.error.meta.reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(out.error.meta.metadata).toEqual({ feature: 'quota_store' })
|
||||
}
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('patchStorage', () => {
|
||||
it('patches only the requested fields', async () => {
|
||||
edition(COMMUNITY)
|
||||
const patch = vi.fn(async () => sampleStorage)
|
||||
const { deps } = makeDeps({ patch })
|
||||
const out = await patchStorage(deps, { id: 'st-1', input: { enabled: false } })
|
||||
expect(out).toEqual({ ok: true, storage: sampleStorage })
|
||||
expect(patch).toHaveBeenCalledWith('st-1', { enabled: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateStorageEgressBilling', () => {
|
||||
it('updates egress billing fields', async () => {
|
||||
edition(BUSINESS)
|
||||
const update = vi.fn(async () => sampleStorage)
|
||||
const { deps } = makeDeps({ get: async () => sampleStorage, update })
|
||||
const patch = vi.fn(async () => sampleStorage)
|
||||
const { deps } = makeDeps({ get: async () => sampleStorage, patch })
|
||||
const out = await updateStorageEgressBilling(deps, {
|
||||
id: 'st-1',
|
||||
input: { enabled: true, unitBytes: 1024, creditsPerUnit: 2 },
|
||||
})
|
||||
expect(out).toEqual({ ok: true, storage: sampleStorage })
|
||||
expect(update).toHaveBeenCalledWith('st-1', {
|
||||
expect(patch).toHaveBeenCalledWith('st-1', {
|
||||
egressCreditBillingEnabled: true,
|
||||
egressCreditUnitBytes: 1024,
|
||||
egressCreditPerUnit: 2,
|
||||
@@ -195,8 +223,8 @@ describe('storage usecase', () => {
|
||||
|
||||
it('blocks enabling egress billing without quota_store', async () => {
|
||||
edition(PRO)
|
||||
const update = vi.fn(async () => sampleStorage)
|
||||
const { deps } = makeDeps({ get: async () => sampleStorage, update })
|
||||
const patch = vi.fn(async () => sampleStorage)
|
||||
const { deps } = makeDeps({ get: async () => sampleStorage, patch })
|
||||
const out = await updateStorageEgressBilling(deps, {
|
||||
id: 'st-1',
|
||||
input: { enabled: true, unitBytes: 1024, creditsPerUnit: 2 },
|
||||
@@ -208,12 +236,12 @@ describe('storage usecase', () => {
|
||||
expect(out.error.meta.reason).toBe('FEATURE_NOT_AVAILABLE')
|
||||
expect(out.error.meta.metadata).toEqual({ feature: 'quota_store' })
|
||||
}
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns not_found for a missing storage when billing is disabled', async () => {
|
||||
edition(PRO)
|
||||
const { deps } = makeDeps({ update: async () => null })
|
||||
const { deps } = makeDeps({ patch: async () => null })
|
||||
const out = await updateStorageEgressBilling(deps, {
|
||||
id: 'missing',
|
||||
input: { enabled: false, unitBytes: 1024, creditsPerUnit: 2 },
|
||||
@@ -227,8 +255,8 @@ describe('storage usecase', () => {
|
||||
|
||||
it('returns not_found before quota_store gating for a missing storage when billing is enabled', async () => {
|
||||
edition(PRO)
|
||||
const update = vi.fn(async () => null)
|
||||
const { deps } = makeDeps({ get: async () => null, update })
|
||||
const patch = vi.fn(async () => null)
|
||||
const { deps } = makeDeps({ get: async () => null, patch })
|
||||
const out = await updateStorageEgressBilling(deps, {
|
||||
id: 'missing',
|
||||
input: { enabled: true, unitBytes: 1024, creditsPerUnit: 2 },
|
||||
@@ -238,7 +266,7 @@ describe('storage usecase', () => {
|
||||
expect(out.error.httpStatus).toBe(404)
|
||||
expect(out.error.message).toBe('Storage not found')
|
||||
}
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
expect(patch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
// the CRUD resource; that one is a cross-resource operation.
|
||||
|
||||
import { FREE_STORAGE_LIMIT } from '@shared/constants'
|
||||
import type { CreateStorageInput, UpdateStorageEgressBillingInput, UpdateStorageInput } from '@shared/schemas'
|
||||
import type {
|
||||
CreateStorageInput,
|
||||
PatchStorageInput,
|
||||
ReplaceStorageInput,
|
||||
UpdateStorageEgressBillingInput,
|
||||
} from '@shared/schemas'
|
||||
import { hasFeature } from '../../domain/licensing'
|
||||
import {
|
||||
type AppError,
|
||||
@@ -86,9 +91,9 @@ export async function createStorage(
|
||||
return { ok: true, storage }
|
||||
}
|
||||
|
||||
export async function updateStorage(
|
||||
export async function replaceStorage(
|
||||
deps: StorageDeps,
|
||||
params: { id: string; input: UpdateStorageInput },
|
||||
params: { id: string; input: ReplaceStorageInput },
|
||||
): Promise<UpdateStorageOutcome> {
|
||||
const { id, input } = params
|
||||
// Feature gate before the existence check — preserves 402-over-404 ordering.
|
||||
@@ -98,7 +103,23 @@ export async function updateStorage(
|
||||
) {
|
||||
return { ok: false, error: featureBlockError({ feature: 'quota_store' }) }
|
||||
}
|
||||
const storage = await deps.storages.update(id, input)
|
||||
const storage = await deps.storages.replace(id, input)
|
||||
if (!storage) return { ok: false, error: storageNotFound() }
|
||||
return { ok: true, storage }
|
||||
}
|
||||
|
||||
export async function patchStorage(
|
||||
deps: StorageDeps,
|
||||
params: { id: string; input: PatchStorageInput },
|
||||
): Promise<UpdateStorageOutcome> {
|
||||
const { id, input } = params
|
||||
if (
|
||||
enablesEgressCreditBilling(input) &&
|
||||
!hasFeature('quota_store', await loadBindingState({ licenseBinding: deps.licenseBinding }))
|
||||
) {
|
||||
return { ok: false, error: featureBlockError({ feature: 'quota_store' }) }
|
||||
}
|
||||
const storage = await deps.storages.patch(id, input)
|
||||
if (!storage) return { ok: false, error: storageNotFound() }
|
||||
return { ok: true, storage }
|
||||
}
|
||||
@@ -113,7 +134,7 @@ export async function updateStorageEgressBilling(
|
||||
if (input.enabled && !hasFeature('quota_store', await loadBindingState({ licenseBinding: deps.licenseBinding }))) {
|
||||
return { ok: false, error: featureBlockError({ feature: 'quota_store' }) }
|
||||
}
|
||||
const storage = await deps.storages.update(id, {
|
||||
const storage = await deps.storages.patch(id, {
|
||||
egressCreditBillingEnabled: input.enabled,
|
||||
egressCreditUnitBytes: input.unitBytes,
|
||||
egressCreditPerUnit: input.creditsPerUnit,
|
||||
|
||||
+14
-2
@@ -14,12 +14,24 @@ export const DirType = {
|
||||
export type DirType = (typeof DirType)[keyof typeof DirType]
|
||||
|
||||
export const StorageStatus = {
|
||||
ACTIVE: 'active',
|
||||
INACTIVE: 'inactive',
|
||||
UNKNOWN: 'unknown',
|
||||
HEALTHY: 'healthy',
|
||||
UNHEALTHY: 'unhealthy',
|
||||
} as const
|
||||
|
||||
export type StorageStatus = (typeof StorageStatus)[keyof typeof StorageStatus]
|
||||
|
||||
export const StorageStatusReason = {
|
||||
CORS: 'cors',
|
||||
AUTHENTICATION_FAILED: 'authentication_failed',
|
||||
PERMISSION_DENIED: 'permission_denied',
|
||||
BUCKET_NOT_FOUND: 'bucket_not_found',
|
||||
NETWORK_ERROR: 'network_error',
|
||||
UNKNOWN: 'unknown',
|
||||
} as const
|
||||
|
||||
export type StorageStatusReason = (typeof StorageStatusReason)[keyof typeof StorageStatusReason]
|
||||
|
||||
// Soft delete is tracked by the `trashedAt` timestamp, not a status value:
|
||||
// live = active & trashedAt IS NULL, trash = active & trashedAt IS NOT NULL.
|
||||
export const ObjectStatus = {
|
||||
|
||||
+12
-2
@@ -175,8 +175,18 @@ export {
|
||||
updateSiteWebDavSchema,
|
||||
webDavVerificationStatusSchema,
|
||||
} from './site-config'
|
||||
export type { CreateStorageInput, UpdateStorageEgressBillingInput, UpdateStorageInput } from './storage'
|
||||
export { createStorageSchema, updateStorageEgressBillingSchema, updateStorageSchema } from './storage'
|
||||
export type {
|
||||
CreateStorageInput,
|
||||
PatchStorageInput,
|
||||
ReplaceStorageInput,
|
||||
UpdateStorageEgressBillingInput,
|
||||
} from './storage'
|
||||
export {
|
||||
createStorageSchema,
|
||||
patchStorageSchema,
|
||||
replaceStorageSchema,
|
||||
updateStorageEgressBillingSchema,
|
||||
} from './storage'
|
||||
|
||||
export const signInSchema = z.object({
|
||||
email: z.string().email(),
|
||||
|
||||
+62
-14
@@ -15,22 +15,69 @@ export const createStorageSchema = z.object({
|
||||
egressCreditPerUnit: z.number().int().positive().default(1),
|
||||
})
|
||||
|
||||
export const updateStorageSchema = z.object({
|
||||
provider: z.string().optional(),
|
||||
bucket: z.string().min(1).optional(),
|
||||
endpoint: z.string().url().optional(),
|
||||
region: z.string().optional(),
|
||||
accessKey: z.string().min(1).optional(),
|
||||
secretKey: z.string().min(1).optional(),
|
||||
export const replaceStorageSchema = z.object({
|
||||
provider: z.string(),
|
||||
bucket: z.string().min(1),
|
||||
endpoint: z.string().url(),
|
||||
region: z.string(),
|
||||
accessKey: z.string().min(1),
|
||||
secretKey: z.string().min(1),
|
||||
customHost: z.string().optional(),
|
||||
capacity: z.number().int().min(0).optional(),
|
||||
forcePathStyle: z.boolean().optional(),
|
||||
egressCreditBillingEnabled: z.boolean().optional(),
|
||||
egressCreditUnitBytes: z.number().int().positive().optional(),
|
||||
egressCreditPerUnit: z.number().int().positive().optional(),
|
||||
status: z.enum(['active', 'disabled']).optional(),
|
||||
capacity: z.number().int().min(0),
|
||||
forcePathStyle: z.boolean(),
|
||||
egressCreditBillingEnabled: z.boolean(),
|
||||
egressCreditUnitBytes: z.number().int().positive(),
|
||||
egressCreditPerUnit: z.number().int().positive(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
|
||||
export const patchStorageSchema = z
|
||||
.object({
|
||||
provider: z.string().optional(),
|
||||
bucket: z.string().min(1).optional(),
|
||||
endpoint: z.string().url().optional(),
|
||||
region: z.string().optional(),
|
||||
accessKey: z.string().min(1).optional(),
|
||||
secretKey: z.string().min(1).optional(),
|
||||
customHost: z.string().optional(),
|
||||
capacity: z.number().int().min(0).optional(),
|
||||
forcePathStyle: z.boolean().optional(),
|
||||
egressCreditBillingEnabled: z.boolean().optional(),
|
||||
egressCreditUnitBytes: z.number().int().positive().optional(),
|
||||
egressCreditPerUnit: z.number().int().positive().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
status: z.enum(['unknown', 'healthy', 'unhealthy']).optional(),
|
||||
statusReason: z
|
||||
.enum(['cors', 'authentication_failed', 'permission_denied', 'bucket_not_found', 'network_error', 'unknown'])
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.refine((input) => Object.keys(input).length > 0, { message: 'At least one field is required' })
|
||||
.refine(
|
||||
(input) =>
|
||||
input.status === undefined ||
|
||||
!['provider', 'bucket', 'endpoint', 'region', 'accessKey', 'secretKey', 'forcePathStyle'].some(
|
||||
(field) => field in input,
|
||||
),
|
||||
{ message: 'Health status cannot be updated with connection settings' },
|
||||
)
|
||||
.superRefine((input, ctx) => {
|
||||
if (input.status === undefined && input.statusReason !== undefined) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Health reason requires a health status', path: ['statusReason'] })
|
||||
return
|
||||
}
|
||||
if (input.status === 'unhealthy' && input.statusReason == null) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Unhealthy status requires a reason', path: ['statusReason'] })
|
||||
}
|
||||
if (input.status !== undefined && input.status !== 'unhealthy' && input.statusReason != null) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Only an unhealthy status can have a reason',
|
||||
path: ['statusReason'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const updateStorageEgressBillingSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
unitBytes: z.number().int().positive(),
|
||||
@@ -38,5 +85,6 @@ export const updateStorageEgressBillingSchema = z.object({
|
||||
})
|
||||
|
||||
export type CreateStorageInput = z.input<typeof createStorageSchema>
|
||||
export type UpdateStorageInput = z.input<typeof updateStorageSchema>
|
||||
export type ReplaceStorageInput = z.input<typeof replaceStorageSchema>
|
||||
export type PatchStorageInput = z.input<typeof patchStorageSchema>
|
||||
export type UpdateStorageEgressBillingInput = z.input<typeof updateStorageEgressBillingSchema>
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface AdminOverviewStorage {
|
||||
id: string
|
||||
provider: string
|
||||
bucket: string
|
||||
enabled: boolean
|
||||
status: string
|
||||
used: number
|
||||
capacity: number
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CommercePayment, CommerceProduct, ProductPrice } from 'zpan-cloud-sdk'
|
||||
import type { DirType, ObjectStatus, StorageStatus } from '../constants'
|
||||
import type { DirType, ObjectStatus, StorageStatus, StorageStatusReason } from '../constants'
|
||||
import type {
|
||||
CloudOrder as ZPanCloudOrder,
|
||||
CloudOrderFulfillmentPayload as ZPanCloudOrderFulfillmentPayload,
|
||||
@@ -40,7 +40,10 @@ export interface Storage {
|
||||
egressCreditUnitBytes: number
|
||||
egressCreditPerUnit: number
|
||||
used: number
|
||||
enabled: boolean
|
||||
status: StorageStatus
|
||||
statusReason: StorageStatusReason | null
|
||||
statusCheckedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Storage } from '@shared/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createStorage, updateStorage } from '@/lib/api'
|
||||
import { createStorage, replaceStorage } from '@/lib/api'
|
||||
import { StorageFormDrawer } from './storage-form-drawer'
|
||||
|
||||
class TestResizeObserver {
|
||||
@@ -27,7 +27,7 @@ vi.mock('sonner', () => ({
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
createStorage: vi.fn(),
|
||||
updateStorage: vi.fn(),
|
||||
replaceStorage: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/eplist', () => ({
|
||||
@@ -57,7 +57,10 @@ const storage: Storage = {
|
||||
egressCreditUnitBytes: 100 * 1024 * 1024,
|
||||
egressCreditPerUnit: 3,
|
||||
used: 0,
|
||||
status: StorageStatus.ACTIVE,
|
||||
enabled: true,
|
||||
status: StorageStatus.HEALTHY,
|
||||
statusReason: null,
|
||||
statusCheckedAt: '2026-01-01T00:00:00.000Z',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
@@ -119,7 +122,7 @@ describe('StorageFormDrawer', () => {
|
||||
|
||||
it('resets edit values, allows provider editing, submits update payload, and toggles secret visibility', async () => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
vi.mocked(updateStorage).mockResolvedValue(storage)
|
||||
vi.mocked(replaceStorage).mockResolvedValue(storage)
|
||||
renderStorageFormDrawer({ storage })
|
||||
|
||||
const providerInput = screen.getByLabelText('admin.storages.fieldProvider') as HTMLInputElement
|
||||
@@ -141,12 +144,16 @@ describe('StorageFormDrawer', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateStorage).toHaveBeenCalledWith(
|
||||
expect(replaceStorage).toHaveBeenCalledWith(
|
||||
'storage-1',
|
||||
expect.not.objectContaining({ capacity: expect.any(Number) }),
|
||||
expect.objectContaining({
|
||||
provider: 'custom-s3',
|
||||
capacity: storage.capacity,
|
||||
egressCreditPerUnit: storage.egressCreditPerUnit,
|
||||
enabled: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(updateStorage).not.toHaveBeenCalledWith('storage-1', expect.objectContaining({ egressCreditPerUnit: 3 }))
|
||||
})
|
||||
|
||||
it('keeps the provider input empty when editing storage without a provider value', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { AdminFormDrawer, AdminFormField, AdminFormLabel } from '@/components/ad
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { createStorage, updateStorage } from '@/lib/api'
|
||||
import { createStorage, replaceStorage } from '@/lib/api'
|
||||
import { eplistEndpointUrl, findEplistProvider, listEplistEndpoints, listEplistProviders } from '@/lib/eplist'
|
||||
|
||||
const storageFormSchema = z.object({
|
||||
@@ -44,9 +44,10 @@ interface StorageFormDrawerProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
storage: Storage | null
|
||||
onCreated?: (storage: Storage) => void
|
||||
}
|
||||
|
||||
export function StorageFormDrawer({ open, onOpenChange, storage }: StorageFormDrawerProps) {
|
||||
export function StorageFormDrawer({ open, onOpenChange, storage, onCreated }: StorageFormDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [showSecret, setShowSecret] = useState(false)
|
||||
@@ -82,10 +83,21 @@ export function StorageFormDrawer({ open, onOpenChange, storage }: StorageFormDr
|
||||
}, [open, storage, form])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: StorageFormValues) => (isEditing ? updateStorage(storage.id, values) : createStorage(values)),
|
||||
onSuccess: () => {
|
||||
mutationFn: (values: StorageFormValues) =>
|
||||
isEditing
|
||||
? replaceStorage(storage.id, {
|
||||
...values,
|
||||
capacity: storage.capacity,
|
||||
egressCreditBillingEnabled: storage.egressCreditBillingEnabled,
|
||||
egressCreditUnitBytes: storage.egressCreditUnitBytes,
|
||||
egressCreditPerUnit: storage.egressCreditPerUnit,
|
||||
enabled: storage.enabled,
|
||||
})
|
||||
: createStorage(values),
|
||||
onSuccess: (savedStorage) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'storages'] })
|
||||
onOpenChange(false)
|
||||
if (!isEditing) onCreated?.(savedStorage)
|
||||
toast.success(isEditing ? t('admin.storages.updated') : t('admin.storages.created'))
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
@@ -25,10 +25,52 @@ const ADMIN_STORAGES_KEYS = [
|
||||
'admin.storages.colStatus',
|
||||
'admin.storages.colHealth',
|
||||
'admin.storages.colActions',
|
||||
'admin.storages.statusActive',
|
||||
'admin.storages.statusInactive',
|
||||
'admin.storages.healthUntested',
|
||||
'admin.storages.healthTesting',
|
||||
'admin.storages.healthSaveFailed',
|
||||
'admin.storages.enableAction',
|
||||
'admin.storages.disableAction',
|
||||
'admin.storages.enableSuccess',
|
||||
'admin.storages.disableSuccess',
|
||||
'admin.storages.cardActions',
|
||||
'admin.storages.manageAction',
|
||||
'admin.storages.available',
|
||||
'admin.storages.unbounded',
|
||||
'admin.storages.usedLabel',
|
||||
'admin.storages.capacityUnbounded',
|
||||
'admin.storages.capacityAria',
|
||||
'admin.storages.lastChecked',
|
||||
'admin.storages.neverChecked',
|
||||
'admin.storages.statusReason.cors',
|
||||
'admin.storages.statusReason.authentication_failed',
|
||||
'admin.storages.statusReason.permission_denied',
|
||||
'admin.storages.statusReason.bucket_not_found',
|
||||
'admin.storages.statusReason.network_error',
|
||||
'admin.storages.statusReason.unknown',
|
||||
'admin.storages.noMatches',
|
||||
'admin.storages.searchPlaceholder',
|
||||
'admin.storages.filter.all',
|
||||
'admin.storages.filter.healthy',
|
||||
'admin.storages.filter.attention',
|
||||
'admin.storages.filter.failed',
|
||||
'admin.storages.filter.disabled',
|
||||
'admin.storages.sort.default',
|
||||
'admin.storages.sort.usage',
|
||||
'admin.storages.sort.used',
|
||||
'admin.storages.sort.bucket',
|
||||
'admin.storages.cardStatus.healthy',
|
||||
'admin.storages.cardStatus.attention',
|
||||
'admin.storages.cardStatus.failed',
|
||||
'admin.storages.cardStatus.disabled',
|
||||
'admin.storages.cardStatus.testing',
|
||||
'admin.storages.overview.backends',
|
||||
'admin.storages.overview.enabled',
|
||||
'admin.storages.overview.capacity',
|
||||
'admin.storages.overview.bounded',
|
||||
'admin.storages.overview.used',
|
||||
'admin.storages.overview.usage',
|
||||
'admin.storages.overview.health',
|
||||
'admin.storages.overview.healthy',
|
||||
'admin.storages.testAction',
|
||||
'admin.storages.testDialogTitle',
|
||||
'admin.storages.testStepCreate',
|
||||
@@ -66,7 +108,7 @@ const ADMIN_STORAGES_KEYS = [
|
||||
'admin.storages.capacityPlaceholder',
|
||||
'admin.storages.capacityHint',
|
||||
'admin.storages.egressBilling',
|
||||
'admin.storages.configureEgressBilling',
|
||||
'admin.storages.capacityBilling',
|
||||
'admin.storages.billingTitle',
|
||||
'admin.storages.billingDescription',
|
||||
'admin.storages.egressBillingHint',
|
||||
@@ -93,6 +135,14 @@ const INTERPOLATED_KEYS: Record<string, string[]> = {
|
||||
'admin.storages.testCleanupFailed': ['{{detail}}'],
|
||||
'admin.storages.billingDescription': ['{{bucket}}'],
|
||||
'admin.storages.egressBillingRate': ['{{credits}}', '{{unit}}'],
|
||||
'admin.storages.enableSuccess': ['{{bucket}}'],
|
||||
'admin.storages.disableSuccess': ['{{bucket}}'],
|
||||
'admin.storages.cardActions': ['{{bucket}}'],
|
||||
'admin.storages.capacityAria': ['{{percent}}', '{{used}}'],
|
||||
'admin.storages.lastChecked': ['{{value}}'],
|
||||
'admin.storages.overview.enabled': ['{{count}}'],
|
||||
'admin.storages.overview.bounded': ['{{count}}'],
|
||||
'admin.storages.overview.usage': ['{{percent}}'],
|
||||
}
|
||||
|
||||
describe('admin.storages locale keys — presence', () => {
|
||||
@@ -170,12 +220,12 @@ describe('admin.storages locale keys — English values contract', () => {
|
||||
expect(enLocale['admin.storages.noStorages']).toBe('No storages configured')
|
||||
})
|
||||
|
||||
it('admin.storages.statusActive is "Active"', () => {
|
||||
expect(enLocale['admin.storages.statusActive']).toBe('Active')
|
||||
it('admin.storages.enableAction is "Enable and test"', () => {
|
||||
expect(enLocale['admin.storages.enableAction']).toBe('Enable and test')
|
||||
})
|
||||
|
||||
it('admin.storages.statusInactive is "Inactive"', () => {
|
||||
expect(enLocale['admin.storages.statusInactive']).toBe('Inactive')
|
||||
it('admin.storages.cardStatus.healthy is "Running normally"', () => {
|
||||
expect(enLocale['admin.storages.cardStatus.healthy']).toBe('Running normally')
|
||||
})
|
||||
|
||||
it('admin.storages.colHealth is "Connection health"', () => {
|
||||
@@ -254,16 +304,16 @@ describe('admin.storages locale keys — i18n runtime translation', () => {
|
||||
expect(i18n.t('admin.storages.add')).toBe('添加存储')
|
||||
})
|
||||
|
||||
it('translates admin.storages.statusActive to Chinese', async () => {
|
||||
it('translates admin.storages.enableAction to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.storages.statusActive')).toBe('正常')
|
||||
expect(i18n.t('admin.storages.enableAction')).toBe('启用并检查')
|
||||
})
|
||||
|
||||
it('translates admin.storages.statusInactive to Chinese', async () => {
|
||||
it('translates admin.storages.cardStatus.healthy to Chinese', async () => {
|
||||
const { default: i18n } = await import('./index')
|
||||
await i18n.changeLanguage('zh')
|
||||
expect(i18n.t('admin.storages.statusInactive')).toBe('未启用')
|
||||
expect(i18n.t('admin.storages.cardStatus.healthy')).toBe('运行正常')
|
||||
})
|
||||
|
||||
it('translates admin.storages.created to Chinese', async () => {
|
||||
|
||||
@@ -981,10 +981,52 @@
|
||||
"admin.storages.colStatus": "Status",
|
||||
"admin.storages.colHealth": "Connection health",
|
||||
"admin.storages.colActions": "Actions",
|
||||
"admin.storages.statusActive": "Active",
|
||||
"admin.storages.statusInactive": "Inactive",
|
||||
"admin.storages.healthUntested": "Not tested",
|
||||
"admin.storages.healthTesting": "Testing...",
|
||||
"admin.storages.healthSaveFailed": "The connection check completed, but its status could not be saved.",
|
||||
"admin.storages.enableAction": "Enable and test",
|
||||
"admin.storages.disableAction": "Stop using",
|
||||
"admin.storages.enableSuccess": "Storage '{{bucket}}' enabled.",
|
||||
"admin.storages.disableSuccess": "Storage '{{bucket}}' stopped.",
|
||||
"admin.storages.cardActions": "Actions for storage {{bucket}}",
|
||||
"admin.storages.manageAction": "Manage storage",
|
||||
"admin.storages.available": "Available",
|
||||
"admin.storages.unbounded": "Unbounded",
|
||||
"admin.storages.usedLabel": "Used",
|
||||
"admin.storages.capacityUnbounded": "Capacity is not configured",
|
||||
"admin.storages.capacityAria": "{{percent}}% used, {{used}} stored",
|
||||
"admin.storages.lastChecked": "Checked {{value}}",
|
||||
"admin.storages.neverChecked": "Not checked yet",
|
||||
"admin.storages.statusReason.cors": "CORS blocked",
|
||||
"admin.storages.statusReason.authentication_failed": "Authentication failed",
|
||||
"admin.storages.statusReason.permission_denied": "Permission denied",
|
||||
"admin.storages.statusReason.bucket_not_found": "Bucket not found",
|
||||
"admin.storages.statusReason.network_error": "Network error",
|
||||
"admin.storages.statusReason.unknown": "Unknown error",
|
||||
"admin.storages.noMatches": "No storage backends match these filters.",
|
||||
"admin.storages.searchPlaceholder": "Search bucket, endpoint, region, or provider",
|
||||
"admin.storages.filter.all": "All",
|
||||
"admin.storages.filter.healthy": "Healthy",
|
||||
"admin.storages.filter.attention": "Needs attention",
|
||||
"admin.storages.filter.failed": "Failed",
|
||||
"admin.storages.filter.disabled": "Stopped",
|
||||
"admin.storages.sort.default": "Default order",
|
||||
"admin.storages.sort.usage": "Usage",
|
||||
"admin.storages.sort.used": "Used space",
|
||||
"admin.storages.sort.bucket": "Bucket",
|
||||
"admin.storages.cardStatus.healthy": "Running normally",
|
||||
"admin.storages.cardStatus.attention": "Needs attention",
|
||||
"admin.storages.cardStatus.failed": "Connection failed",
|
||||
"admin.storages.cardStatus.disabled": "Stopped",
|
||||
"admin.storages.cardStatus.testing": "Checking",
|
||||
"admin.storages.overview.backends": "Storage backends",
|
||||
"admin.storages.overview.enabled": "{{count}} enabled",
|
||||
"admin.storages.overview.capacity": "Total capacity",
|
||||
"admin.storages.overview.bounded": "{{count}} capacity-limited",
|
||||
"admin.storages.overview.used": "Used space",
|
||||
"admin.storages.overview.usage": "{{percent}}% of capacity",
|
||||
"admin.storages.overview.health": "Connection health",
|
||||
"admin.storages.overview.healthy": "Healthy among enabled",
|
||||
"admin.storages.testAction": "Test connection",
|
||||
"admin.storages.testDialogTitle": "Storage connection test",
|
||||
"admin.storages.testStepCreate": "Create temporary upload",
|
||||
@@ -1032,7 +1074,7 @@
|
||||
"admin.storages.capacityPlaceholder": "0",
|
||||
"admin.storages.capacityHint": "Maximum reported storage space. 0 means capacity is not reported.",
|
||||
"admin.storages.egressBilling": "Traffic Credits billing",
|
||||
"admin.storages.configureEgressBilling": "Configure egress billing",
|
||||
"admin.storages.capacityBilling": "Capacity billing",
|
||||
"admin.storages.billingTitle": "Limits and billing",
|
||||
"admin.storages.billingDescription": "Bucket: {{bucket}}",
|
||||
"admin.storages.egressBillingHint": "Charge workspace Credits for download traffic from this storage.",
|
||||
|
||||
@@ -981,10 +981,52 @@
|
||||
"admin.storages.colStatus": "状态",
|
||||
"admin.storages.colHealth": "连接健康",
|
||||
"admin.storages.colActions": "操作",
|
||||
"admin.storages.statusActive": "正常",
|
||||
"admin.storages.statusInactive": "未启用",
|
||||
"admin.storages.healthUntested": "未测试",
|
||||
"admin.storages.healthTesting": "测试中...",
|
||||
"admin.storages.healthSaveFailed": "连接检查已完成,但状态保存失败。",
|
||||
"admin.storages.enableAction": "启用并检查",
|
||||
"admin.storages.disableAction": "停止使用",
|
||||
"admin.storages.enableSuccess": "存储“{{bucket}}”已启用。",
|
||||
"admin.storages.disableSuccess": "存储“{{bucket}}”已停止使用。",
|
||||
"admin.storages.cardActions": "存储 {{bucket}} 的操作",
|
||||
"admin.storages.manageAction": "管理存储",
|
||||
"admin.storages.available": "可用空间",
|
||||
"admin.storages.unbounded": "未设上限",
|
||||
"admin.storages.usedLabel": "已使用",
|
||||
"admin.storages.capacityUnbounded": "未配置容量上限",
|
||||
"admin.storages.capacityAria": "已使用 {{percent}}%,当前占用 {{used}}",
|
||||
"admin.storages.lastChecked": "检查于 {{value}}",
|
||||
"admin.storages.neverChecked": "尚未检查",
|
||||
"admin.storages.statusReason.cors": "CORS 拦截",
|
||||
"admin.storages.statusReason.authentication_failed": "认证失败",
|
||||
"admin.storages.statusReason.permission_denied": "权限不足",
|
||||
"admin.storages.statusReason.bucket_not_found": "Bucket 不存在",
|
||||
"admin.storages.statusReason.network_error": "网络错误",
|
||||
"admin.storages.statusReason.unknown": "未知错误",
|
||||
"admin.storages.noMatches": "没有符合当前条件的存储后端。",
|
||||
"admin.storages.searchPlaceholder": "搜索 Bucket、端点、区域或服务商",
|
||||
"admin.storages.filter.all": "全部",
|
||||
"admin.storages.filter.healthy": "正常",
|
||||
"admin.storages.filter.attention": "需关注",
|
||||
"admin.storages.filter.failed": "异常",
|
||||
"admin.storages.filter.disabled": "已停用",
|
||||
"admin.storages.sort.default": "默认排序",
|
||||
"admin.storages.sort.usage": "使用率",
|
||||
"admin.storages.sort.used": "已用空间",
|
||||
"admin.storages.sort.bucket": "Bucket",
|
||||
"admin.storages.cardStatus.healthy": "运行正常",
|
||||
"admin.storages.cardStatus.attention": "需要关注",
|
||||
"admin.storages.cardStatus.failed": "连接异常",
|
||||
"admin.storages.cardStatus.disabled": "已停用",
|
||||
"admin.storages.cardStatus.testing": "检查中",
|
||||
"admin.storages.overview.backends": "存储后端",
|
||||
"admin.storages.overview.enabled": "{{count}} 个已启用",
|
||||
"admin.storages.overview.capacity": "总容量",
|
||||
"admin.storages.overview.bounded": "{{count}} 个配置了容量",
|
||||
"admin.storages.overview.used": "已用空间",
|
||||
"admin.storages.overview.usage": "占总容量 {{percent}}%",
|
||||
"admin.storages.overview.health": "连接健康",
|
||||
"admin.storages.overview.healthy": "已启用存储中的健康数",
|
||||
"admin.storages.testAction": "测试连接",
|
||||
"admin.storages.testDialogTitle": "存储连接测试",
|
||||
"admin.storages.testStepCreate": "创建临时上传对象",
|
||||
@@ -1032,7 +1074,7 @@
|
||||
"admin.storages.capacityPlaceholder": "0",
|
||||
"admin.storages.capacityHint": "存储后端报告的最大空间,0 表示未报告容量。",
|
||||
"admin.storages.egressBilling": "流量 Credits 计费",
|
||||
"admin.storages.configureEgressBilling": "配置流量计费",
|
||||
"admin.storages.capacityBilling": "容量计费",
|
||||
"admin.storages.billingTitle": "容量与计费",
|
||||
"admin.storages.billingDescription": "存储桶:{{bucket}}",
|
||||
"admin.storages.egressBillingHint": "对此存储产生的下载流量扣除工作区 Credits。",
|
||||
|
||||
+47
-12
@@ -104,11 +104,13 @@ import {
|
||||
listUserEntitlements,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
patchStorage,
|
||||
pollPairing,
|
||||
presignObjectUploadParts,
|
||||
purgeTrashObject,
|
||||
redeemCloudGiftCard,
|
||||
refreshLicense,
|
||||
replaceStorage,
|
||||
resendSiteInvitation,
|
||||
resetBrandingField,
|
||||
restoreObject,
|
||||
@@ -140,7 +142,6 @@ import {
|
||||
updateSiteQuotas,
|
||||
updateSiteRegistration,
|
||||
updateSiteWebDav,
|
||||
updateStorage,
|
||||
updateStorageEgressBilling,
|
||||
updateUserEntitlement,
|
||||
uploadAvatar,
|
||||
@@ -1573,29 +1574,63 @@ describe('api', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateStorage', () => {
|
||||
it('puts updated storage data and returns updated storage', async () => {
|
||||
describe('replaceStorage', () => {
|
||||
const replacement = {
|
||||
provider: 'custom-s3',
|
||||
bucket: 'updated-files',
|
||||
endpoint: 'https://s3.example.com',
|
||||
region: 'auto',
|
||||
accessKey: 'access-key',
|
||||
secretKey: 'secret-key',
|
||||
customHost: '',
|
||||
capacity: 0,
|
||||
forcePathStyle: false,
|
||||
egressCreditBillingEnabled: false,
|
||||
egressCreditUnitBytes: 104857600,
|
||||
egressCreditPerUnit: 1,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
it('puts a complete storage replacement and returns the storage', async () => {
|
||||
const storage = { id: 's1', bucket: 'updated-files' }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(storage))
|
||||
|
||||
const result = await updateStorage('s1', {
|
||||
provider: 'custom-s3',
|
||||
bucket: 'updated-files',
|
||||
forcePathStyle: false,
|
||||
})
|
||||
const result = await replaceStorage('s1', replacement)
|
||||
|
||||
expect(result).toEqual(storage)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/site/storages/s1')
|
||||
expect(init.method).toBe('PUT')
|
||||
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
|
||||
expect(body).toMatchObject({ provider: 'custom-s3', bucket: 'updated-files', forcePathStyle: false })
|
||||
expect(init.body).toBe(JSON.stringify(replacement))
|
||||
})
|
||||
|
||||
it('throws on error response', async () => {
|
||||
it('throws ApiError on error response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
||||
|
||||
await expect(updateStorage('s1', { bucket: 'x' })).rejects.toThrow('forbidden')
|
||||
await expect(replaceStorage('s1', replacement)).rejects.toBeInstanceOf(ApiError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('patchStorage', () => {
|
||||
it('patches partial storage state and returns the storage', async () => {
|
||||
const storage = { id: 's1', enabled: false }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(storage))
|
||||
|
||||
const result = await patchStorage('s1', { enabled: false })
|
||||
|
||||
expect(result).toEqual(storage)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/site/storages/s1')
|
||||
expect(init.method).toBe('PATCH')
|
||||
expect(init.body).toBe(JSON.stringify({ enabled: false }))
|
||||
})
|
||||
|
||||
it('throws ApiError on error response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
||||
|
||||
await expect(
|
||||
patchStorage('missing', { status: 'unhealthy', statusReason: 'network_error' }),
|
||||
).rejects.toBeInstanceOf(ApiError)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+7
-2
@@ -14,8 +14,10 @@ import type {
|
||||
DiscountQuote,
|
||||
DownloaderHeartbeatInput,
|
||||
DownloadTaskActionInput,
|
||||
PatchStorageInput,
|
||||
PresignObjectUploadPartsInput,
|
||||
RedeemGiftCardResponse,
|
||||
ReplaceStorageInput,
|
||||
SiteConfig,
|
||||
SiteSettings,
|
||||
UpdateDownloaderCreditBillingInput,
|
||||
@@ -27,7 +29,6 @@ import type {
|
||||
UpdateSiteRegistrationInput,
|
||||
UpdateSiteWebDavInput,
|
||||
UpdateStorageEgressBillingInput,
|
||||
UpdateStorageInput,
|
||||
} from '@shared/schemas'
|
||||
import type {
|
||||
AdminAuditEvent,
|
||||
@@ -556,10 +557,14 @@ export function getStorage(id: string) {
|
||||
return unwrap<Storage>(storages[':id'].$get({ param: { id } }))
|
||||
}
|
||||
|
||||
export function updateStorage(id: string, data: UpdateStorageInput) {
|
||||
export function replaceStorage(id: string, data: ReplaceStorageInput) {
|
||||
return unwrap<Storage>(storages[':id'].$put({ param: { id }, json: data }))
|
||||
}
|
||||
|
||||
export function patchStorage(id: string, data: PatchStorageInput) {
|
||||
return unwrap<Storage>(storages[':id'].$patch({ param: { id }, json: data }))
|
||||
}
|
||||
|
||||
export function updateStorageEgressBilling(id: string, data: UpdateStorageEgressBillingInput) {
|
||||
return unwrap<Storage>(storages[':id']['egress-billing'].$put({ param: { id }, json: data }))
|
||||
}
|
||||
|
||||
@@ -80,7 +80,8 @@ const overview: AdminOverview = {
|
||||
id: 'storage-1',
|
||||
provider: 'aws-s3',
|
||||
bucket: 'files',
|
||||
status: 'active',
|
||||
enabled: true,
|
||||
status: 'healthy',
|
||||
used: 400,
|
||||
capacity: 1000,
|
||||
writable: true,
|
||||
|
||||
@@ -559,7 +559,7 @@ function StorageBackendsCard({ overview }: { overview: AdminOverview }) {
|
||||
|
||||
function StorageBackendRow({ storage }: { storage: AdminOverviewStorage }) {
|
||||
const { t } = useTranslation()
|
||||
const status = storage.writable ? 'writable' : storage.status === 'active' ? 'full' : 'disabled'
|
||||
const status = storage.writable ? 'writable' : storage.enabled ? 'full' : 'disabled'
|
||||
const usage = percent(storage.used, storage.capacity)
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ObjectStatus, StorageStatus } from '@shared/constants'
|
||||
import { ObjectStatus, StorageStatus, StorageStatusReason } from '@shared/constants'
|
||||
import type { Storage } from '@shared/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type CreateObjectResult,
|
||||
createObject,
|
||||
listStorages,
|
||||
updateStorage,
|
||||
patchStorage,
|
||||
updateStorageEgressBilling,
|
||||
} from '@/lib/api'
|
||||
import { corsJsonForOrigin, StoragesPage } from './index'
|
||||
@@ -43,10 +43,11 @@ vi.mock('@/hooks/useEntitlement', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
ApiError: class ApiError extends Error {},
|
||||
abortObjectUpload: vi.fn(),
|
||||
createObject: vi.fn(),
|
||||
listStorages: vi.fn(),
|
||||
updateStorage: vi.fn(),
|
||||
patchStorage: vi.fn(),
|
||||
updateStorageEgressBilling: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -72,7 +73,10 @@ const storage: Storage = {
|
||||
egressCreditUnitBytes: 1073741824,
|
||||
egressCreditPerUnit: 1,
|
||||
used: 0,
|
||||
status: StorageStatus.ACTIVE,
|
||||
enabled: true,
|
||||
status: StorageStatus.HEALTHY,
|
||||
statusReason: null,
|
||||
statusCheckedAt: '2026-01-01T00:00:00.000Z',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
@@ -141,13 +145,56 @@ describe('admin storages CORS guidance', () => {
|
||||
})
|
||||
|
||||
describe('StoragesPage connection test action', () => {
|
||||
it('shows the storage access key in the list', async () => {
|
||||
it('shows concise storage details without exposing credentials', async () => {
|
||||
vi.mocked(listStorages).mockResolvedValue({ items: [storage], total: 1 })
|
||||
|
||||
const view = renderStoragesPage()
|
||||
|
||||
expect(await view.findByText('access-key')).toBeTruthy()
|
||||
expect(await view.findByText('bucket')).toBeTruthy()
|
||||
expect(await view.findByText('Amazon S3')).toBeTruthy()
|
||||
expect(screen.queryByText('access-key')).toBeNull()
|
||||
})
|
||||
|
||||
it('colors capacity independently from connection health', async () => {
|
||||
vi.mocked(listStorages).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
...storage,
|
||||
capacity: 100,
|
||||
used: 95,
|
||||
status: StorageStatus.UNHEALTHY,
|
||||
statusReason: StorageStatusReason.NETWORK_ERROR,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
const view = renderStoragesPage()
|
||||
|
||||
expect((await view.findByTestId('storage-usage-ring')).classList.contains('text-amber-500')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows used and total capacity on one line with a shared unit', async () => {
|
||||
vi.mocked(listStorages).mockResolvedValue({
|
||||
items: [{ ...storage, capacity: 500 * 1024 ** 2, used: 302.4 * 1024 ** 2 }],
|
||||
total: 1,
|
||||
})
|
||||
|
||||
const view = renderStoragesPage()
|
||||
const detail = await view.findByTestId('storage-usage-detail')
|
||||
|
||||
expect(detail.textContent).toBe('302.4 / 500 MB')
|
||||
expect(detail.classList.contains('whitespace-nowrap')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps editing out of the overflow menu and exposes capacity billing', async () => {
|
||||
vi.mocked(listStorages).mockResolvedValue({ items: [storage], total: 1 })
|
||||
|
||||
const view = renderStoragesPage()
|
||||
fireEvent.pointerDown(await view.findByRole('button', { name: 'admin.storages.cardActions' }), { button: 0 })
|
||||
|
||||
expect(await view.findByRole('menuitem', { name: 'admin.storages.capacityBilling' })).toBeTruthy()
|
||||
expect(screen.queryByRole('menuitem', { name: 'common.edit' })).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the provider cell empty when storage has no provider value', async () => {
|
||||
@@ -155,7 +202,7 @@ describe('StoragesPage connection test action', () => {
|
||||
|
||||
const view = renderStoragesPage()
|
||||
|
||||
expect(await view.findByText('access-key')).toBeTruthy()
|
||||
expect(await view.findByText('bucket')).toBeTruthy()
|
||||
expect(screen.queryByText('admin.storages.providerCustom')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -163,11 +210,13 @@ describe('StoragesPage connection test action', () => {
|
||||
vi.mocked(listStorages).mockResolvedValue({ items: [storage], total: 1 })
|
||||
vi.mocked(createObject).mockResolvedValue(uploadDraft)
|
||||
vi.mocked(abortObjectUpload).mockResolvedValue(undefined)
|
||||
vi.mocked(patchStorage).mockResolvedValue(storage)
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 200 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const view = renderStoragesPage()
|
||||
fireEvent.click(await view.findByTitle('admin.storages.testAction'))
|
||||
fireEvent.pointerDown(await view.findByRole('button', { name: 'admin.storages.cardActions' }), { button: 0 })
|
||||
fireEvent.click(await view.findByRole('menuitem', { name: 'admin.storages.testAction' }))
|
||||
|
||||
await view.findByText('admin.storages.testDialogTitle')
|
||||
expect(screen.getByText('admin.storages.testStepCreate')).toBeTruthy()
|
||||
@@ -194,16 +243,19 @@ describe('StoragesPage connection test action', () => {
|
||||
)
|
||||
await view.findByText('admin.storages.testSuccess')
|
||||
expect(abortObjectUpload).toHaveBeenCalledWith('object-1', 'session-1', { strictStorageCleanup: true })
|
||||
expect(patchStorage).toHaveBeenCalledWith('storage-1', { status: 'healthy', statusReason: null })
|
||||
})
|
||||
|
||||
it('renders current-origin CORS guidance when the browser cannot reach the presigned URL', async () => {
|
||||
vi.mocked(listStorages).mockResolvedValue({ items: [storage], total: 1 })
|
||||
vi.mocked(createObject).mockResolvedValue(uploadDraft)
|
||||
vi.mocked(abortObjectUpload).mockResolvedValue(undefined)
|
||||
vi.mocked(patchStorage).mockResolvedValue(storage)
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
|
||||
const view = renderStoragesPage()
|
||||
fireEvent.click(await view.findByTitle('admin.storages.testAction'))
|
||||
fireEvent.pointerDown(await view.findByRole('button', { name: 'admin.storages.cardActions' }), { button: 0 })
|
||||
fireEvent.click(await view.findByRole('menuitem', { name: 'admin.storages.testAction' }))
|
||||
|
||||
await view.findByText('admin.storages.testCorsFailure')
|
||||
expect(screen.getByTestId('storage-test-step-creating').dataset.state).toBe('done')
|
||||
@@ -220,20 +272,50 @@ describe('StoragesPage connection test action', () => {
|
||||
expect(abortObjectUpload).toHaveBeenCalledWith('object-1', 'session-1', { strictStorageCleanup: true })
|
||||
})
|
||||
|
||||
it('runs a health check after enabling a storage and keeps it enabled when the check fails', async () => {
|
||||
const disabled = {
|
||||
...storage,
|
||||
enabled: false,
|
||||
status: StorageStatus.UNHEALTHY,
|
||||
statusReason: StorageStatusReason.UNKNOWN,
|
||||
}
|
||||
vi.mocked(listStorages).mockResolvedValue({ items: [disabled], total: 1 })
|
||||
vi.mocked(patchStorage).mockImplementation(async (_id, input) => ({
|
||||
...disabled,
|
||||
enabled: input.enabled ?? true,
|
||||
status: input.status ?? disabled.status,
|
||||
}))
|
||||
vi.mocked(createObject).mockRejectedValue(new Error('connection failed'))
|
||||
|
||||
const view = renderStoragesPage()
|
||||
fireEvent.pointerDown(await view.findByRole('button', { name: 'admin.storages.cardActions' }), { button: 0 })
|
||||
fireEvent.click(await view.findByRole('menuitem', { name: 'admin.storages.enableAction' }))
|
||||
|
||||
await waitFor(() => expect(patchStorage).toHaveBeenCalledWith('storage-1', { enabled: true }))
|
||||
await waitFor(() => expect(createObject).toHaveBeenCalled())
|
||||
await waitFor(() =>
|
||||
expect(patchStorage).toHaveBeenCalledWith('storage-1', {
|
||||
status: 'unhealthy',
|
||||
statusReason: 'unknown',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('opens egress billing from the row action and saves through the dedicated wrapper', async () => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
vi.mocked(listStorages).mockResolvedValue({ items: [{ ...storage, egressCreditBillingEnabled: true }], total: 1 })
|
||||
vi.mocked(updateStorage).mockResolvedValue(storage)
|
||||
vi.mocked(patchStorage).mockResolvedValue(storage)
|
||||
vi.mocked(updateStorageEgressBilling).mockResolvedValue(storage)
|
||||
|
||||
const view = renderStoragesPage()
|
||||
fireEvent.click(await view.findByTitle('admin.storages.configureEgressBilling'))
|
||||
fireEvent.pointerDown(await view.findByRole('button', { name: 'admin.storages.cardActions' }), { button: 0 })
|
||||
fireEvent.click(await view.findByRole('menuitem', { name: 'admin.storages.capacityBilling' }))
|
||||
await view.findByText('admin.storages.billingTitle')
|
||||
fireEvent.change(screen.getByLabelText('admin.storages.fieldCapacity'), { target: { value: '2' } })
|
||||
fireEvent.change(screen.getByLabelText('admin.storages.egressBillingCredits'), { target: { value: '4' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
|
||||
|
||||
await waitFor(() => expect(updateStorage).toHaveBeenCalledWith('storage-1', { capacity: 2 * 1024 * 1024 * 1024 }))
|
||||
await waitFor(() => expect(patchStorage).toHaveBeenCalledWith('storage-1', { capacity: 2 * 1024 * 1024 * 1024 }))
|
||||
await waitFor(() =>
|
||||
expect(updateStorageEgressBilling).toHaveBeenCalledWith('storage-1', {
|
||||
enabled: true,
|
||||
@@ -247,10 +329,11 @@ describe('StoragesPage connection test action', () => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
mockHasFeature.mockImplementation((feature) => feature !== 'quota_store')
|
||||
vi.mocked(listStorages).mockResolvedValue({ items: [{ ...storage, egressCreditBillingEnabled: true }], total: 1 })
|
||||
vi.mocked(updateStorage).mockResolvedValue(storage)
|
||||
vi.mocked(patchStorage).mockResolvedValue(storage)
|
||||
|
||||
const view = renderStoragesPage()
|
||||
fireEvent.click(await view.findByTitle('admin.storages.configureEgressBilling'))
|
||||
fireEvent.pointerDown(await view.findByRole('button', { name: 'admin.storages.cardActions' }), { button: 0 })
|
||||
fireEvent.click(await view.findByRole('menuitem', { name: 'admin.storages.capacityBilling' }))
|
||||
|
||||
await view.findByText('admin.storages.egressBillingBusinessOnly')
|
||||
expect(screen.getByLabelText('admin.storages.egressBillingUnit')).toHaveProperty('disabled', true)
|
||||
@@ -258,7 +341,7 @@ describe('StoragesPage connection test action', () => {
|
||||
fireEvent.change(screen.getByLabelText('admin.storages.fieldCapacity'), { target: { value: '3' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
|
||||
|
||||
await waitFor(() => expect(updateStorage).toHaveBeenCalledWith('storage-1', { capacity: 3 * 1024 * 1024 * 1024 }))
|
||||
await waitFor(() => expect(patchStorage).toHaveBeenCalledWith('storage-1', { capacity: 3 * 1024 * 1024 * 1024 }))
|
||||
expect(updateStorageEgressBilling).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { FREE_STORAGE_LIMIT, StorageStatus } from '@shared/constants'
|
||||
import { FREE_STORAGE_LIMIT, StorageStatus, StorageStatusReason } from '@shared/constants'
|
||||
import type { Storage } from '@shared/types'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Boxes,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
Ellipsis,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Pencil,
|
||||
MapPin,
|
||||
Plus,
|
||||
Power,
|
||||
Search,
|
||||
Settings2,
|
||||
TestTube2,
|
||||
Trash2,
|
||||
WifiOff,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { AdminFormDrawer, AdminFormLabel } from '@/components/admin/admin-form-drawer'
|
||||
@@ -31,6 +39,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
@@ -40,10 +55,10 @@ import {
|
||||
abortObjectUpload,
|
||||
createObject,
|
||||
listStorages,
|
||||
updateStorage,
|
||||
patchStorage,
|
||||
updateStorageEgressBilling,
|
||||
} from '@/lib/api'
|
||||
import { type EplistProvider, eplistProviderLabel, listEplistProviders } from '@/lib/eplist'
|
||||
import { eplistProviderLabel, listEplistProviders } from '@/lib/eplist'
|
||||
import { formatSize } from '@/lib/format'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/admin/storages/')({
|
||||
@@ -59,9 +74,12 @@ type StorageHealth =
|
||||
type StorageTestStep = 'creating' | 'uploading' | 'cleanup'
|
||||
type StorageTestPosition = StorageTestStep | 'done'
|
||||
type StorageTestStepState = 'done' | 'failed' | 'active' | 'pending'
|
||||
type StorageFilter = 'all' | 'healthy' | 'attention' | 'failed' | 'disabled'
|
||||
type StorageSort = 'default' | 'usage' | 'used' | 'bucket'
|
||||
|
||||
const TEST_CONTENT = 'zpan storage connection test\n'
|
||||
const DATA_UNITS = { MB: 1024 ** 2, GB: 1024 ** 3, TB: 1024 ** 4 } as const
|
||||
const DISPLAY_DATA_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const
|
||||
|
||||
type DataUnit = keyof typeof DATA_UNITS
|
||||
type BillingForm = {
|
||||
@@ -95,6 +113,20 @@ function readableError(error: unknown) {
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function formatCapacityPair(used: number, capacity: number) {
|
||||
const unitIndex = Math.min(
|
||||
DISPLAY_DATA_UNITS.length - 1,
|
||||
Math.max(0, Math.floor(Math.log(Math.max(capacity, 1)) / Math.log(1024))),
|
||||
)
|
||||
const divisor = 1024 ** unitIndex
|
||||
const usedValue = used / divisor
|
||||
const capacityValue = capacity / divisor
|
||||
const fractionDigits = unitIndex === 0 ? 0 : usedValue > 0 && usedValue < 0.1 ? 2 : 1
|
||||
const formatValue = (value: number) => value.toFixed(fractionDigits).replace(/\.0+$|(\.\d*[1-9])0+$/, '$1')
|
||||
|
||||
return `${formatValue(usedValue)} / ${formatValue(capacityValue)} ${DISPLAY_DATA_UNITS[unitIndex]}`
|
||||
}
|
||||
|
||||
export function StoragesPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -108,6 +140,9 @@ export function StoragesPage() {
|
||||
const [testHealth, setTestHealth] = useState<StorageHealth>({ status: 'idle' })
|
||||
const [testStep, setTestStep] = useState<StorageTestPosition>('done')
|
||||
const [testFailedStep, setTestFailedStep] = useState<StorageTestStep | null>(null)
|
||||
const [filter, setFilter] = useState<StorageFilter>('all')
|
||||
const [sort, setSort] = useState<StorageSort>('default')
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const storagesQuery = useQuery({
|
||||
queryKey: ['admin', 'storages'],
|
||||
@@ -123,10 +158,30 @@ export function StoragesPage() {
|
||||
const providers = providersQuery.data ?? []
|
||||
const storagesLimitReached = !hasFeature('storages_unlimited') && storages.length >= FREE_STORAGE_LIMIT
|
||||
const hasTrafficBilling = hasFeature('quota_store')
|
||||
const visibleStorages = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
const items = storages.filter((storage) => {
|
||||
const visualStatus = storageVisualStatus(storage)
|
||||
const matchesFilter = filter === 'all' || visualStatus === filter
|
||||
const providerLabel = eplistProviderLabel(providers, storage.provider)
|
||||
const matchesQuery =
|
||||
!normalizedQuery ||
|
||||
[storage.bucket, storage.endpoint, storage.region, providerLabel].some((value) =>
|
||||
value.toLowerCase().includes(normalizedQuery),
|
||||
)
|
||||
return matchesFilter && matchesQuery
|
||||
})
|
||||
return [...items].sort((left, right) => {
|
||||
if (sort === 'usage') return storageUsage(right) - storageUsage(left)
|
||||
if (sort === 'used') return right.used - left.used
|
||||
if (sort === 'bucket') return left.bucket.localeCompare(right.bucket)
|
||||
return 0
|
||||
})
|
||||
}, [filter, providers, query, sort, storages])
|
||||
|
||||
const billingMutation = useMutation({
|
||||
mutationFn: async ({ storage, form }: { storage: Storage; form: BillingForm }) => {
|
||||
await updateStorage(storage.id, { capacity: capacityPayload(form) })
|
||||
await patchStorage(storage.id, { capacity: capacityPayload(form) })
|
||||
if (hasTrafficBilling) {
|
||||
await updateStorageEgressBilling(storage.id, egressBillingPayload(form))
|
||||
}
|
||||
@@ -139,6 +194,20 @@ export function StoragesPage() {
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const enabledMutation = useMutation({
|
||||
mutationFn: ({ storage, enabled }: { storage: Storage; enabled: boolean }) => patchStorage(storage.id, { enabled }),
|
||||
onSuccess: (storage, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'storages'] })
|
||||
toast.success(
|
||||
t(variables.enabled ? 'admin.storages.enableSuccess' : 'admin.storages.disableSuccess', {
|
||||
bucket: storage.bucket,
|
||||
}),
|
||||
)
|
||||
if (variables.enabled) handleTest(storage)
|
||||
},
|
||||
onError: (error) => toast.error(readableError(error)),
|
||||
})
|
||||
|
||||
function handleEdit(storage: Storage) {
|
||||
setEditingStorage(storage)
|
||||
setFormOpen(true)
|
||||
@@ -233,6 +302,21 @@ export function StoragesPage() {
|
||||
}
|
||||
}
|
||||
const finalResult = result ?? { status: 'idle' as const }
|
||||
if (finalResult.status !== 'idle') {
|
||||
const status = finalResult.status === 'success' ? StorageStatus.HEALTHY : StorageStatus.UNHEALTHY
|
||||
const statusReason =
|
||||
finalResult.status === 'success'
|
||||
? null
|
||||
: finalResult.status === 'cors'
|
||||
? StorageStatusReason.CORS
|
||||
: StorageStatusReason.UNKNOWN
|
||||
try {
|
||||
await patchStorage(storage.id, { status, statusReason })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'storages'] })
|
||||
} catch {
|
||||
toast.error(t('admin.storages.healthSaveFailed'))
|
||||
}
|
||||
}
|
||||
setTestFailedStep(
|
||||
finalResult.status === 'success' || finalResult.status === 'idle' ? null : (failedStep ?? currentStep),
|
||||
)
|
||||
@@ -253,6 +337,7 @@ export function StoragesPage() {
|
||||
<div className="space-y-4">
|
||||
<AdminPageHeader
|
||||
title={t('admin.storages.title')}
|
||||
description={t('admin.storages.placeholder')}
|
||||
action={
|
||||
<Button size="sm" onClick={handleAddNew} disabled={storagesLimitReached}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
@@ -263,54 +348,80 @@ export function StoragesPage() {
|
||||
|
||||
{storagesLimitReached && <UpgradeHint feature="storages_unlimited" />}
|
||||
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">{t('admin.storages.colBucket')}</th>
|
||||
<th className="hidden px-4 py-3 text-left font-medium md:table-cell">
|
||||
{t('admin.storages.colProvider')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('admin.storages.colAccessKey')}</th>
|
||||
<th className="hidden max-w-48 px-4 py-3 text-left font-medium lg:table-cell">
|
||||
{t('admin.storages.colEndpoint')}
|
||||
</th>
|
||||
<th className="hidden px-4 py-3 text-left font-medium lg:table-cell">
|
||||
{t('admin.storages.colEgressBilling')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t('admin.storages.colStatus')}</th>
|
||||
<th className="px-4 py-3 text-right font-medium">{t('admin.storages.colActions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{storages.map((storage) => (
|
||||
<StorageTableRow
|
||||
key={storage.id}
|
||||
storage={storage}
|
||||
providers={providers}
|
||||
hasTrafficBilling={hasTrafficBilling}
|
||||
testing={testTarget?.id === storage.id && testHealth.status === 'testing'}
|
||||
onTest={() => handleTest(storage)}
|
||||
onEdit={() => handleEdit(storage)}
|
||||
onConfigureBilling={() => handleConfigureBilling(storage)}
|
||||
onDelete={() => setDeleteTarget({ id: storage.id, bucket: storage.bucket })}
|
||||
/>
|
||||
))}
|
||||
{storages.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-12 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Database className="h-10 w-10" />
|
||||
<p>{t('admin.storages.noStorages')}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<StorageOverview storages={storages} />
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex w-fit max-w-full flex-wrap gap-0.5 rounded-lg border bg-card p-1">
|
||||
{(['all', 'healthy', 'attention', 'failed', 'disabled'] as const).map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={filter === value ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 rounded-md px-2.5 text-[11px]"
|
||||
onClick={() => setFilter(value)}
|
||||
>
|
||||
{t(`admin.storages.filter.${value}`)}
|
||||
<span className="ml-1 rounded-full bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
{storageFilterCount(storages, value)}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative min-w-0 flex-1 sm:w-60">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('admin.storages.searchPlaceholder')}
|
||||
className="h-8 rounded-lg bg-card pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Select value={sort} onValueChange={(value) => setSort(value as StorageSort)}>
|
||||
<SelectTrigger className="h-8 w-36 rounded-lg bg-card text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(['default', 'usage', 'used', 'bucket'] as const).map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`admin.storages.sort.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StorageFormDrawer open={formOpen} onOpenChange={handleFormOpenChange} storage={editingStorage} />
|
||||
{visibleStorages.length > 0 ? (
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
{visibleStorages.map((storage) => (
|
||||
<StorageCard
|
||||
key={storage.id}
|
||||
storage={storage}
|
||||
providerLabel={eplistProviderLabel(providers, storage.provider)}
|
||||
testing={testTarget?.id === storage.id && testHealth.status === 'testing'}
|
||||
toggling={enabledMutation.isPending && enabledMutation.variables?.storage.id === storage.id}
|
||||
onTest={() => handleTest(storage)}
|
||||
onToggle={() => enabledMutation.mutate({ storage, enabled: !storage.enabled })}
|
||||
onEdit={() => handleEdit(storage)}
|
||||
onConfigureBilling={() => handleConfigureBilling(storage)}
|
||||
onDelete={() => setDeleteTarget({ id: storage.id, bucket: storage.bucket })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-56 flex-col items-center justify-center gap-3 rounded-lg border border-dashed text-muted-foreground">
|
||||
<Database className="size-10" />
|
||||
<p>{storages.length === 0 ? t('admin.storages.noStorages') : t('admin.storages.noMatches')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StorageFormDrawer
|
||||
open={formOpen}
|
||||
onOpenChange={handleFormOpenChange}
|
||||
storage={editingStorage}
|
||||
onCreated={handleTest}
|
||||
/>
|
||||
|
||||
<StorageEgressBillingDrawer
|
||||
storage={billingTarget}
|
||||
@@ -343,83 +454,330 @@ export function StoragesPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function StorageTableRow({
|
||||
function StorageOverview({ storages }: { storages: Storage[] }) {
|
||||
const { t } = useTranslation()
|
||||
const enabled = storages.filter((storage) => storage.enabled).length
|
||||
const bounded = storages.filter((storage) => storage.capacity > 0)
|
||||
const capacity = bounded.reduce((total, storage) => total + storage.capacity, 0)
|
||||
const used = storages.reduce((total, storage) => total + storage.used, 0)
|
||||
const healthy = storages.filter((storage) => storage.enabled && storage.status === StorageStatus.HEALTHY).length
|
||||
|
||||
const items = [
|
||||
{
|
||||
icon: Boxes,
|
||||
iconClassName: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
|
||||
label: t('admin.storages.overview.backends'),
|
||||
value: String(storages.length),
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
iconClassName: 'bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
label: t('admin.storages.overview.capacity'),
|
||||
value: formatSize(capacity),
|
||||
},
|
||||
{
|
||||
icon: HardDrive,
|
||||
iconClassName: 'bg-amber-500/10 text-amber-700 dark:text-amber-400',
|
||||
label: t('admin.storages.overview.used'),
|
||||
value: formatSize(used),
|
||||
detail:
|
||||
capacity > 0 ? t('admin.storages.overview.usage', { percent: Math.round((used / capacity) * 100) }) : undefined,
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
iconClassName: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400',
|
||||
label: t('admin.storages.overview.health'),
|
||||
value: `${healthy}/${enabled}`,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="grid overflow-hidden rounded-xl border bg-card shadow-xs sm:grid-cols-2 xl:grid-cols-4">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className="flex min-h-[76px] items-center gap-2.5 border-b px-3 py-2.5 last:border-b-0 sm:border-r sm:[&:nth-child(2)]:border-r-0 xl:border-b-0 xl:[&:nth-child(2)]:border-r"
|
||||
>
|
||||
<span className={`flex size-8 shrink-0 items-center justify-center rounded-lg ${item.iconClassName}`}>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] text-muted-foreground">{item.label}</p>
|
||||
<div className="flex min-w-0 items-baseline gap-1.5">
|
||||
<p className="shrink-0 text-base leading-5 font-semibold tracking-tight tabular-nums">{item.value}</p>
|
||||
{item.detail && <p className="truncate text-[10px] text-muted-foreground">{item.detail}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function storageUsage(storage: Storage) {
|
||||
if (storage.capacity <= 0) return -1
|
||||
return storage.used / storage.capacity
|
||||
}
|
||||
|
||||
function storageVisualStatus(storage: Storage): Exclude<StorageFilter, 'all'> {
|
||||
if (!storage.enabled) return 'disabled'
|
||||
if (storage.status === StorageStatus.UNHEALTHY) return 'failed'
|
||||
if (storage.status === StorageStatus.UNKNOWN || storageUsage(storage) >= 0.9) {
|
||||
return 'attention'
|
||||
}
|
||||
return 'healthy'
|
||||
}
|
||||
|
||||
function storageFilterCount(storages: Storage[], filter: StorageFilter) {
|
||||
if (filter === 'all') return storages.length
|
||||
return storages.filter((storage) => storageVisualStatus(storage) === filter).length
|
||||
}
|
||||
|
||||
function StorageCard({
|
||||
storage,
|
||||
providers,
|
||||
hasTrafficBilling,
|
||||
providerLabel,
|
||||
testing,
|
||||
toggling,
|
||||
onTest,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onConfigureBilling,
|
||||
onDelete,
|
||||
}: {
|
||||
storage: Storage
|
||||
providers: EplistProvider[]
|
||||
hasTrafficBilling: boolean
|
||||
providerLabel: string
|
||||
testing: boolean
|
||||
toggling: boolean
|
||||
onTest: () => void
|
||||
onToggle: () => void
|
||||
onEdit: () => void
|
||||
onConfigureBilling: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const isActive = storage.status === StorageStatus.ACTIVE
|
||||
const provider = storage.provider?.trim() ?? ''
|
||||
|
||||
const statusBadge = isActive ? 'bg-green-500/10 text-green-700 dark:text-green-400' : 'bg-muted text-muted-foreground'
|
||||
const usage = storageUsage(storage)
|
||||
const percent = usage < 0 ? null : Math.round(usage * 100)
|
||||
const visiblePercent = Math.min(100, Math.max(0, percent ?? 0))
|
||||
const available = storage.capacity > 0 ? Math.max(0, storage.capacity - storage.used) : null
|
||||
const visualStatus = storageVisualStatus(storage)
|
||||
const badgeStatus = testing ? 'testing' : visualStatus
|
||||
const ringClassName =
|
||||
!storage.enabled || percent === null
|
||||
? 'text-muted-foreground'
|
||||
: percent >= 90
|
||||
? 'text-amber-500'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
|
||||
return (
|
||||
<tr className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3 font-medium">{storage.bucket}</td>
|
||||
<td className="hidden max-w-44 truncate px-4 py-3 text-muted-foreground md:table-cell" title={provider}>
|
||||
{provider ? eplistProviderLabel(providers, provider) : ''}
|
||||
</td>
|
||||
<td className="max-w-44 truncate px-4 py-3 font-mono text-muted-foreground text-xs" title={storage.accessKey}>
|
||||
{storage.accessKey}
|
||||
</td>
|
||||
<td className="hidden max-w-48 truncate px-4 py-3 text-muted-foreground lg:table-cell">{storage.endpoint}</td>
|
||||
<td className="hidden px-4 py-3 text-muted-foreground lg:table-cell">
|
||||
{hasTrafficBilling && storage.egressCreditBillingEnabled
|
||||
? t('admin.storages.egressBillingRate', {
|
||||
credits: storage.egressCreditPerUnit,
|
||||
unit: formatSize(storage.egressCreditUnitBytes),
|
||||
})
|
||||
: t('admin.storages.egressBillingOff')}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${statusBadge}`}>
|
||||
{isActive ? t('admin.storages.statusActive') : t('admin.storages.statusInactive')}
|
||||
<article className="relative overflow-hidden rounded-[14px] border bg-card shadow-[0_1px_2px_rgba(15,23,42,0.03),0_6px_18px_rgba(15,23,42,0.04)]">
|
||||
<header className="flex min-h-[60px] items-center gap-2.5 border-b px-3.5 py-2.5">
|
||||
<span className="flex h-8 min-w-14 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-blue-500/10 px-2 text-blue-700 dark:text-blue-400">
|
||||
<Database className="size-4" />
|
||||
<b className="max-w-12 truncate text-[10px] tracking-wide uppercase">{storage.provider || 'S3'}</b>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onTest}
|
||||
title={t('admin.storages.testAction')}
|
||||
disabled={testing}
|
||||
>
|
||||
{testing ? <Loader2 className="animate-spin" /> : <TestTube2 />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" onClick={onEdit} title={t('common.edit')}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onConfigureBilling}
|
||||
title={t('admin.storages.configureEgressBilling')}
|
||||
>
|
||||
<Settings2 />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-xs" onClick={onDelete} title={t('common.delete')}>
|
||||
<Trash2 className="text-destructive" />
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-xs font-semibold">{providerLabel || storage.provider || 'S3'}</h3>
|
||||
<p className="mt-0.5 truncate font-mono text-[10px] text-muted-foreground" title={storage.endpoint}>
|
||||
{storage.endpoint}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<StorageStatusBadge status={badgeStatus} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
aria-label={t('admin.storages.cardActions', { bucket: storage.bucket })}
|
||||
>
|
||||
<Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={onToggle} disabled={toggling}>
|
||||
{toggling ? <Loader2 className="animate-spin" /> : <Power />}
|
||||
{t(storage.enabled ? 'admin.storages.disableAction' : 'admin.storages.enableAction')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onTest} disabled={!storage.enabled || testing}>
|
||||
{testing ? <Loader2 className="animate-spin" /> : <TestTube2 />}
|
||||
{t('admin.storages.testAction')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onConfigureBilling}>
|
||||
<Settings2 />
|
||||
{t('admin.storages.capacityBilling')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={onDelete}>
|
||||
<Trash2 />
|
||||
{t('common.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 items-center gap-4 px-4 py-3.5 sm:grid-cols-[140px_minmax(0,1fr)]">
|
||||
<StorageUsageRing
|
||||
className={ringClassName}
|
||||
percent={percent}
|
||||
visiblePercent={visiblePercent}
|
||||
used={storage.used}
|
||||
capacity={storage.capacity}
|
||||
ariaLabel={
|
||||
percent === null
|
||||
? t('admin.storages.capacityUnbounded')
|
||||
: t('admin.storages.capacityAria', { percent, used: formatSize(storage.used) })
|
||||
}
|
||||
/>
|
||||
|
||||
<dl className="grid min-w-0 grid-cols-2 gap-2">
|
||||
<div className="col-span-2 flex min-h-12 min-w-0 items-center gap-2 rounded-lg bg-muted/55 px-2.5 py-2">
|
||||
<Database className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[10px] text-muted-foreground">{t('admin.storages.colBucket')}</dt>
|
||||
<dd className="mt-0.5 truncate font-mono text-[11px] font-semibold" title={storage.bucket}>
|
||||
{storage.bucket}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-12 min-w-0 items-center gap-2 rounded-lg bg-muted/55 px-2.5 py-2">
|
||||
<HardDrive className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[10px] text-muted-foreground">{t('admin.storages.available')}</dt>
|
||||
<dd className="mt-0.5 truncate text-[11px] font-semibold">
|
||||
{available === null ? t('admin.storages.unbounded') : formatSize(available)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-12 min-w-0 items-center gap-2 rounded-lg bg-muted/55 px-2.5 py-2">
|
||||
<MapPin className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0">
|
||||
<dt className="text-[10px] text-muted-foreground">{t('admin.storages.fieldRegion')}</dt>
|
||||
<dd className="mt-0.5 truncate text-[11px] font-semibold" title={storage.region}>
|
||||
{storage.region}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<footer className="flex min-h-11 items-center justify-between border-t bg-muted/25 px-2.5 pl-3.5">
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
{testing ? (
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin" />
|
||||
) : storage.status === StorageStatus.UNHEALTHY ? (
|
||||
<WifiOff className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<Activity className="size-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">
|
||||
{storage.statusReason ? `${t(`admin.storages.statusReason.${storage.statusReason}`)} · ` : ''}
|
||||
{storage.statusCheckedAt
|
||||
? t('admin.storages.lastChecked', { value: new Date(storage.statusCheckedAt).toLocaleString() })
|
||||
: t('admin.storages.neverChecked')}
|
||||
</span>
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" className="h-8 text-xs text-primary" onClick={onEdit}>
|
||||
{t('admin.storages.manageAction')}
|
||||
<ArrowRight className="ml-1 size-3.5" />
|
||||
</Button>
|
||||
</footer>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function StorageUsageRing({
|
||||
className,
|
||||
percent,
|
||||
visiblePercent,
|
||||
used,
|
||||
capacity,
|
||||
ariaLabel,
|
||||
}: {
|
||||
className: string
|
||||
percent: number | null
|
||||
visiblePercent: number
|
||||
used: number
|
||||
capacity: number
|
||||
ariaLabel: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const radius = 53
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const dashOffset = circumference * (1 - visiblePercent / 100)
|
||||
const valueClassName = percent === null || Math.abs(percent) >= 1000 ? 'text-lg' : 'text-[22px]'
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="storage-usage-ring"
|
||||
className={`relative mx-auto size-[132px] shrink-0 ${className}`}
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<svg className="size-full -rotate-90" viewBox="0 0 132 132" aria-hidden="true">
|
||||
<circle cx="66" cy="66" r={radius} fill="none" stroke="currentColor" strokeWidth="15" className="text-muted" />
|
||||
<circle
|
||||
cx="66"
|
||||
cy="66"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="15"
|
||||
strokeLinecap="butt"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-[15px] flex flex-col items-center justify-center rounded-full bg-card text-center text-foreground">
|
||||
<strong
|
||||
className={`max-w-[96px] whitespace-nowrap leading-none font-semibold tracking-tight tabular-nums ${valueClassName}`}
|
||||
>
|
||||
{percent === null ? formatSize(used) : `${percent}%`}
|
||||
</strong>
|
||||
{percent === null ? (
|
||||
<span className="mt-1.5 whitespace-nowrap text-[9px] leading-none text-muted-foreground">
|
||||
{t('admin.storages.usedLabel')}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
data-testid="storage-usage-detail"
|
||||
className="mt-1.5 max-w-[96px] whitespace-nowrap text-[9px] leading-none text-muted-foreground tabular-nums"
|
||||
title={`${formatSize(used)} / ${formatSize(capacity)}`}
|
||||
>
|
||||
{formatCapacityPair(used, capacity)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StorageStatusBadge({ status }: { status: Exclude<StorageFilter, 'all'> | 'testing' }) {
|
||||
const { t } = useTranslation()
|
||||
const className =
|
||||
status === 'healthy'
|
||||
? 'bg-green-500/10 text-green-700 dark:text-green-400'
|
||||
: status === 'failed'
|
||||
? 'bg-destructive/10 text-destructive'
|
||||
: status === 'attention'
|
||||
? 'bg-amber-500/10 text-amber-700 dark:text-amber-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-1 text-[11px] font-medium ${className}`}
|
||||
>
|
||||
{status === 'testing' ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : status === 'failed' ? (
|
||||
<WifiOff className="size-3" />
|
||||
) : (
|
||||
<span className="size-1.5 rounded-full bg-current" />
|
||||
)}
|
||||
{t(`admin.storages.cardStatus.${status}`)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user