- {t('reinstall.description')}
+ {description ?? t('reinstall.description')}
@@ -119,7 +127,7 @@ const ReinstallServerCard = () => {
color='danger'
size='sm'
>
- {tStrings('reinstall')}
+ {submitLabel ?? tStrings('reinstall')}
diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx
index f1a51f96e8a..f1bdf3f29ee 100644
--- a/resources/scripts/routers/ServerRouter.tsx
+++ b/resources/scripts/routers/ServerRouter.tsx
@@ -17,6 +17,8 @@ import ScreenBlock, { ErrorMessage } from '@/components/elements/ScreenBlock'
import Spinner from '@/components/elements/Spinner'
import { NavigationBarContext } from '@/components/elements/navigation/NavigationBar'
+import InstallFailedScreen from '@/components/servers/InstallFailedScreen'
+
export const routes: Route[] = [
{
@@ -206,14 +208,7 @@ const ServerRouter = () => {
/>
)
case 'install_failed':
- return (
-
- )
+ return
case null:
return null
default:
diff --git a/routes/api-client.php b/routes/api-client.php
index 8ae08a8e059..59210333391 100644
--- a/routes/api-client.php
+++ b/routes/api-client.php
@@ -31,8 +31,9 @@
Route::post('/rename', [Client\Servers\SettingsController::class, 'rename']);
Route::get(
'/template-groups', [Client\Servers\SettingsController::class, 'getTemplateGroups'],
- );
- Route::post('/reinstall', [Client\Servers\SettingsController::class, 'reinstall']);
+ )->name('servers.settings.template-groups');
+ Route::post('/reinstall', [Client\Servers\SettingsController::class, 'reinstall'])
+ ->name('servers.settings.reinstall');
Route::get(
'/hardware/boot-order', [Client\Servers\SettingsController::class, 'getBootOrder'],
diff --git a/tests/Feature/Controllers/Client/Servers/RetryInstallTest.php b/tests/Feature/Controllers/Client/Servers/RetryInstallTest.php
new file mode 100644
index 00000000000..307b73ef6dc
--- /dev/null
+++ b/tests/Feature/Controllers/Client/Servers/RetryInstallTest.php
@@ -0,0 +1,119 @@
+ $node->id, 'name' => 'Linux', 'hidden' => false]);
+
+ return Template::create([
+ 'template_group_id' => $group->id,
+ 'name' => 'Debian 12',
+ 'vmid' => 9000,
+ 'hidden' => false,
+ ]);
+}
+
+function fakeMissingVm(): void
+{
+ Http::fake([
+ '*/config' => Http::response([
+ 'data' => null,
+ 'message' => "Configuration file 'nodes/proxmox/qemu-server/100.conf' does not exist\n",
+ ], 500),
+ '*' => Http::response(['data' => 'dummy-upid'], 200),
+ ]);
+}
+
+it('retries a failed install straight into a build when its VM is gone', function () {
+ fakeMissingVm();
+
+ [$user, $_, $node, $server] = createServerModel();
+ $server->update(['status' => Status::INSTALL_FAILED->value]);
+ $template = createTemplateFor($node);
+
+ $this->actingAs($user)->postJson("/api/client/servers/{$server->uuid}/settings/reinstall", [
+ 'template_uuid' => $template->uuid,
+ 'account_password' => 'Rt-Aa1!bcdefgh9',
+ 'start_on_completion' => false,
+ ])->assertNoContent();
+
+ expect($server->fresh()->status)->toBe(Status::INSTALLING->value);
+ // Stopping and deleting a VM that isn't there fails on Proxmox, so the
+ // chain has to start at the build.
+ Queue::assertPushed(BuildServerJob::class);
+ Queue::assertNotPushed(SendPowerCommandJob::class);
+});
+
+it('still stops and deletes the old VM first when it exists', function () {
+ Http::fake([
+ '*/config' => Http::response(
+ file_get_contents(base_path('tests/Fixtures/Repositories/Server/GetServerConfigData.json')),
+ 200,
+ ),
+ '*' => Http::response(['data' => 'dummy-upid'], 200),
+ ]);
+
+ [$user, $_, $node, $server] = createServerModel();
+ $template = createTemplateFor($node);
+
+ $this->actingAs($user)->postJson("/api/client/servers/{$server->uuid}/settings/reinstall", [
+ 'template_uuid' => $template->uuid,
+ 'account_password' => 'Rt-Aa1!bcdefgh9',
+ 'start_on_completion' => false,
+ ])->assertNoContent();
+
+ Queue::assertPushed(SendPowerCommandJob::class);
+ Queue::assertNotPushed(BuildServerJob::class);
+});
+
+it('marks a retried install failed when its chain fails', function () {
+ fakeMissingVm();
+ // Serialize pushed jobs as the real queue does. The failure handler then
+ // runs against what was captured at dispatch -- a server that already read
+ // install_failed -- which is what used to leave it stuck on "installing".
+ Queue::fake()->serializeAndRestore();
+
+ [$_, $_, $node, $server] = createServerModel();
+ $server->update(['status' => Status::INSTALL_FAILED->value]);
+
+ app(ServerBuildDispatchService::class)->rebuild(ServerDeploymentData::from([
+ 'server' => $server,
+ 'template' => createTemplateFor($node),
+ 'account_password' => 'Rt-Aa1!bcdefgh9',
+ 'should_create_server' => true,
+ 'start_on_completion' => false,
+ ]));
+
+ expect($server->fresh()->status)->toBe(Status::INSTALLING->value);
+
+ Queue::pushed(BuildServerJob::class)->first()
+ ->invokeChainCatchCallbacks(new RuntimeException('clone failed'));
+
+ expect(Server::find($server->id)->status)->toBe(Status::INSTALL_FAILED->value);
+});
+
+it('keeps reinstall blocked for servers in any other state', function () {
+ fakeMissingVm();
+
+ [$user, $_, $node, $server] = createServerModel();
+ $server->update(['status' => Status::SUSPENDED->value]);
+
+ $this->actingAs($user)->postJson("/api/client/servers/{$server->uuid}/settings/reinstall", [
+ 'template_uuid' => createTemplateFor($node)->uuid,
+ 'account_password' => 'Rt-Aa1!bcdefgh9',
+ 'start_on_completion' => false,
+ ])->assertConflict();
+
+ Queue::assertNothingPushed();
+});
diff --git a/tests/e2e/servers/retry-install.spec.ts b/tests/e2e/servers/retry-install.spec.ts
new file mode 100644
index 00000000000..af49316bb92
--- /dev/null
+++ b/tests/e2e/servers/retry-install.spec.ts
@@ -0,0 +1,38 @@
+import { expect, test } from '@playwright/test'
+
+import { signInAsAdmin } from '../support/auth'
+
+test.beforeEach(async ({ page }) => {
+ await signInAsAdmin(page)
+})
+
+// A failed install used to be a dead end. The seeded server (E2eSeeder) is in
+// install_failed, and the retry has to go through the reinstall endpoint.
+test('offers to retry a failed install', async ({ page }) => {
+ await page.goto('/servers/e2e00000')
+
+ await expect(
+ page.getByRole('heading', { name: 'Install failed' })
+ ).toBeVisible()
+
+ await page.getByLabel('Template Group').click()
+ await page.getByRole('option', { name: 'E2E Linux' }).click()
+ await page.getByLabel('Template', { exact: true }).click()
+ await page.getByRole('option', { name: 'E2E Debian 12' }).click()
+ await page.getByLabel('System OS Password').fill('Rt-Aa1!bcdefgh9')
+
+ // The seeded node isn't a real Proxmox host, so answer the reinstall here
+ // rather than queue a build that could only fail.
+ let body: Record
| undefined
+ await page.route('**/settings/reinstall', async route => {
+ body = route.request().postDataJSON()
+ await route.fulfill({ status: 204 })
+ })
+
+ await page.getByRole('button', { name: 'Retry Installation' }).click()
+ await page.getByRole('button', { name: 'Confirm' }).click()
+
+ await expect(page.getByRole('heading', { name: 'Installing' })).toBeVisible()
+ expect(body).toMatchObject({ account_password: 'Rt-Aa1!bcdefgh9' })
+ expect(body?.template_uuid).toBeTruthy()
+})
From 7456b332df5d8aca1676003c95252ae9a274e05e Mon Sep 17 00:00:00 2001
From: Eric Wang <37554696+ericwang401@users.noreply.github.com>
Date: Fri, 16 Oct 2026 00:33:07 +0000
Subject: [PATCH 3/3] feat(servers): update the installing and restoring
screens by themselves
The "Installing", "Restoring Backup" and "Restoring Snapshot" screens only
ever change when a queued job finishes, but nothing told the page: it
stayed on the screen until the user refreshed. ServerRouter now re-fetches
the server every 5s while it's in one of those states, so the screen moves
on as soon as the work is done -- including straight after retrying a
failed install. A failed poll is simply retried on the next tick.
ScreenBlock takes a `spin` flag, used by those three screens, that turns
the icon slowly (2s per turn, off for reduced motion) to show it's live.
The icon's border box moves to a wrapper so only the glyph turns.
The retry e2e spec now fakes the install finishing and expects the page to
leave "Installing" without a reload; it fails without the polling.
---
CHANGELOG.md | 3 +++
.../components/elements/ScreenBlock.tsx | 13 ++++++++++-
resources/scripts/routers/ServerRouter.tsx | 23 +++++++++++++++++++
tests/e2e/servers/retry-install.spec.ts | 16 +++++++++++++
4 files changed, 54 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc0c607dc87..e08c237ea01 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,9 @@ follows [Semantic Versioning](https://semver.org) guidelines.
- A server whose installation failed can now be retried. The "Install failed" screen was a dead end that only said to
contact an administrator; it now offers the reinstall form, and the API accepts a reinstall for a server in that
state. Every other blocked state, such as suspended, still refuses it.
+- The "Installing", "Restoring Backup" and "Restoring Snapshot" screens now update by themselves. They check the
+ server every few seconds and move on once the work is done, with a spinning icon to show they're live; before, they
+ stayed put until the page was refreshed.
### Fixed
diff --git a/resources/scripts/components/elements/ScreenBlock.tsx b/resources/scripts/components/elements/ScreenBlock.tsx
index 728e4d7a409..cc9e87b3201 100644
--- a/resources/scripts/components/elements/ScreenBlock.tsx
+++ b/resources/scripts/components/elements/ScreenBlock.tsx
@@ -14,6 +14,8 @@ interface BaseProps {
message: string
full?: boolean
center?: boolean
+ /** Spin the icon, for screens that update themselves when the work is done. */
+ spin?: boolean
children?: ReactNode
onRetry?: () => void
onBack?: () => void
@@ -36,6 +38,7 @@ const ScreenBlock = ({
icon: Icon,
message,
center,
+ spin,
onBack,
onRetry,
full,
@@ -54,7 +57,15 @@ const ScreenBlock = ({
}`}
>
{Icon && (
-
+
+
+
)}
diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx
index f1bdf3f29ee..da948e80dbe 100644
--- a/resources/scripts/routers/ServerRouter.tsx
+++ b/resources/scripts/routers/ServerRouter.tsx
@@ -169,6 +169,26 @@ const ServerRouter = () => {
}
}, [id])
+ // These statuses are only ever cleared by a queued job in the background, so
+ // poll while the server is in one and let the screen move on by itself
+ // instead of waiting for the user to refresh.
+ const isBusy =
+ server?.status === 'installing' ||
+ server?.status === 'restoring_backup' ||
+ server?.status === 'restoring_snapshot'
+
+ useEffect(() => {
+ if (!isBusy) return
+
+ const interval = setInterval(() => {
+ // A failed poll is retried on the next tick; the screen keeps
+ // showing the last known status meanwhile.
+ getServer(id as string).catch(() => {})
+ }, 5000)
+
+ return () => clearInterval(interval)
+ }, [id, isBusy])
+
const getScreenBlock = (status: EloquentStatus) => {
switch (status) {
case 'suspended':
@@ -185,6 +205,7 @@ const ServerRouter = () => {
@@ -194,6 +215,7 @@ const ServerRouter = () => {
@@ -203,6 +225,7 @@ const ServerRouter = () => {
diff --git a/tests/e2e/servers/retry-install.spec.ts b/tests/e2e/servers/retry-install.spec.ts
index af49316bb92..5c8f6d5f490 100644
--- a/tests/e2e/servers/retry-install.spec.ts
+++ b/tests/e2e/servers/retry-install.spec.ts
@@ -35,4 +35,20 @@ test('offers to retry a failed install', async ({ page }) => {
await expect(page.getByRole('heading', { name: 'Installing' })).toBeVisible()
expect(body).toMatchObject({ account_password: 'Rt-Aa1!bcdefgh9' })
expect(body?.template_uuid).toBeTruthy()
+
+ // Pretend the install has now finished. The Installing screen polls, so it
+ // has to move on by itself -- nobody reloads the page.
+ await page.route('**/api/client/servers/e2e00000', async route => {
+ const response = await route.fetch()
+ const json = await response.json()
+ json.data.status = null
+ await route.fulfill({ response, json })
+ })
+
+ await expect(page.getByRole('heading', { name: 'Installing' })).toBeHidden({
+ timeout: 15_000,
+ })
+ await expect(
+ page.getByRole('heading', { name: 'Install failed' })
+ ).toBeHidden()
})