Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/Actions/Server/InstallServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

namespace App\Actions\Server;

use App\DTOs\SocketEventDTO;
use App\Enums\ServerStatus;
use App\Enums\ServiceStatus;
use App\Events\SocketEvent;
use App\Exceptions\SSHConnectionError;
use App\Exceptions\SSHError;
use App\Facades\Notifier;
use App\Http\Resources\ServerResource;
use App\Models\Server;
use App\Notifications\ServerInstallationSucceed;
use App\ServerProviders\Custom;
Expand Down Expand Up @@ -103,5 +106,11 @@ protected function progress(int|float $percentage, ?string $step = null): void
$this->server->progress = $percentage;
$this->server->progress_step = $step;
$this->server->save();

SocketEvent::dispatch(new SocketEventDTO(
projectId: $this->server->project_id,
type: 'server.updated',
data: new ServerResource($this->server),
));
Comment on lines +110 to +114
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

progress() now dispatches a full ServerResource on every progress tick. ServerResource::toArray() performs a services()->pluck(...) query, so frequent progress updates can add avoidable DB load during installation. Consider emitting a lightweight progress payload (id/status/status_color/progress/progress_step) or adjust ServerResource to avoid querying services when not needed / use already-loaded relations.

Copilot uses AI. Check for mistakes.
}
}
9 changes: 9 additions & 0 deletions app/SiteTypes/AbstractSiteType.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

namespace App\SiteTypes;

use App\DTOs\SocketEventDTO;
use App\Events\SocketEvent;
use App\Exceptions\FailedToDeployGitKey;
use App\Exceptions\SSHError;
use App\Http\Resources\SiteResource;
use App\Models\Service;
use App\Models\Site;
use App\Services\PHP\PHP;
Expand Down Expand Up @@ -64,6 +67,12 @@ protected function progress(int $percentage): void
{
$this->site->progress = $percentage;
$this->site->save();

SocketEvent::dispatch(new SocketEventDTO(
projectId: $this->site->server->project_id,
type: 'site.updated',
data: new SiteResource($this->site),
Comment on lines +71 to +74
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

progress() now dispatches site.updated using SiteResource, which includes type_data. For some site types (e.g. WordPress) type_data contains plaintext credentials (admin password, database password), so this will broadcast sensitive data repeatedly during installs. Consider broadcasting a minimal payload for progress updates (e.g. id/status/status_color/progress) or introduce a dedicated resource that omits secrets, and reserve the full SiteResource for places where the UI actually needs it.

Suggested change
SocketEvent::dispatch(new SocketEventDTO(
projectId: $this->site->server->project_id,
type: 'site.updated',
data: new SiteResource($this->site),
$sanitizedSite = clone $this->site;
$sanitizedSite->setAttribute('type_data', null);
SocketEvent::dispatch(new SocketEventDTO(
projectId: $this->site->server->project_id,
type: 'site.updated',
data: new SiteResource($sanitizedSite),

Copilot uses AI. Check for mistakes.
));
}

/**
Expand Down
9 changes: 6 additions & 3 deletions app/SiteTypes/NodeJS.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,30 +80,32 @@ public function data(array $input): array
public function install(): void
{
$this->isolate();
$this->progress(10);
$this->site->webserver()->createVHost($this->site);
$this->progress(15);
$this->progress(20);
$this->deployKey();
$this->progress(30);
app(Git::class)->clone($this->site);
$this->progress(45);
$this->site->server->ssh($this->site->user)->exec(
__('npm install --prefix=:path', [
'path' => $this->site->path,
]),
'install-npm-dependencies',
$this->site->id
);
$this->progress(60);
$this->site->server->ssh($this->site->user)->exec(
__('npm run build --prefix=:path', [
'path' => $this->site->path,
]),
'npm-build',
$this->site->id
);
$this->progress(65);
$this->progress(75);
$command = __('npm start --prefix=:path', [
'path' => $this->site->path,
]);
$this->progress(80);
/** @var ?Worker $worker */
$worker = $this->site->workers()->where('name', 'app')->first();
if ($worker) {
Expand All @@ -122,6 +124,7 @@ public function install(): void
$this->site,
);
}
$this->progress(90);
}

public function baseCommands(): array
Expand Down
4 changes: 3 additions & 1 deletion app/SiteTypes/PHPBlank.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,11 @@ public function data(array $input): array
public function install(): void
{
$this->isolate();
$this->progress(20);
$this->site->webserver()->createVHost($this->site);
$this->progress(65);
$this->progress(55);
$this->site->php()?->restart();
$this->progress(90);
}

public function baseCommands(): array
Expand Down
6 changes: 4 additions & 2 deletions app/SiteTypes/PHPMyAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,9 @@ public function data(array $input): array
public function install(): void
{
$this->isolate();
$this->progress(10);
$this->site->webserver()->createVHost($this->site);
$this->progress(30);
$this->progress(25);
$this->site->server->ssh($this->site->user)->exec(
view('ssh.phpmyadmin.install', [
'version' => $this->site->type_data['version'],
Expand All @@ -59,7 +60,8 @@ public function install(): void
'install-phpmyadmin',
$this->site->id
);
$this->progress(65);
$this->progress(70);
$this->site->php()?->restart();
$this->progress(90);
}
}
9 changes: 6 additions & 3 deletions app/SiteTypes/PHPSite.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,16 +93,19 @@ public function data(array $input): array
public function install(): void
{
$this->isolate();
$this->progress(10);
$this->site->webserver()->createVHost($this->site);
$this->progress(15);
$this->progress(25);
$this->deployKey();
$this->progress(30);
$this->progress(40);
app(Git::class)->clone($this->site);
$this->progress(65);
$this->progress(60);
$this->site->php()?->restart();
$this->progress(75);
if ($this->site->type_data['composer']) {
app(Composer::class)->installDependencies($this->site);
}
$this->progress(90);
}

public function baseCommands(): array
Expand Down
6 changes: 4 additions & 2 deletions app/SiteTypes/Wordpress.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,13 @@ public function data(array $input): array
public function install(): void
{
$this->isolate();
$this->progress(10);

$this->site->webserver()->createVHost($this->site);
$this->progress(30);
$this->progress(25);

$this->site->php()?->restart();
$this->progress(60);
$this->progress(40);

$this->site->server->ssh($this->site->user)->exec(
view('ssh.wordpress.install', [
Expand All @@ -120,5 +121,6 @@ public function install(): void
'install-wordpress',
$this->site->id
);
$this->progress(90);
}
}
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion resources/js/components/ui/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export function Combobox({
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder={searchText} />
<CommandList className="pointer-events-auto p-0" onWheel={(e) => e.stopPropagation()}>
<CommandList className="p-0">
<CommandEmpty>{noneFoundText}</CommandEmpty>
<CommandGroup>
{open &&
Expand Down
1 change: 1 addition & 0 deletions resources/js/components/ui/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ function CommandList({ className, ...props }: React.ComponentProps<typeof Comman
<CommandPrimitive.List
data-slot="command-list"
className={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
onWheel={(e) => e.stopPropagation()}
{...props}
/>
);
Expand Down
28 changes: 28 additions & 0 deletions resources/js/hooks/use-socket-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,34 @@ export function useSocketListener(callback: (data: SocketEventData) => void): vo
}, []);
}

/**
* Keeps a single resource in sync with socket events.
*
* Returns the live resource (initially from Inertia props, updated via socket).
* Pass `null` to skip listening (safe to call unconditionally).
*/
Comment on lines +163 to +168
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says “Pass null to skip listening”, but the hook still registers a socket listener and simply ignores events when initial is null. Consider rewording to clarify that it skips handling/updates (or add an early-return path that avoids subscribing when initial is null if you truly want to skip listening).

Copilot uses AI. Check for mistakes.
export function useRealtimeRecord<T extends { id: number }>(initial: T | null | undefined, eventPrefix: string): T | null {
const [record, setRecord] = useState<T | null>(initial ?? null);

useEffect(() => {
setRecord(initial ?? null);
}, [initial]);

useSocketListener(
useCallback(
(event) => {
if (!initial) return;
if (event.type === `${eventPrefix}.updated` && event.data && 'id' in event.data && event.data.id === initial.id) {
setRecord(event.data as unknown as T);
}
},
[eventPrefix, initial?.id],
),
);

return record;
}

/**
* Manages paginated Inertia data with realtime socket updates.
*
Expand Down
1 change: 1 addition & 0 deletions resources/js/pages/servers/components/create-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@ export default function CreateServer({
onClick={copyToClipboard}
id="public_key"
value={page.props.public_key_text}
readOnly
className="justify-between overflow-auto font-normal"
spellCheck={false}
></Textarea>
Expand Down
25 changes: 21 additions & 4 deletions resources/js/pages/servers/components/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,30 @@ import { cn } from '@/lib/utils';
import { Site } from '@/types/site';
import { StatusRipple } from '@/components/status-ripple';
import { Badge } from '@/components/ui/badge';
import { useForm } from '@inertiajs/react';
import { useState } from 'react';
import { router, useForm } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { useRealtimeRecord } from '@/hooks/use-socket-events';

import { InstantLogs } from '@/pages/server-logs/components/instant-logs';

export default function ServerHeader({ server, site }: { server: Server; site?: Site }) {
export default function ServerHeader({ server: initialServer, site: initialSite }: { server: Server; site?: Site }) {
const server = useRealtimeRecord<Server>(initialServer, 'server')!;
const site = useRealtimeRecord<Site>(initialSite, 'site');

// Reload page when installation completes
useEffect(() => {
if (initialServer.status === 'installing' && (server.status === 'ready' || server.status === 'installation_failed')) {
router.reload();
}
}, [server.status, initialServer.status]);

useEffect(() => {
if (initialSite?.status === 'installing' && site && (site.status === 'ready' || site.status === 'installation_failed')) {
router.reload();
}
}, [site?.status, initialSite?.status]);

const statusForm = useForm();

const checkStatus = () => {
Expand Down Expand Up @@ -119,7 +136,7 @@ export default function ServerHeader({ server, site }: { server: Server; site?:
<TooltipTrigger asChild>
<div className="flex items-center space-x-1">
<LoaderCircleIcon className={cn('size-4', site.status === 'installing' ? 'text-brand animate-spin' : '')} />
<div>%{parseInt(site.progress.toString() || '0')}</div>
<div>{parseInt((site.progress ?? 0).toString())}%</div>
{site.status === 'installation_failed' && (
<Badge className="ml-1" variant={site.status_color}>
{site.status}
Expand Down
Loading