-
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
- 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();
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: BackupType;
destination_ids?: number[];
filesystem_configurations?: BackupDestination[];
}
export type CleanupSchedule = ScheduleShared;
// 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