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. Adding Filtering & Pagination
  12. Hooks
  13. 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 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)

Route Configuration

// 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.


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)
    {
        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.

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

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

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

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. 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.

Tab Dispatcher Pattern

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

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
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:

  • 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:

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

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

Adding Filtering & Pagination

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.


Constants & Labels

Backup Types

export type BackupType = "full" | "files" | "databases";

export const backupTypeLabels: Record<BackupType, string> = {
  full: "Full Backup",
  files: "File Backup",
  databases: "Database 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: 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 * *" },
};

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"
}
  • @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. Add filtering and pagination as needed — the examples pass full collections; add client-side filtering or ->paginate() if desired
  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