Refactor FileDisk system with per-disk unique names and disk assignments UI

Major changes to the file disk subsystem:

- Each FileDisk now gets a unique Laravel disk name (disk_{id}) instead
  of temp_{driver}, fixing the bug where multiple local disks with
  different roots overwrote each other's config.

- Move disk registration logic from FileDisk model to FileDiskService
  (registerDisk, getDiskName). Model keeps only getDecodedCredentials
  and a deprecated setConfig() wrapper.

- Add Disk Assignments admin UI (File Disk tab) with three purpose
  dropdowns: Media Storage, PDF Storage, Backup Storage. Stored as
  settings (media_disk_id, pdf_disk_id, backup_disk_id).

- Backup tab now uses the assigned backup disk instead of a per-backup
  dropdown. BackupsController refactored to use BackupService which
  centralizes disk resolution. Removed stale 4-second cache.

- Add local_public disk to config/filesystems.php so system disks
  are properly defined.

- Local disk roots stored relative to storage/app/ with hint text
  in the admin modal explaining the convention.

- Fix BaseModal watchEffect -> watch to prevent infinite request
  loops on the File Disk page.

- Fix string/number comparison for disk purpose IDs from settings.

- Add safeguards: prevent deleting disks with files, warn on
  purpose change, prevent deleting system disks.
This commit is contained in:
Darko Gjorgjijoski
2026-04-07 02:04:57 +02:00
parent ea1fc9b799
commit 20085cab5d
20 changed files with 492 additions and 258 deletions

View File

@@ -4,6 +4,7 @@ namespace App\Console\Commands;
use App\Models\FileDisk;
use App\Models\Setting;
use App\Services\FileDiskService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
@@ -24,21 +25,8 @@ class MigrateMediaToPrivateDisk extends Command
return self::FAILURE;
}
$prefix = env('DYNAMIC_DISK_PREFIX', 'temp_');
$targetDiskName = $prefix.$targetDisk->driver;
// Register disk config without mutating filesystems.default
$credentials = collect(json_decode($targetDisk->credentials));
$baseConfig = config('filesystems.disks.'.$targetDisk->driver, []);
foreach ($baseConfig as $key => $value) {
if ($credentials->has($key)) {
$baseConfig[$key] = $credentials[$key];
}
}
config(['filesystems.disks.'.$targetDiskName => $baseConfig]);
$targetRoot = $baseConfig['root'] ?? null;
$targetDiskName = app(FileDiskService::class)->registerDisk($targetDisk);
$targetRoot = config('filesystems.disks.'.$targetDiskName.'.root');
if (! $targetRoot) {
$this->error('Could not resolve target disk root path.');

View File

@@ -4,61 +4,47 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Jobs\CreateBackupJob;
use App\Models\FileDisk;
use App\Rules\Backup\PathToZip;
use App\Services\Backup\BackupService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Spatie\Backup\BackupDestination\Backup;
use Spatie\Backup\BackupDestination\BackupDestination;
use Spatie\Backup\Helpers\Format;
use Symfony\Component\HttpFoundation\StreamedResponse;
class BackupsController extends Controller
{
public function __construct(
private readonly BackupService $backupService,
) {}
public function index(Request $request): JsonResponse
{
$this->authorize('manage backups');
$configuredBackupDisks = config('backup.backup.destination.disks');
try {
if ($request->file_disk_id) {
$fileDisk = FileDisk::find($request->file_disk_id);
if ($fileDisk) {
$fileDisk->setConfig();
$prefix = env('DYNAMIC_DISK_PREFIX', 'temp_');
config(['backup.backup.destination.disks' => [$prefix.$fileDisk->driver]]);
$configuredBackupDisks = config('backup.backup.destination.disks');
}
}
$destination = $this->backupService->getDestination($request->file_disk_id);
$backupDestination = BackupDestination::create(config('filesystems.default'), config('backup.backup.name'));
$backups = Cache::remember("backups-{$request->file_disk_id}", now()->addSeconds(4), function () use ($backupDestination) {
return $backupDestination
->backups()
->map(function (Backup $backup) {
return [
'path' => $backup->path(),
'created_at' => $backup->date()->format('Y-m-d H:i:s'),
'size' => Format::humanReadableSize($backup->sizeInBytes()),
];
})
->toArray();
});
$backups = $destination
->backups()
->map(function (Backup $backup) {
return [
'path' => $backup->path(),
'created_at' => $backup->date()->format('Y-m-d H:i:s'),
'size' => Format::humanReadableSize($backup->sizeInBytes()),
];
})
->toArray();
return response()->json([
'backups' => $backups,
'disks' => $configuredBackupDisks,
]);
} catch (\Exception $e) {
return response()->json([
'backups' => [],
'error' => 'invalid_disk_credentials',
'error_message' => $e->getMessage(),
'disks' => $configuredBackupDisks,
]);
}
}
@@ -82,9 +68,9 @@ class BackupsController extends Controller
'path' => ['required', new PathToZip],
]);
$backupDestination = BackupDestination::create(config('filesystems.default'), config('backup.backup.name'));
$destination = $this->backupService->getDestination($request->file_disk_id);
$backupDestination
$destination
->backups()
->first(function (Backup $backup) use ($validated) {
return $backup->path() === $validated['path'];
@@ -102,9 +88,9 @@ class BackupsController extends Controller
'path' => ['required', new PathToZip],
]);
$backupDestination = BackupDestination::create(config('filesystems.default'), config('backup.backup.name'));
$destination = $this->backupService->getDestination($request->file_disk_id);
$backup = $backupDestination->backups()->first(function (Backup $backup) use ($validated) {
$backup = $destination->backups()->first(function (Backup $backup) use ($validated) {
return $backup->path() === $validated['path'];
});

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\DiskEnvironmentRequest;
use App\Http\Resources\FileDiskResource;
use App\Models\FileDisk;
use App\Models\Setting;
use App\Services\FileDiskService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
@@ -91,8 +92,10 @@ class DiskController extends Controller
$diskData = [];
switch ($disk) {
case 'local':
// Path is relative to storage/app/.
// e.g., "backups" resolves to storage/app/backups/ at runtime.
$diskData = [
'root' => config('filesystems.disks.local.root'),
'root' => '',
];
break;
@@ -216,11 +219,50 @@ class DiskController extends Controller
],
];
$default = config('filesystems.default');
$defaultDisk = FileDisk::where('set_as_default', true)->first();
return response()->json([
'drivers' => $drivers,
'default' => $default,
'default' => $defaultDisk?->driver ?? 'local',
]);
}
public function getDiskPurposes(): JsonResponse
{
$this->authorize('manage file disk');
$defaultDisk = FileDisk::where('set_as_default', true)->first();
$defaultId = $defaultDisk?->id;
return response()->json([
'media_disk_id' => Setting::getSetting('media_disk_id') ?? $defaultId,
'pdf_disk_id' => Setting::getSetting('pdf_disk_id') ?? $defaultId,
'backup_disk_id' => Setting::getSetting('backup_disk_id') ?? $defaultId,
]);
}
public function updateDiskPurposes(Request $request): JsonResponse
{
$this->authorize('manage file disk');
$request->validate([
'media_disk_id' => 'nullable|exists:file_disks,id',
'pdf_disk_id' => 'nullable|exists:file_disks,id',
'backup_disk_id' => 'nullable|exists:file_disks,id',
]);
if ($request->has('media_disk_id')) {
Setting::setSetting('media_disk_id', $request->media_disk_id);
}
if ($request->has('pdf_disk_id')) {
Setting::setSetting('pdf_disk_id', $request->pdf_disk_id);
}
if ($request->has('backup_disk_id')) {
Setting::setSetting('backup_disk_id', $request->backup_disk_id);
}
return response()->json(['success' => true]);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Services\FileDiskService;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -31,6 +32,21 @@ class FileDisk extends Model
$this->attributes['credentials'] = json_encode($value);
}
/**
* Decode credentials, handling double-encoded JSON from legacy data.
*/
public function getDecodedCredentials(): Collection
{
$decoded = json_decode($this->credentials, true);
// Handle double-encoded JSON (string inside string)
if (is_string($decoded)) {
$decoded = json_decode($decoded, true);
}
return collect($decoded ?? []);
}
public function scopeWhereOrder($query, $orderByField, $orderBy)
{
$query->orderBy($orderByField, $orderBy);
@@ -83,14 +99,14 @@ class FileDisk extends Model
/**
* Apply this disk's credentials to the filesystem configuration at runtime.
*
* @deprecated Use FileDiskService::registerDisk() instead — setConfig() mutates filesystems.default.
*/
public function setConfig(): void
{
$driver = $this->driver;
$credentials = collect(json_decode($this['credentials']));
self::setFilesystem($credentials, $driver);
$service = app(FileDiskService::class);
$diskName = $service->registerDisk($this);
config(['filesystems.default' => $diskName]);
}
/**
@@ -103,6 +119,8 @@ class FileDisk extends Model
/**
* Register a dynamic filesystem disk in the runtime configuration using the given credentials.
*
* @deprecated Use FileDisk::find($id)->registerDisk() instead.
*/
public static function setFilesystem(Collection $credentials, string $driver): void
{
@@ -118,6 +136,10 @@ class FileDisk extends Model
}
}
if ($driver === 'local' && isset($disks['root']) && ! str_starts_with($disks['root'], '/')) {
$disks['root'] = storage_path('app/'.$disks['root']);
}
config(['filesystems.disks.'.$prefix.$driver => $disks]);
}

View File

@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Models\FileDisk;
use App\Models\Setting;
use App\Services\FileDiskService;
use App\Services\Setup\InstallUtils;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
@@ -163,28 +164,16 @@ class AppConfigProvider extends ServiceProvider
protected function configureFileSystemFromDatabase(): void
{
try {
// Register the default file disk config without changing filesystems.default.
// Calling setConfig() mutates the global default which causes side effects
// on pages that make multiple API requests (e.g., File Disk admin page).
$fileDisk = FileDisk::whereSetAsDefault(true)->first();
if ($fileDisk) {
$prefix = env('DYNAMIC_DISK_PREFIX', 'temp_');
$diskName = $prefix.$fileDisk->driver;
$credentials = collect(json_decode($fileDisk->credentials));
$baseConfig = config('filesystems.disks.'.$fileDisk->driver, []);
foreach ($baseConfig as $key => $value) {
if ($credentials->has($key)) {
$baseConfig[$key] = $credentials[$key];
}
}
config(['filesystems.disks.'.$diskName => $baseConfig]);
// Point Spatie Media Library at the same disk
config(['media-library.disk_name' => $diskName]);
if (! $fileDisk) {
return;
}
$diskName = app(FileDiskService::class)->registerDisk($fileDisk);
// Point Spatie Media Library at the resolved disk
config(['media-library.disk_name' => $diskName]);
} catch (\Exception $e) {
// Silently fail if database is not available (during installation, migrations, etc.)
}

View File

@@ -3,6 +3,7 @@
namespace App\Services\Backup;
use App\Models\FileDisk;
use App\Services\FileDiskService;
use Exception;
use Spatie\Backup\Config\Config;
@@ -16,14 +17,10 @@ class BackupConfigurationFactory
$fileDisk = FileDisk::find($data['file_disk_id']);
$fileDisk->setConfig();
$diskName = app(FileDiskService::class)->registerDisk($fileDisk);
$prefix = env('DYNAMIC_DISK_PREFIX', 'temp_');
config(['backup.backup.destination.disks' => [$diskName]]);
config(['backup.backup.destination.disks' => [$prefix.$fileDisk->driver]]);
$config = Config::fromArray(config('backup'));
return $config;
return Config::fromArray(config('backup'));
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Services\Backup;
use App\Models\FileDisk;
use App\Models\Setting;
use App\Services\FileDiskService;
use Spatie\Backup\BackupDestination\BackupDestination;
class BackupService
{
public function __construct(
private readonly FileDiskService $fileDiskService,
) {}
/**
* Resolve the backup FileDisk from the given ID, settings, or default.
*/
public function resolveBackupDisk(?int $fileDiskId = null): ?FileDisk
{
if ($fileDiskId) {
$disk = FileDisk::find($fileDiskId);
if ($disk) {
return $disk;
}
}
$backupDiskId = Setting::getSetting('backup_disk_id');
if ($backupDiskId) {
$disk = FileDisk::find($backupDiskId);
if ($disk) {
return $disk;
}
}
return FileDisk::where('set_as_default', true)->first();
}
/**
* Create a BackupDestination pointing at the resolved disk.
*/
public function getDestination(?int $fileDiskId = null): BackupDestination
{
$disk = $this->resolveBackupDisk($fileDiskId);
$diskName = $disk
? $this->fileDiskService->registerDisk($disk)
: config('filesystems.default');
return BackupDestination::create($diskName, config('backup.backup.name'));
}
}

View File

@@ -13,8 +13,10 @@ class FileDiskService
$this->clearDefaults();
}
$credentials = $this->normalizeCredentials($request->credentials, $request->driver);
return FileDisk::create([
'credentials' => $request->credentials,
'credentials' => $credentials,
'name' => $request->name,
'driver' => $request->driver,
'set_as_default' => $request->set_as_default,
@@ -24,8 +26,10 @@ class FileDiskService
public function update(FileDisk $disk, Request $request): FileDisk
{
$credentials = $this->normalizeCredentials($request->credentials, $request->driver);
$data = [
'credentials' => $request->credentials,
'credentials' => $credentials,
'name' => $request->name,
'driver' => $request->driver,
];
@@ -53,21 +57,77 @@ class FileDiskService
return $disk;
}
/**
* Get the unique Laravel filesystem disk name for a FileDisk.
*/
public function getDiskName(FileDisk $disk): string
{
if ($disk->isSystem()) {
return $disk->name === 'local_public' ? 'local_public' : 'local';
}
return 'disk_'.$disk->id;
}
/**
* Register a FileDisk in the runtime filesystem configuration.
* Returns the Laravel disk name. Does NOT change filesystems.default.
*/
public function registerDisk(FileDisk $disk): string
{
$diskName = $this->getDiskName($disk);
// System disks are already in config/filesystems.php
if ($disk->isSystem()) {
return $diskName;
}
$credentials = $disk->getDecodedCredentials();
$baseConfig = config('filesystems.disks.'.$disk->driver, []);
foreach ($baseConfig as $key => $value) {
if ($credentials->has($key)) {
$baseConfig[$key] = $credentials[$key];
}
}
// Resolve relative local roots to storage/app/{path}
if ($disk->driver === 'local' && isset($baseConfig['root']) && ! str_starts_with($baseConfig['root'], '/')) {
$baseConfig['root'] = storage_path('app/'.$baseConfig['root']);
}
config(['filesystems.disks.'.$diskName => $baseConfig]);
return $diskName;
}
public function validateCredentials(array $credentials, string $driver): bool
{
FileDisk::setFilesystem(collect($credentials), $driver);
// Create a temporary disk config for validation
$baseConfig = config('filesystems.disks.'.$driver, []);
$prefix = env('DYNAMIC_DISK_PREFIX', 'temp_');
foreach ($baseConfig as $key => $value) {
if (isset($credentials[$key])) {
$baseConfig[$key] = $credentials[$key];
}
}
if ($driver === 'local' && isset($baseConfig['root']) && ! str_starts_with($baseConfig['root'], '/')) {
$baseConfig['root'] = storage_path('app/'.$baseConfig['root']);
}
$tempDiskName = 'validation_temp';
config(['filesystems.disks.'.$tempDiskName => $baseConfig]);
try {
$root = '';
if ($driver == 'dropbox') {
$root = $credentials['root'].'/';
}
\Storage::disk($prefix.$driver)->put($root.'invoiceshelf_temp.text', 'Check Credentials');
\Storage::disk($tempDiskName)->put($root.'invoiceshelf_temp.text', 'Check Credentials');
if (\Storage::disk($prefix.$driver)->exists($root.'invoiceshelf_temp.text')) {
\Storage::disk($prefix.$driver)->delete($root.'invoiceshelf_temp.text');
if (\Storage::disk($tempDiskName)->exists($root.'invoiceshelf_temp.text')) {
\Storage::disk($tempDiskName)->delete($root.'invoiceshelf_temp.text');
return true;
}
@@ -78,6 +138,29 @@ class FileDiskService
return false;
}
/**
* For local disks, strip any absolute prefix and store the root
* as a path relative to storage/app. At runtime the path is
* resolved to an absolute path via storage_path().
*/
private function normalizeCredentials(array $credentials, string $driver): array
{
if ($driver === 'local' && isset($credentials['root'])) {
$root = $credentials['root'];
$storageApp = storage_path('app').'/';
if (str_starts_with($root, $storageApp)) {
$root = substr($root, strlen($storageApp));
}
$root = ltrim($root, '/');
$credentials['root'] = $root;
}
return $credentials;
}
private function clearDefaults(): void
{
FileDisk::query()->update(['set_as_default' => false]);

View File

@@ -37,6 +37,13 @@ return [
'report' => false,
],
'local_public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
],
's3' => [
'driver' => 's3',
'key' => env('AWS_KEY'),

View File

@@ -25,7 +25,7 @@ class FileDiskFactory extends Factory
'set_as_default' => false,
'credentials' => [
'driver' => 'local',
'root' => storage_path('app'),
'root' => 'test-disks',
],
];

View File

@@ -1461,7 +1461,18 @@
"deleted_message": "File Disk deleted successfully",
"disk_variables_save_successfully": "Disk Configured Successfully",
"disk_variables_save_error": "Disk configuration failed.",
"invalid_disk_credentials": "Invalid credential of selected disk"
"invalid_disk_credentials": "Invalid credential of selected disk",
"disk_assignments": "Disk Assignments",
"disk_assignments_description": "Configure which disk is used for different types of file storage.",
"media_storage": "Media Storage",
"media_storage_description": "Used for expense receipts, avatars, and company logos.",
"pdf_storage": "PDF Storage",
"pdf_storage_description": "Used for generated invoice, estimate, and payment PDFs.",
"backup_storage": "Backup Storage",
"backup_storage_description": "Used for application backups.",
"purposes_saved": "Disk assignments saved successfully",
"local_root_hint": "Path is relative to storage/app/. For example, entering \"backups\" will store files in storage/app/backups/.",
"change_disk_warning": "Changing disk assignments will only affect new uploads. Existing files will remain on their original disk and will continue to be accessible. If you need to move existing files, use the command: php artisan media:secure"
},
"taxations": {
"add_billing_address": "Enter Billing Address",

View File

@@ -118,6 +118,7 @@ export const API = {
// Disks & Backups
DISKS: '/api/v1/disks',
DISK_DRIVERS: '/api/v1/disk/drivers',
DISK_PURPOSES: '/api/v1/disk/purposes',
BACKUPS: '/api/v1/backups',
DOWNLOAD_BACKUP: '/api/v1/download-backup',

View File

@@ -34,6 +34,12 @@ export interface CreateDiskPayload {
set_as_default?: boolean
}
export interface DiskPurposes {
media_disk_id: number | null
pdf_disk_id: number | null
backup_disk_id: number | null
}
export const diskService = {
async list(params?: ListParams): Promise<PaginatedResponse<Disk>> {
const { data } = await client.get(API.DISKS, { params })
@@ -67,4 +73,14 @@ export const diskService = {
const { data } = await client.get(API.DISK_DRIVERS)
return data
},
async getDiskPurposes(): Promise<DiskPurposes> {
const { data } = await client.get(API.DISK_PURPOSES)
return data
},
async updateDiskPurposes(payload: Partial<DiskPurposes>): Promise<{ success: boolean }> {
const { data } = await client.put(API.DISK_PURPOSES, payload)
return data
},
}

View File

@@ -93,7 +93,7 @@
<script setup lang="ts">
import { useModalStore } from '@v2/stores/modal.store'
import { computed, watchEffect, useSlots } from 'vue'
import { computed, watch, useSlots } from 'vue'
import {
Dialog,
DialogOverlay,
@@ -120,9 +120,9 @@ const emit = defineEmits<Emits>()
const modalStore = useModalStore()
watchEffect(() => {
if (props.show) {
emit('open', props.show)
watch(() => props.show, (newVal) => {
if (newVal) {
emit('open', newVal)
}
})

View File

@@ -9,7 +9,6 @@ import {
backupService,
type CreateBackupPayload,
} from '@v2/api/services/backup.service'
import { diskService, type Disk } from '@v2/api/services/disk.service'
import {
getErrorTranslationKey,
handleApiError,
@@ -17,23 +16,14 @@ import {
type BackupOption = CreateBackupPayload['option']
interface DiskOption extends Disk {
display_name: string
}
interface BackupTypeOption {
id: BackupOption
label: string
}
interface BackupModalData {
disks?: DiskOption[]
selectedDiskId?: number | null
}
interface BackupForm {
option: BackupOption | ''
selectedDiskId: number | null
file_disk_id: number | null
}
const modalStore = useModalStore()
@@ -41,27 +31,16 @@ const notificationStore = useNotificationStore()
const { t } = useI18n()
const isSaving = ref(false)
const isFetchingInitialData = ref(false)
const disks = ref<DiskOption[]>([])
const form = reactive<BackupForm>({
option: 'full',
selectedDiskId: null,
file_disk_id: null,
})
const backupTypeOptions: BackupTypeOption[] = [
{
id: 'full',
label: 'full',
},
{
id: 'only-db',
label: 'only-db',
},
{
id: 'only-files',
label: 'only-files',
},
{ id: 'full', label: 'full' },
{ id: 'only-db', label: 'only-db' },
{ id: 'only-files', label: 'only-files' },
]
const modalActive = computed<boolean>(() => {
@@ -72,61 +51,23 @@ const rules = computed(() => ({
option: {
required: helpers.withMessage(t('validation.required'), required),
},
selectedDiskId: {
required: helpers.withMessage(t('validation.required'), required),
},
}))
const v$ = useVuelidate(rules, form)
async function setInitialData(): Promise<void> {
function setInitialData(): void {
resetForm()
isFetchingInitialData.value = true
try {
const modalData = isBackupModalData(modalStore.data) ? modalStore.data : null
if (modalData?.disks?.length) {
disks.value = modalData.disks
form.selectedDiskId =
modalData.selectedDiskId ??
modalData.disks.find((disk) => disk.set_as_default)?.id ??
modalData.disks[0]?.id ??
null
return
}
const response = await diskService.list({ limit: 'all' })
disks.value = response.data.map((disk) => ({
...disk,
display_name: `${disk.name} - [${disk.driver}]`,
}))
const selectedDiskId =
modalStore.data &&
typeof modalStore.data === 'object' &&
'id' in (modalStore.data as Record<string, unknown>)
? Number((modalStore.data as Record<string, unknown>).id)
: null
form.selectedDiskId =
disks.value.find((disk) => disk.id === selectedDiskId)?.id ??
disks.value.find((disk) => disk.set_as_default)?.id ??
disks.value[0]?.id ??
null
} catch (error: unknown) {
showApiError(error)
} finally {
isFetchingInitialData.value = false
const modalData = modalStore.data as Record<string, unknown> | null
if (modalData?.file_disk_id) {
form.file_disk_id = Number(modalData.file_disk_id)
}
}
async function createBackup(): Promise<void> {
v$.value.$touch()
if (v$.value.$invalid || !form.selectedDiskId) {
if (v$.value.$invalid || !form.file_disk_id) {
return
}
@@ -135,7 +76,7 @@ async function createBackup(): Promise<void> {
try {
const response = await backupService.create({
option: form.option as BackupOption,
file_disk_id: form.selectedDiskId,
file_disk_id: form.file_disk_id,
})
if (response.success) {
@@ -165,7 +106,7 @@ function showApiError(error: unknown): void {
function resetForm(): void {
form.option = 'full'
form.selectedDiskId = null
form.file_disk_id = null
v$.value.$reset()
}
@@ -174,13 +115,8 @@ function closeModal(): void {
setTimeout(() => {
resetForm()
disks.value = []
}, 300)
}
function isBackupModalData(value: unknown): value is BackupModalData {
return Boolean(value && typeof value === 'object')
}
</script>
<template>
@@ -212,28 +148,6 @@ function isBackupModalData(value: unknown): value is BackupModalData {
@update:model-value="v$.option.$touch()"
/>
</BaseInputGroup>
<BaseInputGroup
:label="$t('settings.disk.select_disk')"
:error="
v$.selectedDiskId.$error && v$.selectedDiskId.$errors[0]?.$message
"
required
>
<BaseMultiselect
v-model="form.selectedDiskId"
:options="disks"
:content-loading="isFetchingInitialData"
:can-deselect="false"
:invalid="v$.selectedDiskId.$error"
label="display_name"
track-by="id"
value-prop="id"
searchable
:placeholder="$t('settings.disk.select_disk')"
@update:model-value="v$.selectedDiskId.$touch()"
/>
</BaseInputGroup>
</BaseInputGrid>
</div>

View File

@@ -20,6 +20,7 @@ interface DiskField {
key: string
labelKey: string
placeholder?: string
hint?: string
}
interface DiskDriverOption {
@@ -39,7 +40,8 @@ const DRIVER_FIELDS: Record<DiskDriverValue, DiskField[]> = {
{
key: 'root',
labelKey: 'settings.disk.local_root',
placeholder: 'Ex. /user/root/',
placeholder: 'Ex. backups',
hint: 'settings.disk.local_root_hint',
},
],
s3: [
@@ -512,6 +514,9 @@ function isDisk(value: unknown): value is Disk {
:placeholder="field.placeholder"
@input="touchCredential(field.key)"
/>
<span v-if="field.hint" class="text-xs text-subtle mt-1 block">
{{ $t(field.hint) }}
</span>
</BaseInputGroup>
</BaseInputGrid>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '@v2/stores/modal.store'
import { useDialogStore } from '@v2/stores/dialog.store'
@@ -20,10 +20,6 @@ interface TableColumn {
sortable?: boolean
}
interface DiskOption extends Disk {
display_name: string
}
interface FetchParams {
page: number
filter: Record<string, unknown>
@@ -46,8 +42,7 @@ const notificationStore = useNotificationStore()
const { t } = useI18n()
const table = ref<{ refresh: () => void } | null>(null)
const disks = ref<DiskOption[]>([])
const selectedDisk = ref<DiskOption | null>(null)
const backupDisk = ref<Disk | null>(null)
const isFetchingInitialData = ref(false)
const backupError = ref('')
@@ -68,6 +63,12 @@ const backupColumns = computed<TableColumn[]>(() => [
label: t('settings.backup.size'),
tdClass: 'font-medium text-heading',
},
{
key: 'disk_name',
label: t('settings.disk.title', 1),
tdClass: 'font-medium text-muted',
sortable: false,
},
{
key: 'actions',
label: '',
@@ -76,30 +77,27 @@ const backupColumns = computed<TableColumn[]>(() => [
},
])
watch(
selectedDisk,
(newDisk, oldDisk) => {
if (newDisk?.id && oldDisk?.id && newDisk.id !== oldDisk.id) {
refreshTable()
}
}
)
loadBackupDisk()
loadDisks()
async function loadDisks(): Promise<void> {
async function loadBackupDisk(): Promise<void> {
isFetchingInitialData.value = true
try {
const response = await diskService.list({ limit: 'all' })
const [diskResponse, purposesResponse] = await Promise.all([
diskService.list({ limit: 'all' }),
diskService.getDiskPurposes(),
])
disks.value = response.data.map((disk) => ({
...disk,
display_name: `${disk.name} - [${disk.driver}]`,
}))
const disks = diskResponse.data
const backupDiskId = purposesResponse.backup_disk_id
selectedDisk.value =
disks.value.find((disk) => disk.set_as_default) ?? disks.value[0] ?? null
backupDisk.value =
(backupDiskId ? disks.find((disk) => disk.id === Number(backupDiskId)) : null) ??
disks.find((disk) => disk.set_as_default) ??
disks[0] ??
null
// Refresh table now that we know which disk to query
refreshTable()
} catch (error: unknown) {
showApiError(error)
} finally {
@@ -108,7 +106,7 @@ async function loadDisks(): Promise<void> {
}
async function fetchData({ page }: FetchParams): Promise<FetchResult> {
if (!selectedDisk.value) {
if (!backupDisk.value) {
return emptyResult(page)
}
@@ -116,8 +114,8 @@ async function fetchData({ page }: FetchParams): Promise<FetchResult> {
try {
const response = await backupService.list({
disk: selectedDisk.value.driver,
file_disk_id: selectedDisk.value.id,
disk: backupDisk.value.driver,
file_disk_id: backupDisk.value.id,
})
if (response.error) {
@@ -142,7 +140,7 @@ async function fetchData({ page }: FetchParams): Promise<FetchResult> {
}
async function removeBackup(backup: Backup): Promise<void> {
if (!selectedDisk.value) {
if (!backupDisk.value) {
return
}
@@ -162,8 +160,8 @@ async function removeBackup(backup: Backup): Promise<void> {
try {
const response = await backupService.delete({
disk: selectedDisk.value.driver,
file_disk_id: selectedDisk.value.id,
disk: backupDisk.value.driver,
file_disk_id: backupDisk.value.id,
path: backup.path,
})
@@ -180,7 +178,7 @@ async function removeBackup(backup: Backup): Promise<void> {
}
async function downloadBackup(backup: Backup): Promise<void> {
if (!selectedDisk.value) {
if (!backupDisk.value) {
return
}
@@ -189,8 +187,8 @@ async function downloadBackup(backup: Backup): Promise<void> {
try {
const blob = await backupService.download({
disk: selectedDisk.value.driver,
file_disk_id: selectedDisk.value.id,
disk: backupDisk.value.driver,
file_disk_id: backupDisk.value.id,
path: backup.path,
})
@@ -216,13 +214,16 @@ async function downloadBackup(backup: Backup): Promise<void> {
}
function openCreateBackupModal(): void {
if (!backupDisk.value) {
return
}
modalStore.openModal({
title: t('settings.backup.create_backup'),
componentName: 'AdminBackupModal',
size: 'sm',
data: {
disks: disks.value,
selectedDiskId: selectedDisk.value?.id ?? null,
file_disk_id: backupDisk.value.id,
},
refreshData: table.value?.refresh,
})
@@ -271,26 +272,6 @@ function showApiError(error: unknown): void {
</BaseButton>
</template>
<div class="grid my-14 md:grid-cols-3">
<BaseInputGroup
:label="$t('settings.disk.select_disk')"
:content-loading="isFetchingInitialData"
>
<BaseMultiselect
v-model="selectedDisk"
:content-loading="isFetchingInitialData"
:options="disks"
track-by="id"
value-prop="id"
label="display_name"
:placeholder="$t('settings.disk.select_disk')"
object
searchable
class="w-full"
/>
</BaseInputGroup>
</div>
<BaseErrorAlert
v-if="backupError"
class="mt-6"
@@ -304,6 +285,10 @@ function showApiError(error: unknown): void {
:data="fetchData"
:columns="backupColumns"
>
<template #cell-disk_name>
{{ backupDisk?.name ?? '-' }}
</template>
<template #cell-actions="{ row }">
<BaseDropdown>
<template #activator>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import type { DiskPurposes } from '@v2/api/services/disk.service'
import { useI18n } from 'vue-i18n'
import { useModalStore } from '@v2/stores/modal.store'
import { useDialogStore } from '@v2/stores/dialog.store'
@@ -47,6 +48,79 @@ const savePdfToDisk = ref(
(globalStore.globalSettings?.save_pdf_to_disk ?? 'NO') === 'YES'
)
// Disk purpose assignments
const allDisks = ref<Disk[]>([])
const purposes = ref<DiskPurposes>({
media_disk_id: null,
pdf_disk_id: null,
backup_disk_id: null,
})
const originalPurposes = ref<DiskPurposes>({
media_disk_id: null,
pdf_disk_id: null,
backup_disk_id: null,
})
const isSavingPurposes = ref(false)
onMounted(async () => {
try {
const [disksRes, purposesRes] = await Promise.all([
diskService.list({ limit: 'all' as unknown as number }),
diskService.getDiskPurposes(),
])
allDisks.value = disksRes.data
const normalized = {
media_disk_id: purposesRes.media_disk_id ? Number(purposesRes.media_disk_id) : null,
pdf_disk_id: purposesRes.pdf_disk_id ? Number(purposesRes.pdf_disk_id) : null,
backup_disk_id: purposesRes.backup_disk_id ? Number(purposesRes.backup_disk_id) : null,
}
purposes.value = { ...normalized }
originalPurposes.value = { ...normalized }
} catch {
// Silently fail
}
})
function hasChangedPurposes(): boolean {
return (
purposes.value.media_disk_id !== originalPurposes.value.media_disk_id ||
purposes.value.pdf_disk_id !== originalPurposes.value.pdf_disk_id ||
purposes.value.backup_disk_id !== originalPurposes.value.backup_disk_id
)
}
async function savePurposes(): Promise<void> {
if (hasChangedPurposes()) {
const confirmed = await dialogStore.openDialog({
title: t('general.are_you_sure'),
message: t('settings.disk.change_disk_warning'),
yesLabel: t('general.ok'),
noLabel: t('general.cancel'),
variant: 'danger',
hideNoButton: false,
size: 'lg',
})
if (!confirmed) {
return
}
}
isSavingPurposes.value = true
try {
await diskService.updateDiskPurposes(purposes.value)
originalPurposes.value = { ...purposes.value }
notificationStore.showNotification({
type: 'success',
message: t('settings.disk.purposes_saved'),
})
} catch (error: unknown) {
showApiError(error)
} finally {
isSavingPurposes.value = false
}
}
const fileDiskColumns = computed<TableColumn[]>(() => [
{
key: 'name',
@@ -292,4 +366,68 @@ function showApiError(error: unknown): void {
:description="$t('settings.disk.disk_setting_description')"
/>
</BaseSettingCard>
<!-- Disk Assignments -->
<BaseSettingCard
:title="$t('settings.disk.disk_assignments')"
:description="$t('settings.disk.disk_assignments_description')"
class="mt-6"
>
<BaseInputGrid class="mt-4">
<BaseInputGroup :label="$t('settings.disk.media_storage')">
<BaseMultiselect
v-model="purposes.media_disk_id"
:options="allDisks"
value-prop="id"
label="name"
track-by="name"
:can-deselect="false"
:placeholder="$t('settings.disk.select_disk')"
/>
<span class="text-xs text-subtle mt-1 block">
{{ $t('settings.disk.media_storage_description') }}
</span>
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.disk.pdf_storage')">
<BaseMultiselect
v-model="purposes.pdf_disk_id"
:options="allDisks"
value-prop="id"
label="name"
track-by="name"
:can-deselect="false"
:placeholder="$t('settings.disk.select_disk')"
/>
<span class="text-xs text-subtle mt-1 block">
{{ $t('settings.disk.pdf_storage_description') }}
</span>
</BaseInputGroup>
<BaseInputGroup :label="$t('settings.disk.backup_storage')">
<BaseMultiselect
v-model="purposes.backup_disk_id"
:options="allDisks"
value-prop="id"
label="name"
track-by="name"
:can-deselect="false"
:placeholder="$t('settings.disk.select_disk')"
/>
<span class="text-xs text-subtle mt-1 block">
{{ $t('settings.disk.backup_storage_description') }}
</span>
</BaseInputGroup>
</BaseInputGrid>
<BaseButton
:loading="isSavingPurposes"
:disabled="isSavingPurposes"
variant="primary"
class="mt-6"
@click="savePurposes"
>
{{ $t('general.save') }}
</BaseButton>
</BaseSettingCard>
</template>

View File

@@ -346,6 +346,8 @@ Route::prefix('/v1')->group(function () {
Route::get('download-backup', [BackupsController::class, 'download']);
Route::get('/disk/drivers', [DiskController::class, 'getDiskDrivers']);
Route::get('/disk/purposes', [DiskController::class, 'getDiskPurposes']);
Route::put('/disk/purposes', [DiskController::class, 'updateDiskPurposes']);
// Exchange Rate
// ----------------------------------

View File

@@ -50,11 +50,7 @@ test('create backup', function () {
$response = getJson("/api/v1/backups?disk={$disk->driver}&&file_disk_id={$disk->id}");
$prefix = env('DYNAMIC_DISK_PREFIX', 'temp_');
$response->assertStatus(200)->assertJson([
'disks' => [
$prefix.$disk->driver,
],
$response->assertStatus(200)->assertJsonStructure([
'backups',
]);
});