Building React UI - SameOldNick/laravel-backup-manager GitHub Wiki
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
- Adding Filtering & Pagination
- 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 all backups |
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 all destinations |
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'],
],
'download' => [
'middleware' => ['signed'],
],
],
];You can add additional middleware (e.g. can:manage-backups) by extending the management.middleware array.
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)
{
return Inertia::render('admin/backup-manager/index', [
'tab' => 'backups',
'action' => 'list',
'backups' => $data->backups,
]);
}
}Pagination: If you have a large number of backups, call
->paginate()on the collection in the responder and update the front-end to use Inertia's paginator props. The examples here pass the full collection for simplicity.
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) {
return back()->with('error', 'The backup job was not found.');
}
$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,
]);
}
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(CreateCleanupScheduleViewData $data);
public function renderStoreCleanupSchedule(StoreCleanupScheduleViewData $data);
public function renderEditCleanupSchedule(EditCleanupScheduleViewData $data);
public function renderUpdateCleanupSchedule(UpdateCleanupScheduleViewData $data);
public function renderDestroyCleanupSchedule(DestroyCleanupScheduleViewData $data);
}The schedule responders receive the data they need to render a form, so you do not have to re-derive it:
| View data | Fields |
|---|---|
CreateBackupScheduleViewData, CreateCleanupScheduleViewData
|
configurations β the destinations that can be attached |
EditBackupScheduleViewData, EditCleanupScheduleViewData
|
schedule, configurations β the destinations that can be offered, and selectedDestinationIds β the ids currently attached to the schedule |
configurations on the edit data contains every active destination plus any attached
destination that has since been deactivated, so a deactivated destination that is still
attached stays visible in the picker instead of being silently detached on the next save.
selectedDestinationIds is the set to pre-select; do not derive it by filtering
configurations on is_active, because active destinations that are not attached would be
pre-selected too.
Backup schedules submit the selection as destination_ids on the schedule itself, while the
cleanup schedule create and edit screens submit it as a top-level destination_ids field.
A schedule does not have to have any destinations. A schedule with none uses the destinations
configured for the app (backup.destination.disks) when it runs, which is also what every
schedule created before destination selection existed does. So an empty selection on the create
screen is valid, and sending destination_ids: [] on update is how an app clears the selection.
The one distinction to get right on the edit screen is absent vs. empty:
| Form sends | Effect |
|---|---|
destination_ids: [] |
Detaches every destination, falling back to the app's configured destinations |
destination_ids omitted |
Leaves the current attachments untouched |
Since the edit screen pre-selects selectedDestinationIds, a picked-at-least-one form naturally
sends the current set back, and an untouched form should omit the key entirely.
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: BackupType;
destination_ids?: number[];
filesystem_configurations?: BackupDestination[];
}
export interface CleanupSchedule extends ScheduleShared {
destination_ids?: number[];
filesystem_configurations?: BackupDestination[];
}
// Backups page props use discriminated union on `action`:
export type BackupBackupsPageProps =
| { action: "list"; backups: Backup[] }
| { 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" | "files" | "databases";
// Destinations page props:
export type BackupDestinationsPageProps =
| { action: "list"; destinations: 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. Use Inertia's usePage hook to read the shared props.
// index.tsx
import { Head, Link, router, usePage } from "@inertiajs/react";
import { Bell, ChevronRight } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import BackupsTab from "./tabs/backups";
import DestinationsTab from "./tabs/destinations";
import SchedulesTab from "./tabs/schedules";
import type { TabValue } from "./types";
const tabRoutes: { value: TabValue; route: () => string; label: string }[] = [
{
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: React.FC = () => {
const { tab } = usePage<{ tab: TabValue }>().props;
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" />
<div className="flex flex-col gap-4">
<Card>
<Tabs
defaultValue={tab || "backups"}
value={tab}
onValueChange={handleTabChange}
>
<TabsList>
<TabsTrigger value="backups">
<Bell className="h-5 w-5 ltr:mr-2 rtl:ml-2" />
Backups
</TabsTrigger>
<TabsTrigger value="destinations">
<Bell className="h-5 w-5 ltr:mr-2 rtl:ml-2" />
Destinations
</TabsTrigger>
<TabsTrigger value="schedules">
<Bell className="h-5 w-5 ltr:mr-2 rtl:ml-2" />
Schedules
</TabsTrigger>
</TabsList>
<TabsContent value="backups">
<BackupsTab />
</TabsContent>
<TabsContent value="destinations">
<DestinationsTab />
</TabsContent>
<TabsContent value="schedules">
<SchedulesTab />
</TabsContent>
</Tabs>
</Card>
</div>
</>
);
};
export default BackupManager;Wrap this page in your application layout using Inertia's standard pattern β either via a layout file or by wrapping the component in your own layout component. Inertia resolves the layout based on your HandleInertiaRequests middleware or the Inertia::render() call.
Each tab is a dispatcher component that reads the action prop and renders the appropriate sub-component.
Each tab reads the action prop from Inertia's shared page props and renders the appropriate sub-component:
// tabs/destinations.tsx
import { usePage } from "@inertiajs/react";
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 { props } = usePage<BackupDestinationsPageProps>();
const { action } = props;
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 |
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:
- Backup data from the server
- 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);
console.error("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 search and 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
import { router } from "@inertiajs/react";
import { useEffect, useRef, useState } from "react";
const BackupRunTerminal = ({
uuid,
type,
startUrl,
}: {
uuid: string;
type: BackupType;
startUrl: string;
}) => {
const outputRef = useRef<HTMLDivElement>(null);
const [startError, setStartError] = useState<string | null>(null);
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(() => {
router.post(
startUrl,
{},
{
onError: (errors) =>
setStartError(errors?.message || "Failed to start backup."),
},
);
}, [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>
);The examples above pass full collections for simplicity. If you need client-side filtering, you can add it with local state:
const [search, setSearch] = useState("");
const filtered = backups.filter((b) =>
b.file?.name?.toLowerCase().includes(search.toLowerCase()),
);For server-side pagination, call ->paginate() on the collection in the responder and handle the paginator props in your component using Inertia's built-in support.
export type BackupType = "full" | "files" | "databases";
export const backupTypeLabels: Record<BackupType, string> = {
full: "Full Backup",
files: "File Backup",
databases: "Database 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: Record<BackupType, string> = {
full: "Full Backup",
files: "Files Backup",
databases: "Database 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"
}-
@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 -
Add filtering and pagination as needed β the examples pass full collections; add client-side filtering or
->paginate()if desired -
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