Skip to content

Building React UI

Nick Hamnett edited this page Jun 11, 2026 · 2 revisions

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.

Table of Contents

  1. Architecture Overview
  2. Package Routes
  3. Responder Interfaces
  4. TypeScript Types
  5. React Component Structure
  6. Main Page: Tabbed Layout
  7. Backups Tab
  8. Destinations Tab
  9. Schedules Tab
  10. Real-Time Features
  11. Hooks
  12. Shared Components

Architecture Overview

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.


Package Routes

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)

Route Configuration

// config/backup-manager.php
return [
    'routes' => [
        'enabled' => true,
        'all' => [
            'middleware' => ['web'],
            'prefix' => '/backup',
            'as' => 'backup.',
        ],
        'management' => [
            'middleware' => ['auth', 'can:manage-backups'],
        ],
        'download' => [
            'middleware' => ['signed'],
        ],
    ],
];

Responder Interfaces

The package defines six responder contracts. You need to implement each one:

1. BackupsUiResponder

namespace SameOldNick\BackupManager\Contracts\Responders;

interface BackupsUiResponder
{
    public function renderBackupsList(BackupsListViewData $data);
}

View DataBackupsListViewData:

  • $data->backupsLengthAwarePaginator of 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'),
            ],
        ]);
    }
}

2. PerformBackupUiResponder

namespace SameOldNick\BackupManager\Contracts\Responders;

interface PerformBackupUiResponder
{
    public function renderInitializeBackup(InitializeBackupViewData $data);
    public function renderStartBackup(StartBackupViewData $data);
    public function renderPerformBackup(PerformBackupViewData $data);
}

Key methodrenderPerformBackup 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,
        ]);
}

3. SchedulesUiResponder

namespace SameOldNick\BackupManager\Contracts\Responders;

interface SchedulesUiResponder
{
    public function renderSchedulesList(SchedulesListViewData $data);
}

View DataSchedulesListViewData:

  • $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,
    ]);
}

4. BackupDestinationsUiResponder

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,
    ]);
}

5. BackupSchedulesUiResponder

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);
}

6. CleanupSchedulesUiResponder

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);
}

TypeScript Types

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 };

React Component Structure

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

Main Page: Tabbed Layout

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.

Tab Dispatcher Pattern

// 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 />}
        </>
    );
};

Backups Tab

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

Backup List Container

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:

  1. BackupRunConfirm — a dialog asking the user to confirm the backup type
  2. BackupRunModal — the modal with an xterm.js terminal showing real-time progress

Backup Run Modal

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.

Backup Detail Modal

Shows full metadata for a backup record: UUID, timestamps, file info (name, size, MIME type), and error messages when present.


Destinations Tab

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)

Destination Form

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 (passwordusername/password or keyprivate_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
});

Connection Testing

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.


Schedules Tab

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

Schedule List

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

Backup Schedule Form

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)

Cleanup Schedule Form

Simpler than backup schedules — just:

  • Name
  • Active toggle
  • Cron Expression

Real-Time Features

The backup run and destination test features use Laravel Reverb for real-time communication. The backend broadcasts events on WebSocket channels.

Backup Run Flow

  1. User clicks "Perform Backup" → confirmation dialog
  2. Frontend POSTs to backup.perform.initialize
  3. Backend creates a lease and redirects to a signed URL
  4. Frontend renders the BackupRunModal with the signed start_url
  5. Frontend POSTs to start_url to begin the backup
  6. Backend broadcasts on channel backups.{uuid}:
    • .job-statuspendingstartedcompleted / failed
    • .process-statusbegincomplete
    • .process-output — streaming terminal output lines
  7. Frontend renders output in xterm.js terminal

Channel Events

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
}

Hooks

useJob

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 };
};

useProcess

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
};

useXterm

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 };
};

Wiring It All Together in the Terminal Component

// 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
};

Shared Components

SectionContainer

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;
}

TableContainer

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>
);

Filter & Search Pattern

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.


Constants & Labels

Backup Types

export type BackupType = 'full' | 'database' | 'files';

export const backupTypeLabels: Record<BackupType, string> = {
    full: 'Full Backup',
    database: 'Database Backup',
    files: 'File Backup',
};

Backup Status

export const backupStatusLabels: Record<BackupStatus, string> = {
    successful: 'Successful',
    failed: 'Failed',
    file_not_found: 'File Not Found',
    deleted: 'Deleted',
};

Destination Protocols

export const protocols = {
    local: 'Local',
    ftp: 'FTP',
    sftp: 'SFTP',
};

export const authTypes = {
    password: 'Password',
    key: 'Key',
};

Schedule Types

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 * *' },
};

Dependencies

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

Summary

  1. Implement the six responder interfaces — each maps a View Data DTO to Inertia props
  2. Build a single tabbed page — all responders render the same component with tab + action props
  3. Use discriminated unions — the action prop determines which sub-component renders
  4. Follow the filter pattern — debounced Inertia reloads with URL-synced state
  5. Real-time features — use useEcho to subscribe to backups.{uuid} channels for job status, process status, and streaming output
  6. xterm.js — render backup output in a terminal emulator for a professional feel

Clone this wiki locally