-
Notifications
You must be signed in to change notification settings - Fork 0
Building React UI
This guide walks you through building a React frontend for the sameoldnick/laravel-backup-manager package using Inertia.js. The package provides backend responders that render Inertia pages — you implement the React components that display them.
- Architecture Overview
- Package Routes
- Responder Interfaces
- TypeScript Types
- React Component Structure
- Main Page: Tabbed Layout
- Backups Tab
- Destinations Tab
- Schedules Tab
- Real-Time Features
- Hooks
- Shared Components
The package follows a responder pattern. Each controller action delegates view rendering to a responder interface. You implement these interfaces as Inertia responders that call Inertia::render() with your page component and the data the package supplies.
┌──────────────────────────────────────────────────┐
│ Package (sameoldnick/laravel-backup-manager) │
│ ┌─────────────┐ ┌──────────────────────────┐ │
│ │ Controllers │───▶│ Responder Interfaces │ │
│ │ │ │ (Contracts) │ │
│ └─────────────┘ └──────────┬───────────────┘ │
└────────────────────────────────┼──────────────────┘
│ You implement
▼
┌──────────────────────────────────────────────────┐
│ Your App │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ Responder Impls │ │ React Components │ │
│ │ (Inertia::render) │◀─│ (pages/admin/...) │ │
│ └────────────────────┘ └────────────────────┘ │
└──────────────────────────────────────────────────┘
Each responder implementation receives a View Data DTO from the package. Your job is to map that DTO into Inertia props that your React component understands.
The package registers these routes under a configurable prefix (default: /backup):
| Method | URI | Name | Purpose |
|---|---|---|---|
GET |
/backups |
backup.backups.index |
List backups (paginated) |
GET |
/backups/{backup}/download |
backup.backups.download |
Generate signed download link |
POST |
/perform |
backup.perform.initialize |
Initialize a backup run |
POST |
/perform/start |
backup.perform.start |
Start the backup (signed) |
GET |
/perform/{type}/{uuid} |
backup.perform.show |
Show the backup terminal (signed) |
GET |
/destinations |
backup.destinations.index |
List destinations (paginated) |
GET |
/destinations/create |
backup.destinations.create |
Create destination form |
POST |
/destinations |
backup.destinations.store |
Store new destination |
GET |
/destinations/{destination} |
backup.destinations.show |
Show/edit destination |
PUT |
/destinations/{destination} |
backup.destinations.update |
Update destination |
POST |
/destinations/{destination}/test |
backup.destinations.test |
Test destination connection |
GET |
/destinations/{destination}/test/{uuid} |
backup.destinations.test.result |
Show test result (signed) |
DELETE |
/destinations/{destination} |
backup.destinations.destroy |
Delete destination |
GET |
/schedules |
backup.schedules.index |
List all schedules |
GET |
/schedules/backup/create |
backup.schedules.backup.create |
Create backup schedule form |
POST |
/schedules/backup |
backup.schedules.backup.store |
Store backup schedule |
GET |
/schedules/backup/{schedule}/edit |
backup.schedules.backup.edit |
Edit backup schedule form |
PUT |
/schedules/backup/{schedule} |
backup.schedules.backup.update |
Update backup schedule |
DELETE |
/schedules/backup/{schedule} |
backup.schedules.backup.destroy |
Delete backup schedule |
GET |
/schedules/cleanup/create |
backup.schedules.cleanup.create |
Create cleanup schedule form |
POST |
/schedules/cleanup |
backup.schedules.cleanup.store |
Store cleanup schedule |
GET |
/schedules/cleanup/{schedule}/edit |
backup.schedules.cleanup.edit |
Edit cleanup schedule form |
PUT |
/schedules/cleanup/{schedule} |
backup.schedules.cleanup.update |
Update cleanup schedule |
DELETE |
/schedules/cleanup/{schedule} |
backup.schedules.cleanup.destroy |
Delete cleanup schedule |
GET |
/files/{file} |
backup.file |
Download a backup file (signed) |
// config/backup-manager.php
return [
'routes' => [
'enabled' => true,
'all' => [
'middleware' => ['web'],
'prefix' => '/backup',
'as' => 'backup.',
],
'management' => [
'middleware' => ['auth', 'can:manage-backups'],
],
'download' => [
'middleware' => ['signed'],
],
],
];The package defines six responder contracts. You need to implement each one:
namespace SameOldNick\BackupManager\Contracts\Responders;
interface BackupsUiResponder
{
public function renderBackupsList(BackupsListViewData $data);
}View Data — BackupsListViewData:
-
$data->backups—LengthAwarePaginatorof backup records
Example Implementation:
use Inertia\Inertia;
use SameOldNick\BackupManager\Contracts\Responders\BackupsUiResponder as Contract;
use SameOldNick\BackupManager\DataTransferObjects\Responders\Backups\BackupsListViewData;
class BackupsUiResponder implements Contract
{
public function renderBackupsList(BackupsListViewData $data)
{
$request = request();
return Inertia::render('admin/backup-manager/index', [
'tab' => 'backups',
'action' => 'list',
'backups' => $data->backups->paginate(
$request->query('per_page', 15)
)->appends($request->query()),
'filters' => [
'status' => $request->query('status', 'all'),
'search' => $request->query('search', ''),
'show' => $request->query('per_page', '15'),
],
]);
}
}namespace SameOldNick\BackupManager\Contracts\Responders;
interface PerformBackupUiResponder
{
public function renderInitializeBackup(InitializeBackupViewData $data);
public function renderStartBackup(StartBackupViewData $data);
public function renderPerformBackup(PerformBackupViewData $data);
}Key method — renderPerformBackup renders the page where the backup terminal appears:
public function renderPerformBackup(PerformBackupViewData $data)
{
if (! $data->lease) {
toast(__('The backup job was not found.'), 'error');
return back();
}
$startUrl = url()->temporarySignedRoute('backup.perform.start',
$data->lease->expiresAt, [
'type' => $data->type,
'uuid' => $data->uuid,
]);
return Inertia::render('admin/backup-manager/index', [
'tab' => 'backups',
'action' => 'list',
'performing_backup' => [
'uuid' => $data->uuid,
'type' => $data->type,
'start_url' => $startUrl,
],
]);
}The renderInitializeBackup method typically redirects to a signed URL that triggers renderPerformBackup:
public function renderInitializeBackup(InitializeBackupViewData $data)
{
return redirect()->temporarySignedRoute('backup.perform.show',
$data->lease->expiresAt, [
'type' => $data->type,
'uuid' => $data->uuid,
]);
}namespace SameOldNick\BackupManager\Contracts\Responders;
interface SchedulesUiResponder
{
public function renderSchedulesList(SchedulesListViewData $data);
}View Data — SchedulesListViewData:
-
$data->backupSchedules— Collection of backup schedules -
$data->cleanupSchedules— Collection of cleanup schedules
public function renderSchedulesList(SchedulesListViewData $data)
{
return Inertia::render('admin/backup-manager/index', [
'tab' => 'schedules',
'action' => 'list',
'backupSchedules' => $data->backupSchedules,
'cleanupSchedules' => $data->cleanupSchedules,
]);
}namespace SameOldNick\BackupManager\Contracts\Responders;
interface BackupDestinationsUiResponder
{
public function renderBackupDestinationsList(BackupDestinationsListViewData $data);
public function renderCreateBackupDestination();
public function renderStoreBackupDestination(StoreBackupDestinationViewData $data);
public function renderEditBackupDestination(EditBackupDestinationViewData $data);
public function renderUpdateBackupDestination(UpdateBackupDestinationViewData $data);
public function renderDestroyBackupDestination(DestroyBackupDestinationViewData $data);
public function renderBackupDestinationTestResult(BackupDestinationTestResultViewData $data);
}Key methods:
public function renderBackupDestinationsList(BackupDestinationsListViewData $data)
{
return Inertia::render('admin/backup-manager/index', [
'tab' => 'destinations',
'action' => 'list',
'destinations' => $data->backupDestinations->paginate(
request()->query('perPage', 10),
request()->query('page', 1)
),
]);
}
public function renderCreateBackupDestination()
{
return Inertia::render('admin/backup-manager/index', [
'tab' => 'destinations',
'action' => 'create',
]);
}
public function renderEditBackupDestination(EditBackupDestinationViewData $data)
{
return Inertia::render('admin/backup-manager/index', [
'tab' => 'destinations',
'action' => 'edit',
'destination' => $data->configuration,
'enabled' => $data->configuration->isEnabled($data->backupConfig),
]);
}
public function renderBackupDestinationTestResult(BackupDestinationTestResultViewData $data)
{
return Inertia::render('admin/backup-manager/index', [
'tab' => 'destinations',
'action' => 'edit',
'destination' => $data->configuration,
'enabled' => $data->configuration->isEnabled($data->backupConfig),
'testUuid' => $data->uuid,
]);
}namespace SameOldNick\BackupManager\Contracts\Responders;
interface BackupSchedulesUiResponder
{
public function renderCreateBackupSchedule(CreateBackupScheduleViewData $data);
public function renderStoreBackupSchedule(StoreBackupScheduleViewData $data);
public function renderEditBackupSchedule(EditBackupScheduleViewData $data);
public function renderUpdateBackupSchedule(UpdateBackupScheduleViewData $data);
public function renderDestroyBackupSchedule(DestroyBackupScheduleViewData $data);
}namespace SameOldNick\BackupManager\Contracts\Responders;
interface CleanupSchedulesUiResponder
{
public function renderCreateCleanupSchedule();
public function renderStoreCleanupSchedule(StoreCleanupScheduleViewData $data);
public function renderEditCleanupSchedule(EditCleanupScheduleViewData $data);
public function renderUpdateCleanupSchedule(UpdateCleanupScheduleViewData $data);
public function renderDestroyCleanupSchedule(DestroyCleanupScheduleViewData $data);
}Define these types to match the data your responders send:
// types.d.ts
export type TabValue = 'backups' | 'destinations' | 'schedules';
// ===== Backup Types =====
export type BackupStatus =
| 'successful'
| 'failed'
| 'file_not_found'
| 'deleted';
export interface FileMeta {
size: number;
last_modified: string;
mime_type: string;
}
export interface File {
id: string;
name: string;
meta: FileMeta;
file_exists: boolean;
created_at: string;
updated_at: string | null;
}
export interface Backup {
uuid: string;
status: BackupStatus;
error_message?: string;
created_at: string;
updated_at: string | null;
deleted_at: string | null;
file: File | null;
}
// ===== Destination Types =====
export interface BackupDestination {
id: number;
is_active: boolean;
name: string;
type: 'local' | 'ftp' | 'sftp';
root?: string;
host?: string;
port?: number;
auth_type: 'password' | 'key';
username?: string;
}
// ===== Schedule Types =====
export interface ScheduleShared {
id: number;
name: string;
cron_expression: string;
next_run: string | null;
is_active: boolean;
created_at: string;
updated_at: string | null;
}
export interface BackupSchedule extends ScheduleShared {
type: 'only_files' | 'only_databases' | 'full';
destination_ids?: number[];
filesystem_configurations?: BackupDestination[];
}
export type CleanupSchedule = ScheduleShared;
// ===== Page Props =====
export type BackupManagerPageProps = {
tab: TabValue;
};
export type Filters = {
status: string;
search: string;
show: string;
};
// Backups page props use discriminated union on `action`:
export type BackupBackupsPageProps =
| { action: 'list'; backups: PaginateResponse<Backup>; filters: Filters }
| { action: 'list'; backups: undefined; performing_backup: PerformingBackup }
| { action: 'show'; backup: Backup };
export interface PerformingBackup {
uuid: string;
type: BackupType;
start_url: string;
}
export type BackupType = 'full' | 'database' | 'files';
// Destinations page props:
export type BackupDestinationsPageProps =
| { action: 'list'; destinations: PaginateResponse<BackupDestination> }
| { action: 'create' }
| { action: 'edit'; destination: BackupDestination; enabled: boolean; testUuid?: string };
// Schedules page props:
export type BackupSchedulesPageProps =
| { action: 'list'; backupSchedules: BackupSchedule[]; cleanupSchedules: CleanupSchedule[] }
| { action: 'create:backup'; destinations: BackupDestination[] }
| { action: 'create:cleanup' }
| { action: 'edit:backup'; schedule: BackupSchedule; destinations: BackupDestination[] }
| { action: 'edit:cleanup'; schedule: CleanupSchedule };Here's the recommended file structure:
resources/js/pages/admin/backup-manager/
├── index.tsx # Main page (tabbed layout)
├── types.d.ts # All TypeScript types
├── tabs/
│ ├── backups.tsx # Backups tab dispatcher
│ ├── destinations.tsx # Destinations tab dispatcher
│ └── schedules.tsx # Schedules tab dispatcher
├── hooks/
│ ├── use-job.ts # WebSocket job monitoring
│ ├── use-process.ts # WebSocket process monitoring
│ └── use-xterm.ts # xterm.js terminal integration
├── components/
│ ├── shared/
│ │ ├── section-container.tsx # Page section wrapper
│ │ └── table-container.tsx # Table wrapper
│ ├── backups/
│ │ ├── constants.ts # Backup type/status labels
│ │ ├── container.tsx # Backup list with filters + run actions
│ │ ├── table.tsx # Backup table rows
│ │ ├── details/
│ │ │ └── details.tsx # Backup detail modal
│ │ └── run/
│ │ ├── confirm.tsx # Confirm backup run dialog
│ │ ├── modal.tsx # Backup progress modal
│ │ └── terminal.tsx # xterm.js terminal in modal
│ ├── destinations/
│ │ ├── actions/
│ │ │ ├── constants.ts # Zod validation schemas
│ │ │ ├── list/
│ │ │ │ ├── container.tsx # Destination list with filters
│ │ │ │ └── table.tsx # Destination table rows
│ │ │ ├── create/
│ │ │ │ ├── container.tsx # Create page wrapper
│ │ │ │ └── form.tsx # Create form
│ │ │ ├── edit/
│ │ │ │ ├── container.tsx # Edit page wrapper
│ │ │ │ └── form.tsx # Edit form
│ │ │ └── test/
│ │ │ └── modal.tsx # Connection test progress modal
│ │ └── fields/
│ │ ├── main-fields.tsx # Name, type, enabled fields
│ │ ├── protocol-fields.tsx # Protocol-specific fields
│ │ ├── local-fields.tsx # Local root path field
│ │ ├── ftp-fields.tsx # FTP host/port fields
│ │ ├── sftp-fields.tsx # SFTP host/port fields
│ │ ├── connection-fields.tsx # Auth type toggle
│ │ ├── login-fields.tsx # Username/password fields
│ │ ├── key-fields.tsx # Private key/passphrase fields
│ │ └── root-field.tsx # Root path field
│ └── schedules/
│ ├── constants.ts # Zod schemas, cron presets, backup types
│ ├── list/
│ │ ├── container.tsx # Schedule list with filters
│ │ └── table.tsx # Combined backup + cleanup schedule table
│ ├── fields/
│ │ ├── backup-schedule-fields.tsx
│ │ ├── cleanup-schedule-fields.tsx
│ │ └── shared/
│ │ ├── destination-selector-field.tsx
│ │ └── cron-expression-field.tsx
│ └── actions/
│ ├── create/
│ │ ├── backup/
│ │ │ ├── container.tsx
│ │ │ └── form.tsx
│ │ └── cleanup/
│ │ ├── container.tsx
│ │ └── form.tsx
│ └── edit/
│ ├── backup/
│ │ ├── container.tsx
│ │ └── form.tsx
│ └── cleanup/
│ ├── container.tsx
│ └── form.tsx
The entry point is a single Inertia page that uses the tab prop to determine which tab content to render. All responders render the same page component with different props.
// index.tsx
import { Head, router } from '@inertiajs/react';
import { Link } from '@inertiajs/react';
import { Bell, ChevronRight } from 'lucide-react';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { Card } from '@/components/ui/card';
import {
Tabs,
TabsContent,
UnderlineTabsList,
UnderlineTabsTrigger,
} from '@/components/ui/tabs';
import { usePagePropsContext } from '@/contexts/usePagePropsContext';
import { providesPage } from '@/hoc/providesPage';
import AppLayout from '@/layouts/app-layout';
import BackupsTab from './tabs/backups';
import DestinationsTab from './tabs/destinations';
import SchedulesTab from './tabs/schedules';
import type { BackupManagerPageProps } from './types';
const tabRoutes = [
{
value: 'backups',
route: () => route('backup.backups.index'),
label: 'Backups',
},
{
value: 'destinations',
route: () => route('backup.destinations.index'),
label: 'Destinations',
},
{
value: 'schedules',
route: () => route('backup.schedules.index'),
label: 'Schedules',
},
];
const BackupManager = providesPage(
() => {
const { pageProps: { tab } } = usePagePropsContext<BackupManagerPageProps>();
const handleTabChange = (value: string) => {
const tabRoute = tabRoutes.find((r) => r.value === value)?.route;
router.get(
tabRoute ? tabRoute() : route('backup.backups.index'),
{},
{ preserveState: true, preserveScroll: true, replace: true },
);
};
return (
<>
<Head title="Backup Manager" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={route('dashboard')}>Dashboard</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator>
<ChevronRight className="size-4" />
</BreadcrumbSeparator>
<BreadcrumbItem isLast>
<BreadcrumbPage>Backup Manager</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<div className="flex min-h-[calc(100vh-212px)] flex-col gap-4">
<div className="grid grid-cols-1 gap-4">
<Card>
<Tabs
defaultValue={tab || 'backups'}
value={tab}
onValueChange={handleTabChange}
>
<UnderlineTabsList>
<UnderlineTabsTrigger value="backups">
<Bell className="h-5 w-5 ltr:mr-2 rtl:ml-2" />
Backups
</UnderlineTabsTrigger>
<UnderlineTabsTrigger value="destinations">
<Bell className="h-5 w-5 ltr:mr-2 rtl:ml-2" />
Destinations
</UnderlineTabsTrigger>
<UnderlineTabsTrigger value="schedules">
<Bell className="h-5 w-5 ltr:mr-2 rtl:ml-2" />
Schedules
</UnderlineTabsTrigger>
</UnderlineTabsList>
<TabsContent value="backups">
<BackupsTab />
</TabsContent>
<TabsContent value="destinations">
<DestinationsTab />
</TabsContent>
<TabsContent value="schedules">
<SchedulesTab />
</TabsContent>
</Tabs>
</Card>
</div>
</div>
</>
);
},
{ layout: AppLayout },
);
export default BackupManager;Each tab is a dispatcher component that reads the action prop and renders the appropriate sub-component.
// tabs/destinations.tsx
import { usePagePropsContext } from '@/contexts/usePagePropsContext';
import CreateDestinationContainer from '../components/destinations/actions/create/container';
import EditDestinationContainer from '../components/destinations/actions/edit/container';
import DestinationsListContainer from '../components/destinations/actions/list/container';
import type { BackupDestinationsPageProps } from '../types';
const DestinationsTab: React.FC = () => {
const { pageProps: { action } } = usePagePropsContext<BackupDestinationsPageProps>();
return (
<>
{action === 'list' && <DestinationsListContainer />}
{action === 'create' && <CreateDestinationContainer />}
{action === 'edit' && <EditDestinationContainer />}
</>
);
};The Backups tab has three sub-views driven by the action prop and the presence of performing_backup:
| Condition | Rendered Component |
|---|---|
action === 'list' + backups defined |
Backup list table with filters and pagination |
action === 'list' + performing_backup defined |
Backup list with the backup-in-progress modal |
action === 'show' |
Backup detail modal |
This is the most complex component. It handles:
- Paginated backup data via
usePagination - Filtering by status (All, Successful, Failed, Deleted)
- Text search with debounced Inertia reload
- "Perform Backup" dropdown to trigger backup runs
- Backup run confirmation dialog → initialize → progress modal
// Key flow for performing a backup:
const showConfirmModal = useCallback((type: BackupType) => {
setBackupStep({ step: 'confirm', type });
}, []);
const handleRunClick = useCallback(async (type: BackupType) => {
try {
router.post(
route('backup.perform.initialize'),
{ type },
{ preserveState: true },
);
} catch {
setBackupStep(undefined);
toast({ toast_type: 'error', message: 'Failed to start backup.' });
}
}, []);The component conditionally renders:
-
BackupRunConfirm— a dialog asking the user to confirm the backup type -
BackupRunModal— the modal with an xterm.js terminal showing real-time progress
When performingBackup is present, a modal opens showing the backup in progress:
// components/backups/run/modal.tsx
<Dialog open={isOpen} onOpenChange={handleRunModalOpenChange}>
<DialogContent
className="sm:max-w-4xl"
closeable={false}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>{backupTypeLabels[type]}</DialogTitle>
<DialogDescription>
Running a backup may take a while. Monitor progress in
real-time through the terminal below.
</DialogDescription>
</DialogHeader>
<BackupRunTerminal
uuid={uuid}
type={type}
startUrl={startUrl}
/>
<DialogFooter>
<Button variant="outline" onClick={handleRequestClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>Important: The modal prevents closing via escape key or outside click while the backup is running. A confirmation dialog appears if the user tries to close.
Shows full metadata for a backup record: UUID, timestamps, file info (name, size, MIME type), and error messages when present.
The Destinations tab has three sub-views:
action |
Rendered Component |
|---|---|
list |
Destination list table with pagination, search, status filter |
create |
Create destination form |
edit |
Edit destination form (+ test connection modal) |
The form supports three storage types via Zod validation:
| Type | Required Fields |
|---|---|
local |
root (path) |
ftp |
host, port, username, password
|
sftp |
host, port, auth type (password → username/password or key → private_key/passphrase) |
// Validation schema (Zod):
export const destinationBaseObject = z.object({
enabled: z.boolean(),
name: z.string().min(1).max(255),
type: z.enum(['local', 'ftp', 'sftp']),
host: z.string().optional(),
port: z.number().optional(),
auth_type: z.enum(['password', 'key']).optional(),
root: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
confirm_password: z.string().optional(),
private_key: z.string().optional(),
passphrase: z.string().optional(),
}).superRefine((data, ctx) => {
// Conditional validation based on type
if (data.type === 'local' && !data.root) {
ctx.addIssue({ code: 'custom', message: 'Root path required', path: ['root'] });
}
// ... etc
});The edit page supports testing the destination connection. When a test is triggered, the backend broadcasts job status updates over a WebSocket channel (test-destination.{uuid}). A modal shows the test progress.
The Schedules tab has five sub-views:
action |
Rendered Component |
|---|---|
list |
Combined backup + cleanup schedule table |
create:backup |
Create backup schedule form |
create:cleanup |
Create cleanup schedule form |
edit:backup |
Edit backup schedule form |
edit:cleanup |
Edit cleanup schedule form |
The list combines backup schedules and cleanup schedules in one table, separated by section headers. Each row shows:
- Checkbox for bulk selection
- Schedule name
- Cron expression (with human-readable next run time)
- Next run date (parsed from cron)
- Active/inactive toggle
- Edit/delete actions
Fields:
- Name — identifier for the schedule
- Type — Files Backup, Database Backup, or Full Backup
- Destinations — multi-select of configured destinations
- Active — enable/disable toggle
- Cron Expression — cron expression field with presets (hourly, daily, weekly, monthly, custom)
Simpler than backup schedules — just:
- Name
- Active toggle
- Cron Expression
The backup run and destination test features use Laravel Reverb for real-time communication. The backend broadcasts events on WebSocket channels.
- User clicks "Perform Backup" → confirmation dialog
- Frontend POSTs to
backup.perform.initialize - Backend creates a lease and redirects to a signed URL
- Frontend renders the
BackupRunModalwith the signedstart_url - Frontend POSTs to
start_urlto begin the backup - Backend broadcasts on channel
backups.{uuid}:-
.job-status—pending→started→completed/failed -
.process-status—begin→complete -
.process-output— streaming terminal output lines
-
- Frontend renders output in xterm.js terminal
Job Status Notification:
{
date_time: string; // ISO timestamp
status: 'pending' | 'started' | 'completed' | 'failed';
extra?: {
exception?: string;
};
}Process Status Notification:
{
date_time: string;
status: 'begin' | 'complete';
}Process Output Notification:
{
date_time: string;
message: string; // A line of terminal output
newline: boolean; // Whether a newline follows
}Listens for job status updates on a WebSocket channel.
// hooks/use-job.ts
import { useEcho } from '@laravel/echo-react';
import { useCallback, useState } from 'react';
export type JobStatus = 'pending' | 'started' | 'completed' | 'failed';
export interface UseJobOptions {
channel: string; // e.g., "backups.{uuid}"
}
const useJob = ({ channel }: UseJobOptions) => {
const [jobStatus, setJobStatus] = useState<JobStatus>('pending');
const [jobStartedAt, setJobStartedAt] = useState<Date | null>(null);
const [jobFinishedAt, setJobFinishedAt] = useState<Date | null>(null);
const [jobLiveStartedAt, setJobLiveStartedAt] = useState<Date | null>(null);
const jobStatusSubscription = useEcho(
channel,
'.job-status',
(e: { date_time: string; status: JobStatus }) => {
setJobStatus(e.status);
// Track timing...
},
);
const unsubscribe = useCallback(() => {
jobStatusSubscription.leave();
}, [jobStatusSubscription]);
return { jobStatus, jobStartedAt, jobFinishedAt, jobLiveStartedAt, unsubscribe };
};Listens for process status and streaming output.
// hooks/use-process.ts
export interface UseProcessOptions {
channel: string;
onProcessOutput?: (output: string, newline: boolean) => void;
}
const useProcess = ({ channel, onProcessOutput }: UseProcessOptions) => {
const [processStatus, setProcessStatus] = useState<'idle' | 'begin' | 'complete'>('idle');
const processStatusSubscription = useEcho(
channel,
'.process-status',
(e: { date_time: string; status: 'begin' | 'complete' }) => {
setProcessStatus(e.status);
},
);
const processOutputSubscription = useEcho(
channel,
'.process-output',
(e: { date_time: string; message: string; newline: boolean }) => {
onProcessOutput?.(e.message, e.newline);
},
);
// ... unsubscribe
};Manages an xterm.js terminal instance. The terminal is initialized when the container ref is available and disposed on unmount.
// hooks/use-xterm.ts
import { Terminal } from '@xterm/xterm';
const useXterm = ({ containerRef }: { containerRef: React.RefObject<HTMLElement | null> }) => {
const terminalRef = useRef<Terminal | null>(null);
const initializeTerminal = useCallback((options?, addons = []) => {
if (!containerRef?.current) return;
const terminal = new Terminal(options);
addons.forEach(addon => terminal.loadAddon(addon));
terminal.open(containerRef.current);
terminalRef.current = terminal;
}, [containerRef]);
const disposeTerminal = useCallback(() => {
terminalRef.current?.dispose();
terminalRef.current = null;
}, []);
return { getTerminal: () => terminalRef.current, initializeTerminal, disposeTerminal };
};// components/backups/run/terminal.tsx
const BackupRunTerminal = ({ uuid, type, startUrl }) => {
const outputRef = useRef<HTMLDivElement>(null);
const { post } = useHttp();
const { getTerminal, initializeTerminal, disposeTerminal } = useXterm({
containerRef: outputRef,
});
const { jobStatus, jobStartedAt, jobFinishedAt, unsubscribe: jobUnsubscribe } =
useJob({ channel: `backups.${uuid}` });
const { processStatus, unsubscribe: processUnsubscribe } = useProcess({
channel: `backups.${uuid}`,
onProcessOutput: (nextOutput, newline) => {
const terminal = getTerminal();
if (!terminal) return;
nextOutput.split(/\n/).forEach((line, i, lines) => {
if (i !== lines.length - 1 || newline) {
terminal.writeln(line);
} else {
terminal.write(line);
}
});
},
});
useEffect(() => {
initializeTerminal();
return () => {
jobUnsubscribe();
processUnsubscribe();
disposeTerminal();
};
}, []);
// POST to start the backup
useEffect(() => {
post(startUrl, {
onError: (errors) => setStartError(errors?.message),
});
}, [post, startUrl]);
// ... render status message, duration timer, and terminal output div
};A consistent page section wrapper with optional title, description, and header actions:
interface BackupSectionContainerProps {
title?: string;
description?: string;
children: React.ReactNode;
className?: string;
headerActions?: React.ReactNode;
}A simple wrapper that provides consistent overflow and background styling for tables:
const BackupTableContainer: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
children, className, ...props
}) => (
<div className={cn('overflow-hidden bg-white dark:bg-slate-900', className)} {...props}>
{children}
</div>
);All list views (backups, destinations, schedules) follow the same pattern for filtering:
// 1. Local state for filter values
const [status, setStatus] = useState<string>(filters?.status || 'all');
const [search, setSearch] = useState<string>(filters?.search || '');
// 2. Debounced Inertia reload
const refresh = useDebounce(({ status, query }) => {
router.get(
route('backup.backups.index', { status, query }),
{},
{ preserveState: true },
);
}, 1000);
// 3. Handlers update local state and trigger debounced reload
const handleStatusChange = (value: string) => {
setStatus(value);
refresh({ status: value !== 'all' ? value : undefined, query: search });
};
const handleSearchChange = (value: string) => {
setSearch(value);
refresh({ status, query: value || undefined });
};This ensures the URL stays in sync with the filters, enabling browser back/forward navigation and shareable URLs.
export type BackupType = 'full' | 'database' | 'files';
export const backupTypeLabels: Record<BackupType, string> = {
full: 'Full Backup',
database: 'Database Backup',
files: 'File Backup',
};export const backupStatusLabels: Record<BackupStatus, string> = {
successful: 'Successful',
failed: 'Failed',
file_not_found: 'File Not Found',
deleted: 'Deleted',
};export const protocols = {
local: 'Local',
ftp: 'FTP',
sftp: 'SFTP',
};
export const authTypes = {
password: 'Password',
key: 'Key',
};export const backupTypes = {
only_files: 'Files Backup',
only_databases: 'Database Backup',
full: 'Full Backup',
};
export const cronPresets = {
hourly: { label: 'Hourly', value: '0 * * * *' },
daily: { label: 'Daily', value: '0 0 * * *' },
weekly: { label: 'Weekly', value: '0 0 * * 0' },
monthly: { label: 'Monthly', value: '0 0 1 * *' },
};Your React frontend will need these packages:
{
"@inertiajs/react": "^3.0",
"@laravel/echo-react": "^1.0",
"@xterm/xterm": "^5.0",
"cron-parser": "^4.0",
"date-fns": "^3.0",
"laravel-echo": "^1.0",
"pusher-js": "^8.0",
"zod": "^3.0",
"react-hook-form": "^7.0",
"string": "^3.0"
}-
@xterm/xterm— Terminal emulator for real-time backup output -
cron-parser— Parse and display next run times from cron expressions -
@laravel/echo-react— React hook for Laravel Echo WebSocket subscriptions -
zod+react-hook-form— Form validation and management -
date-fns— Date formatting and duration calculation
- Implement the six responder interfaces — each maps a View Data DTO to Inertia props
-
Build a single tabbed page — all responders render the same component with
tab+actionprops -
Use discriminated unions — the
actionprop determines which sub-component renders - Follow the filter pattern — debounced Inertia reloads with URL-synced state
-
Real-time features — use
useEchoto subscribe tobackups.{uuid}channels for job status, process status, and streaming output - xterm.js — render backup output in a terminal emulator for a professional feel