check staging area has enough capacity
Continuous Integration / backend-tests (push) Successful in 39s
Continuous Integration / frontend-check (push) Successful in 20s
Continuous Integration / e2e-tests (push) Successful in 5m17s

This commit is contained in:
2026-05-05 21:33:44 -04:00
parent 32fc9e4506
commit 65860e0408
7 changed files with 157 additions and 27 deletions
+7
View File
@@ -159,6 +159,13 @@ class DashboardStatsSchema(BaseModel):
redundancy_ratio: float redundancy_ratio: float
class StagingInfoSchema(BaseModel):
path: str
total_bytes: int
used_bytes: int
free_bytes: int
class JobSchema(BaseModel): class JobSchema(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+38 -1
View File
@@ -1,7 +1,10 @@
import shutil
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.database import get_db from app.db.database import get_db
from app.api.common import DashboardStatsSchema from app.api.common import DashboardStatsSchema, StagingInfoSchema
from app.core.config import settings
from sqlalchemy import func, text from sqlalchemy import func, text
from app.db import models from app.db import models
@@ -113,3 +116,37 @@ def get_dashboard_stats(db_session: Session = Depends(get_db)):
last_scan_time=last_scan.completed_at if last_scan else None, last_scan_time=last_scan.completed_at if last_scan else None,
redundancy_ratio=round(redundancy_percentage, 1), redundancy_ratio=round(redundancy_percentage, 1),
) )
@router.get(
"/staging/info", response_model=StagingInfoSchema, operation_id="get_staging_info"
)
def get_staging_info():
"""Returns disk usage information for the backup staging directory."""
path = settings.staging_directory
try:
usage = shutil.disk_usage(path)
return StagingInfoSchema(
path=path,
total_bytes=usage.total,
used_bytes=usage.used,
free_bytes=usage.free,
)
except OSError:
# Fallback: if the configured path doesn't exist yet, check its parent
parent = path if path == "/" else path.rsplit("/", 1)[0] or "/"
try:
usage = shutil.disk_usage(parent)
return StagingInfoSchema(
path=path,
total_bytes=usage.total,
used_bytes=usage.used,
free_bytes=usage.free,
)
except OSError:
return StagingInfoSchema(
path=path,
total_bytes=0,
used_bytes=0,
free_bytes=0,
)
+25
View File
@@ -374,6 +374,31 @@ class ArchiverService:
if current_chunk: if current_chunk:
chunks.append(current_chunk) chunks.append(current_chunk)
# --- Staging Space Validation ---
# Sequential media (tape) requires staging the full tarfile before writing.
# Ensure the staging directory has enough free space for the largest chunk.
if not storage_provider.capabilities.get("supports_random_access"):
largest_chunk_size = max(
sum(i["offset_end"] - i["offset_start"] for i in chunk)
for chunk in chunks
)
try:
usage = shutil.disk_usage(self.staging_directory)
# Require 110% of chunk size to leave headroom for tar overhead
required = int(largest_chunk_size * 1.1)
if usage.free < required:
free_gb = usage.free / (1024**3)
req_gb = required / (1024**3)
JobManager.fail_job(
job_id,
f"Staging area at {self.staging_directory} has only {free_gb:.1f} GB free, "
f"but the largest archive chunk requires {req_gb:.1f} GB. "
f"Free up space or reduce the backup set.",
)
return
except OSError as e:
logger.warning(f"Could not check staging disk usage: {e}")
JobManager.add_job_log(job_id, f"Packed into {len(chunks)} archive(s)") JobManager.add_job_log(job_id, f"Packed into {len(chunks)} archive(s)")
for chunk_index, chunk_items in enumerate(chunks): for chunk_index, chunk_items in enumerate(chunks):
File diff suppressed because one or more lines are too long
+15 -8
View File
@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client'; import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen'; import { client } from './client.gen';
import type { AddDirectoryToRestoreQueueData, AddDirectoryToRestoreQueueErrors, AddDirectoryToRestoreQueueResponses, AddFileToRestoreQueueData, AddFileToRestoreQueueErrors, AddFileToRestoreQueueResponses, ArchiveBrowseData, ArchiveBrowseErrors, ArchiveBrowseResponses, ArchiveMetadataData, ArchiveMetadataErrors, ArchiveMetadataResponses, ArchiveSearchData, ArchiveSearchErrors, ArchiveSearchResponses, ArchiveTreeData, ArchiveTreeErrors, ArchiveTreeResponses, BatchAddToRestoreQueueData, BatchAddToRestoreQueueErrors, BatchAddToRestoreQueueResponses, BatchConfirmDiscrepanciesData, BatchConfirmDiscrepanciesErrors, BatchConfirmDiscrepanciesResponses, BatchDeleteDiscrepanciesData, BatchDeleteDiscrepanciesErrors, BatchDeleteDiscrepanciesResponses, BatchDismissDiscrepanciesData, BatchDismissDiscrepanciesErrors, BatchDismissDiscrepanciesResponses, BatchResolveDiscrepanciesData, BatchResolveDiscrepanciesErrors, BatchResolveDiscrepanciesResponses, BatchTrackData, BatchTrackErrors, BatchTrackResponses, BrowseDiscrepanciesData, BrowseDiscrepanciesErrors, BrowseDiscrepanciesResponses, BrowseRestoreQueueData, BrowseRestoreQueueErrors, BrowseRestoreQueueResponses, CancelJobData, CancelJobErrors, CancelJobResponses, CheckHealthData, CheckHealthResponses, ClearRestoreQueueData, ClearRestoreQueueResponses, ConfirmDiscrepancyData, ConfirmDiscrepancyErrors, ConfirmDiscrepancyResponses, CreateMediaData, CreateMediaErrors, CreateMediaResponses, CreateSecretData, CreateSecretErrors, CreateSecretResponses, DeleteDiscrepancyData, DeleteDiscrepancyErrors, DeleteDiscrepancyResponses, DeleteMediaData, DeleteMediaErrors, DeleteMediaResponses, DeleteSecretData, DeleteSecretErrors, DeleteSecretResponses, DetectMediaData, DetectMediaResponses, DiscoverHardwareData, DiscoverHardwareResponses, DismissDiscrepancyData, DismissDiscrepancyErrors, DismissDiscrepancyResponses, DownloadExclusionReportData, DownloadExclusionReportErrors, DownloadExclusionReportResponses, ExportDatabaseData, ExportDatabaseResponses, FilesystemBrowseData, FilesystemBrowseErrors, FilesystemBrowseResponses, FilesystemSearchData, FilesystemSearchErrors, FilesystemSearchResponses, FilesystemTreeData, FilesystemTreeErrors, FilesystemTreeResponses, GetAnalyticsData, GetAnalyticsResponses, GetDashboardStatsData, GetDashboardStatsResponses, GetDiscrepancyTreeData, GetDiscrepancyTreeErrors, GetDiscrepancyTreeResponses, GetJobCountData, GetJobCountResponses, GetJobData, GetJobErrors, GetJobLogsData, GetJobLogsErrors, GetJobLogsResponses, GetJobResponses, GetJobStatsData, GetJobStatsResponses, GetRestoreManifestData, GetRestoreManifestResponses, GetRestoreQueueData, GetRestoreQueueResponses, GetRestoreQueueTreeData, GetRestoreQueueTreeErrors, GetRestoreQueueTreeResponses, GetScanStatusData, GetScanStatusResponses, GetSecretData, GetSecretErrors, GetSecretResponses, GetSettingsData, GetSettingsResponses, GetTreemapData, GetTreemapResponses, IgnoreHardwareData, IgnoreHardwareErrors, IgnoreHardwareResponses, ImportDatabaseData, ImportDatabaseErrors, ImportDatabaseResponses, InitializeMediaData, InitializeMediaErrors, InitializeMediaResponses, ListBackupsData, ListBackupsResponses, ListDirectoriesData, ListDirectoriesErrors, ListDirectoriesResponses, ListDiscrepanciesData, ListDiscrepanciesResponses, ListJobsData, ListJobsErrors, ListJobsResponses, ListMediaData, ListMediaErrors, ListMediaResponses, ListProvidersData, ListProvidersResponses, ListSecretsData, ListSecretsResponses, RemoveFromRestoreQueueData, RemoveFromRestoreQueueErrors, RemoveFromRestoreQueueResponses, ReorderMediaData, ReorderMediaErrors, ReorderMediaResponses, ResetTestEnvironmentData, ResetTestEnvironmentResponses, RetryJobData, RetryJobErrors, RetryJobResponses, StreamJobsData, StreamJobsResponses, TestExclusionsData, TestExclusionsErrors, TestExclusionsResponses, TestNotificationData, TestNotificationErrors, TestNotificationResponses, TriggerAutoBackupData, TriggerAutoBackupResponses, TriggerBackupData, TriggerBackupErrors, TriggerBackupResponses, TriggerIndexingData, TriggerIndexingResponses, TriggerRestoreData, TriggerRestoreErrors, TriggerRestoreResponses, TriggerScanData, TriggerScanResponses, UndoDismissDiscrepancyData, UndoDismissDiscrepancyErrors, UndoDismissDiscrepancyResponses, UpdateMediaData, UpdateMediaErrors, UpdateMediaResponses, UpdateSettingsData, UpdateSettingsErrors, UpdateSettingsResponses } from './types.gen'; import type { AddDirectoryToRestoreQueueData, AddDirectoryToRestoreQueueErrors, AddDirectoryToRestoreQueueResponses, AddFileToRestoreQueueData, AddFileToRestoreQueueErrors, AddFileToRestoreQueueResponses, ArchiveBrowseData, ArchiveBrowseErrors, ArchiveBrowseResponses, ArchiveMetadataData, ArchiveMetadataErrors, ArchiveMetadataResponses, ArchiveSearchData, ArchiveSearchErrors, ArchiveSearchResponses, ArchiveTreeData, ArchiveTreeErrors, ArchiveTreeResponses, BatchAddToRestoreQueueData, BatchAddToRestoreQueueErrors, BatchAddToRestoreQueueResponses, BatchConfirmDiscrepanciesData, BatchConfirmDiscrepanciesErrors, BatchConfirmDiscrepanciesResponses, BatchDeleteDiscrepanciesData, BatchDeleteDiscrepanciesErrors, BatchDeleteDiscrepanciesResponses, BatchDismissDiscrepanciesData, BatchDismissDiscrepanciesErrors, BatchDismissDiscrepanciesResponses, BatchResolveDiscrepanciesData, BatchResolveDiscrepanciesErrors, BatchResolveDiscrepanciesResponses, BatchTrackData, BatchTrackErrors, BatchTrackResponses, BrowseDiscrepanciesData, BrowseDiscrepanciesErrors, BrowseDiscrepanciesResponses, BrowseRestoreQueueData, BrowseRestoreQueueErrors, BrowseRestoreQueueResponses, CancelJobData, CancelJobErrors, CancelJobResponses, CheckHealthData, CheckHealthResponses, ClearRestoreQueueData, ClearRestoreQueueResponses, ConfirmDiscrepancyData, ConfirmDiscrepancyErrors, ConfirmDiscrepancyResponses, CreateMediaData, CreateMediaErrors, CreateMediaResponses, CreateSecretData, CreateSecretErrors, CreateSecretResponses, DeleteDiscrepancyData, DeleteDiscrepancyErrors, DeleteDiscrepancyResponses, DeleteMediaData, DeleteMediaErrors, DeleteMediaResponses, DeleteSecretData, DeleteSecretErrors, DeleteSecretResponses, DetectMediaData, DetectMediaResponses, DiscoverHardwareData, DiscoverHardwareResponses, DismissDiscrepancyData, DismissDiscrepancyErrors, DismissDiscrepancyResponses, DownloadExclusionReportData, DownloadExclusionReportErrors, DownloadExclusionReportResponses, ExportDatabaseData, ExportDatabaseResponses, FilesystemBrowseData, FilesystemBrowseErrors, FilesystemBrowseResponses, FilesystemSearchData, FilesystemSearchErrors, FilesystemSearchResponses, FilesystemTreeData, FilesystemTreeErrors, FilesystemTreeResponses, GetAnalyticsData, GetAnalyticsResponses, GetDashboardStatsData, GetDashboardStatsResponses, GetDiscrepancyTreeData, GetDiscrepancyTreeErrors, GetDiscrepancyTreeResponses, GetJobCountData, GetJobCountResponses, GetJobData, GetJobErrors, GetJobLogsData, GetJobLogsErrors, GetJobLogsResponses, GetJobResponses, GetJobStatsData, GetJobStatsResponses, GetRestoreManifestData, GetRestoreManifestResponses, GetRestoreQueueData, GetRestoreQueueResponses, GetRestoreQueueTreeData, GetRestoreQueueTreeErrors, GetRestoreQueueTreeResponses, GetScanStatusData, GetScanStatusResponses, GetSecretData, GetSecretErrors, GetSecretResponses, GetSettingsData, GetSettingsResponses, GetStagingInfoData, GetStagingInfoResponses, GetTreemapData, GetTreemapResponses, IgnoreHardwareData, IgnoreHardwareErrors, IgnoreHardwareResponses, ImportDatabaseData, ImportDatabaseErrors, ImportDatabaseResponses, InitializeMediaData, InitializeMediaErrors, InitializeMediaResponses, ListBackupsData, ListBackupsResponses, ListDirectoriesData, ListDirectoriesErrors, ListDirectoriesResponses, ListDiscrepanciesData, ListDiscrepanciesResponses, ListJobsData, ListJobsErrors, ListJobsResponses, ListMediaData, ListMediaErrors, ListMediaResponses, ListProvidersData, ListProvidersResponses, ListSecretsData, ListSecretsResponses, RemoveFromRestoreQueueData, RemoveFromRestoreQueueErrors, RemoveFromRestoreQueueResponses, ReorderMediaData, ReorderMediaErrors, ReorderMediaResponses, ResetTestEnvironmentData, ResetTestEnvironmentResponses, RetryJobData, RetryJobErrors, RetryJobResponses, StreamJobsData, StreamJobsResponses, TestExclusionsData, TestExclusionsErrors, TestExclusionsResponses, TestNotificationData, TestNotificationErrors, TestNotificationResponses, TriggerAutoBackupData, TriggerAutoBackupResponses, TriggerBackupData, TriggerBackupErrors, TriggerBackupResponses, TriggerIndexingData, TriggerIndexingResponses, TriggerRestoreData, TriggerRestoreErrors, TriggerRestoreResponses, TriggerScanData, TriggerScanResponses, UndoDismissDiscrepancyData, UndoDismissDiscrepancyErrors, UndoDismissDiscrepancyResponses, UpdateMediaData, UpdateMediaErrors, UpdateMediaResponses, UpdateSettingsData, UpdateSettingsErrors, UpdateSettingsResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean, TResponse = unknown> = Options2<TData, ThrowOnError, TResponse> & {
/** /**
@@ -32,6 +32,13 @@ export const resetTestEnvironment = <ThrowOnError extends boolean = false>(optio
*/ */
export const getDashboardStats = <ThrowOnError extends boolean = false>(options?: Options<GetDashboardStatsData, ThrowOnError>) => (options?.client ?? client).get<GetDashboardStatsResponses, unknown, ThrowOnError>({ url: '/system/dashboard/stats', ...options }); export const getDashboardStats = <ThrowOnError extends boolean = false>(options?: Options<GetDashboardStatsData, ThrowOnError>) => (options?.client ?? client).get<GetDashboardStatsResponses, unknown, ThrowOnError>({ url: '/system/dashboard/stats', ...options });
/**
* Get Staging Info
*
* Returns disk usage information for the backup staging directory.
*/
export const getStagingInfo = <ThrowOnError extends boolean = false>(options?: Options<GetStagingInfoData, ThrowOnError>) => (options?.client ?? client).get<GetStagingInfoResponses, unknown, ThrowOnError>({ url: '/system/staging/info', ...options });
/** /**
* List Jobs * List Jobs
* *
@@ -53,6 +60,13 @@ export const getJobCount = <ThrowOnError extends boolean = false>(options?: Opti
*/ */
export const getJobStats = <ThrowOnError extends boolean = false>(options?: Options<GetJobStatsData, ThrowOnError>) => (options?.client ?? client).get<GetJobStatsResponses, unknown, ThrowOnError>({ url: '/system/jobs/stats', ...options }); export const getJobStats = <ThrowOnError extends boolean = false>(options?: Options<GetJobStatsData, ThrowOnError>) => (options?.client ?? client).get<GetJobStatsResponses, unknown, ThrowOnError>({ url: '/system/jobs/stats', ...options });
/**
* Stream Jobs
*
* Server-Sent Events (SSE) endpoint for real-time job status updates.
*/
export const streamJobs = <ThrowOnError extends boolean = false>(options?: Options<StreamJobsData, ThrowOnError>) => (options?.client ?? client).get<StreamJobsResponses, unknown, ThrowOnError>({ url: '/system/jobs/stream', ...options });
/** /**
* Get Job * Get Job
* *
@@ -81,13 +95,6 @@ export const cancelJob = <ThrowOnError extends boolean = false>(options: Options
*/ */
export const retryJob = <ThrowOnError extends boolean = false>(options: Options<RetryJobData, ThrowOnError>) => (options.client ?? client).post<RetryJobResponses, RetryJobErrors, ThrowOnError>({ url: '/system/jobs/{job_id}/retry', ...options }); export const retryJob = <ThrowOnError extends boolean = false>(options: Options<RetryJobData, ThrowOnError>) => (options.client ?? client).post<RetryJobResponses, RetryJobErrors, ThrowOnError>({ url: '/system/jobs/{job_id}/retry', ...options });
/**
* Stream Jobs
*
* Server-Sent Events (SSE) endpoint for real-time job status updates.
*/
export const streamJobs = <ThrowOnError extends boolean = false>(options?: Options<StreamJobsData, ThrowOnError>) => (options?.client ?? client).get<StreamJobsResponses, unknown, ThrowOnError>({ url: '/system/jobs/stream', ...options });
/** /**
* Trigger Scan * Trigger Scan
* *
+52 -14
View File
@@ -1106,6 +1106,28 @@ export type SettingSchema = {
value: string; value: string;
}; };
/**
* StagingInfoSchema
*/
export type StagingInfoSchema = {
/**
* Path
*/
path: string;
/**
* Total Bytes
*/
total_bytes: number;
/**
* Used Bytes
*/
used_bytes: number;
/**
* Free Bytes
*/
free_bytes: number;
};
/** /**
* StorageProviderSchema * StorageProviderSchema
*/ */
@@ -1338,6 +1360,22 @@ export type GetDashboardStatsResponses = {
export type GetDashboardStatsResponse = GetDashboardStatsResponses[keyof GetDashboardStatsResponses]; export type GetDashboardStatsResponse = GetDashboardStatsResponses[keyof GetDashboardStatsResponses];
export type GetStagingInfoData = {
body?: never;
path?: never;
query?: never;
url: '/system/staging/info';
};
export type GetStagingInfoResponses = {
/**
* Successful Response
*/
200: StagingInfoSchema;
};
export type GetStagingInfoResponse = GetStagingInfoResponses[keyof GetStagingInfoResponses];
export type ListJobsData = { export type ListJobsData = {
body?: never; body?: never;
path?: never; path?: never;
@@ -1402,6 +1440,20 @@ export type GetJobStatsResponses = {
200: unknown; 200: unknown;
}; };
export type StreamJobsData = {
body?: never;
path?: never;
query?: never;
url: '/system/jobs/stream';
};
export type StreamJobsResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type GetJobData = { export type GetJobData = {
body?: never; body?: never;
path: { path: {
@@ -1520,20 +1572,6 @@ export type RetryJobResponses = {
200: unknown; 200: unknown;
}; };
export type StreamJobsData = {
body?: never;
path?: never;
query?: never;
url: '/system/jobs/stream';
};
export type StreamJobsResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type TriggerScanData = { export type TriggerScanData = {
body?: never; body?: never;
path?: never; path?: never;
+18 -2
View File
@@ -52,8 +52,10 @@
ignoreHardware, ignoreHardware,
listProviders, listProviders,
listSecrets, listSecrets,
getStagingInfo,
type MediaSchema, type MediaSchema,
type StorageProviderSchema type StorageProviderSchema,
type StagingInfoSchema
} from '$lib/api'; } from '$lib/api';
import { LTO_CAPACITY, PROVIDER_TEMPLATES, type LtoTapeCreateData, type OfflineHddCreateData, type CloudCreateData } from '$lib/types'; import { LTO_CAPACITY, PROVIDER_TEMPLATES, type LtoTapeCreateData, type OfflineHddCreateData, type CloudCreateData } from '$lib/types';
import { dndzone } from 'svelte-dnd-action'; import { dndzone } from 'svelte-dnd-action';
@@ -67,6 +69,7 @@
let loading = $state(true); let loading = $state(true);
let showRegisterDialog = $state(false); let showRegisterDialog = $state(false);
let editingMedia = $state<MediaSchema | null>(null); let editingMedia = $state<MediaSchema | null>(null);
let stagingInfo = $state<StagingInfoSchema | null>(null);
let activeMedia = $derived(mediaList.filter(m => m.status === 'active')); let activeMedia = $derived(mediaList.filter(m => m.status === 'active'));
let fullMedia = $derived(mediaList.filter(m => m.status === 'full')); let fullMedia = $derived(mediaList.filter(m => m.status === 'full'));
@@ -295,10 +298,20 @@
} }
} }
async function loadStagingInfo() {
try {
const res = await getStagingInfo();
if (res.data) stagingInfo = res.data;
} catch (error) {
console.error("Failed to load staging info:", error);
}
}
onMount(async () => { onMount(async () => {
// Initial load (non-silent and forced refresh to show live hardware status immediately) // Initial load (non-silent and forced refresh to show live hardware status immediately)
loadMedia(false, true); loadMedia(false, true);
loadSecrets(); loadSecrets();
loadStagingInfo();
try { try {
const res = await listProviders(); const res = await listProviders();
@@ -307,7 +320,10 @@
console.error("Failed to load storage providers:", error); console.error("Failed to load storage providers:", error);
} }
pollInterval = setInterval(pollHardware, POLL_SLOW); pollInterval = setInterval(() => {
pollHardware();
loadStagingInfo();
}, POLL_SLOW);
}); });
onDestroy(() => { onDestroy(() => {