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
class StagingInfoSchema(BaseModel):
path: str
total_bytes: int
used_bytes: int
free_bytes: int
class JobSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
+38 -1
View File
@@ -1,7 +1,10 @@
import shutil
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
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 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,
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:
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)")
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 { 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> & {
/**
@@ -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 });
/**
* 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
*
@@ -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 });
/**
* 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
*
@@ -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 });
/**
* 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
*
+52 -14
View File
@@ -1106,6 +1106,28 @@ export type SettingSchema = {
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
*/
@@ -1338,6 +1360,22 @@ export type 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 = {
body?: never;
path?: never;
@@ -1402,6 +1440,20 @@ export type GetJobStatsResponses = {
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 = {
body?: never;
path: {
@@ -1520,20 +1572,6 @@ export type RetryJobResponses = {
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 = {
body?: never;
path?: never;
+18 -2
View File
@@ -52,8 +52,10 @@
ignoreHardware,
listProviders,
listSecrets,
getStagingInfo,
type MediaSchema,
type StorageProviderSchema
type StorageProviderSchema,
type StagingInfoSchema
} from '$lib/api';
import { LTO_CAPACITY, PROVIDER_TEMPLATES, type LtoTapeCreateData, type OfflineHddCreateData, type CloudCreateData } from '$lib/types';
import { dndzone } from 'svelte-dnd-action';
@@ -67,6 +69,7 @@
let loading = $state(true);
let showRegisterDialog = $state(false);
let editingMedia = $state<MediaSchema | null>(null);
let stagingInfo = $state<StagingInfoSchema | null>(null);
let activeMedia = $derived(mediaList.filter(m => m.status === 'active'));
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 () => {
// Initial load (non-silent and forced refresh to show live hardware status immediately)
loadMedia(false, true);
loadSecrets();
loadStagingInfo();
try {
const res = await listProviders();
@@ -307,7 +320,10 @@
console.error("Failed to load storage providers:", error);
}
pollInterval = setInterval(pollHardware, POLL_SLOW);
pollInterval = setInterval(() => {
pollHardware();
loadStagingInfo();
}, POLL_SLOW);
});
onDestroy(() => {