Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4bbb99c
feat(server): add helpers to detect self-restart capability.
Vidarte-Alberto Sep 9, 2026
161a5ac
refactor(server): generalize scheduleDockerRestart to scheduleProcess…
Vidarte-Alberto Sep 9, 2026
23471dd
fix(server): force the process to exit after stopping the server so i…
Vidarte-Alberto Sep 9, 2026
b5edde5
feat(server): add phoenixd process control via pidfile signaling.
Vidarte-Alberto Sep 9, 2026
78da5f4
feat(server): expose /system endpoints to restart the server and phoe…
Vidarte-Alberto Sep 9, 2026
0c530ed
feat(scripts): restart the server automatically when the process exits.
Vidarte-Alberto Sep 9, 2026
e6fc389
feat(scripts): add a phoenixd wrapper that restarts on exit and track…
Vidarte-Alberto Sep 9, 2026
7e256a4
feat(install): run phoenixd through the restart wrapper and flag the …
Vidarte-Alberto Sep 9, 2026
237bba7
feat(install): add a --local flag to install from the current checkou…
Vidarte-Alberto Sep 9, 2026
5138acb
feat(install): build the server jar and client automatically under --…
Vidarte-Alberto Sep 9, 2026
c6b23fe
feat(client): add a hook and service to trigger server and phoenixd r…
Vidarte-Alberto Sep 9, 2026
c805427
feat(client): add a Settings card to restart the server and phoenixd.
Vidarte-Alberto Sep 9, 2026
bfa1842
style(client): match restart button colors to the close channel dange…
Vidarte-Alberto Sep 9, 2026
8de1900
feat(hardware): flag the ambrosia service as service-managed for self…
Vidarte-Alberto Sep 9, 2026
7dc7d0c
feat(hardware): route phoenixd through the restart wrapper.
Vidarte-Alberto Sep 9, 2026
1c95922
fix(uninstall): remove the phoenixd restart wrapper installed by inst…
Vidarte-Alberto Sep 9, 2026
be7dbd9
fix(server): remove malformed whitespace from the fallback wordlist URL.
Vidarte-Alberto Sep 9, 2026
b611450
fix(docker): bind the client to 0.0.0.0 so the middleware can reach i…
Vidarte-Alberto Sep 9, 2026
5caca19
refactor(server): use descriptive variable names in SeedGenerator.
Vidarte-Alberto Sep 9, 2026
06d8fd4
feat(scripts): retrofit the phoenixd restart wrapper and service flag…
Vidarte-Alberto Sep 9, 2026
e715b4a
fix(install): use find instead of ls to satisfy shellcheck.
Vidarte-Alberto Sep 9, 2026
31450ff
fix(install): fall back to the plain phoenixd binary when the restart…
Vidarte-Alberto Sep 9, 2026
61b6356
refactor: use descriptive variable names in restart control tests.
Vidarte-Alberto Sep 10, 2026
c1cf843
fix(install): order the client service after the server so it doesn't…
Vidarte-Alberto Sep 10, 2026
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
2 changes: 2 additions & 0 deletions client/src/components/pages/Store/Settings/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { QRUrl } from "./QRUrl";
import { SecureConnection } from "./SecureConnection/SecureConnection";
import { Seed } from "./Seed";
import { StoreInfo } from "./StoreInfo";
import { SystemCard } from "./System/SystemCard";
import { TicketTemplates } from "./TicketTemplates";
import { Tips } from "./Tips";
import { Tutorials } from "./Tutorials";
Expand All @@ -47,6 +48,7 @@ export function Settings() {
<ImportData />
<NwcConnectionCard />
<PhoenixdRemoteCard />
<SystemCard />
<Tutorials />
</>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from "@heroui/react";
import { AlertTriangle } from "lucide-react";

export function RestartConfirmModal({ isOpen, title, description, isRestarting, onCancel, onConfirm, systemCardTranslations }) {
return (
<Modal isOpen={isOpen} onOpenChange={onCancel} isDismissable={!isRestarting} hideCloseButton={isRestarting}>
<ModalContent>
<ModalHeader>{title}</ModalHeader>
<ModalBody>
<div className="flex items-start gap-3 border border-red-400 rounded-lg p-4">
<AlertTriangle className="w-5 h-5 text-red-600 mt-0.5 shrink-0" />
<p className="text-red-700 text-sm">{description}</p>
</div>
</ModalBody>
<ModalFooter>
<Button
variant="bordered"
className="px-6 py-2 border border-border text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
onPress={onCancel}
isDisabled={isRestarting}
>
{systemCardTranslations("cardSystem.cancelButton")}
</Button>
<Button color="danger" onPress={onConfirm} isLoading={isRestarting}>
{systemCardTranslations("cardSystem.confirmButton")}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
101 changes: 101 additions & 0 deletions client/src/components/pages/Store/Settings/System/SystemCard.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"use client";

import { useEffect, useState } from "react";

import { addToast, Button, Card, CardBody, CardHeader } from "@heroui/react";
import { useTranslations } from "next-intl";

import { useSystemRestart } from "@/hooks/useSystemRestart";

import { RestartConfirmModal } from "./RestartConfirmModal";

export function SystemCard() {
const systemCardTranslations = useTranslations("settings");
const {
serverRestartSupported,
phoenixdRestartSupported,
restartingTarget,
loadRestartCapabilities,
restartServer,
restartPhoenixd,
} = useSystemRestart();
const [confirmTarget, setConfirmTarget] = useState(null);

useEffect(() => {
loadRestartCapabilities();
}, [loadRestartCapabilities]);

const restartTargets = {
server: {
buttonLabelKey: "cardSystem.restartServerButton",
confirmTitleKey: "cardSystem.confirmServerTitle",
confirmDescriptionKey: "cardSystem.confirmServerDescription",
successMessageKey: "cardSystem.restartServerSuccess",
errorMessageKey: "cardSystem.restartServerError",
isSupported: serverRestartSupported,
restart: restartServer,
},
phoenixd: {
buttonLabelKey: "cardSystem.restartPhoenixdButton",
confirmTitleKey: "cardSystem.confirmPhoenixdTitle",
confirmDescriptionKey: "cardSystem.confirmPhoenixdDescription",
successMessageKey: "cardSystem.restartPhoenixdSuccess",
errorMessageKey: "cardSystem.restartPhoenixdError",
isSupported: phoenixdRestartSupported,
restart: restartPhoenixd,
},
};

const activeConfirmTarget = confirmTarget ? restartTargets[confirmTarget] : null;
const supportedTargetEntries = Object.entries(restartTargets).filter(([, target]) => target.isSupported);

const handleConfirmRestart = async () => {
const succeeded = await activeConfirmTarget.restart();
const resultMessageKey = succeeded ? activeConfirmTarget.successMessageKey : activeConfirmTarget.errorMessageKey;
setConfirmTarget(null);
addToast({ color: succeeded ? "success" : "danger", description: systemCardTranslations(resultMessageKey) });
};

return (
<Card shadow="none" className="rounded-lg mb-6 p-6 shadow-lg">
<CardHeader className="flex flex-col items-start pb-0">
<h2 className="text-lg sm:text-xl xl:text-2xl font-semibold text-green-900">
{systemCardTranslations("cardSystem.title")}
</h2>
</CardHeader>

<CardBody className="gap-4">
<p className="text-sm text-gray-500">{systemCardTranslations("cardSystem.description")}</p>

{supportedTargetEntries.length === 0 ? (
<p className="text-sm text-gray-500">{systemCardTranslations("cardSystem.unsupportedNotice")}</p>
) : (
<div className="flex flex-wrap gap-3">
{supportedTargetEntries.map(([targetName, target]) => (
<Button
key={targetName}
color="danger"
onPress={() => setConfirmTarget(targetName)}
isLoading={restartingTarget === targetName}
>
{systemCardTranslations(target.buttonLabelKey)}
</Button>
))}
</div>
)}
</CardBody>

{activeConfirmTarget && (
<RestartConfirmModal
isOpen
isRestarting={restartingTarget !== null}
onCancel={() => setConfirmTarget(null)}
onConfirm={handleConfirmRestart}
systemCardTranslations={systemCardTranslations}
title={systemCardTranslations(activeConfirmTarget.confirmTitleKey)}
description={systemCardTranslations(activeConfirmTarget.confirmDescriptionKey)}
/>
)}
</Card>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { render, screen, fireEvent, act } from "@testing-library/react";

import * as useSystemRestartHook from "@/hooks/useSystemRestart";

import { SystemCard } from "../SystemCard";

jest.mock("@/hooks/useSystemRestart");

jest.mock("@heroui/react", () => ({
addToast: jest.fn(),
Button: ({ onPress, children, isLoading, ...props }) => (
<button type="button" disabled={isLoading} onClick={onPress} {...props}>{children}</button>
),
Card: ({ children }) => <div>{children}</div>,
CardHeader: ({ children }) => <div>{children}</div>,
CardBody: ({ children }) => <div>{children}</div>,
}));

jest.mock("../RestartConfirmModal", () => ({
RestartConfirmModal: ({ isOpen, title, description, onConfirm, onCancel }) => (
isOpen ? (
<div data-testid="restart-confirm-modal">
<span>{title}</span>
<span>{description}</span>
<button type="button" onClick={onConfirm}>confirm</button>
<button type="button" onClick={onCancel}>cancel</button>
</div>
) : null
),
}));

function mockUseSystemRestart(overrides = {}) {
const defaults = {
serverRestartSupported: false,
phoenixdRestartSupported: false,
restartingTarget: null,
loadRestartCapabilities: jest.fn(),
restartServer: jest.fn().mockResolvedValue(true),
restartPhoenixd: jest.fn().mockResolvedValue(true),
};
const restartState = { ...defaults, ...overrides };
jest.spyOn(useSystemRestartHook, "useSystemRestart").mockReturnValue(restartState);
return restartState;
}

describe("SystemCard", () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe("Rendering", () => {
it("renders the title and description", () => {
mockUseSystemRestart();
render(<SystemCard />);

expect(screen.getByText("cardSystem.title")).toBeInTheDocument();
expect(screen.getByText("cardSystem.description")).toBeInTheDocument();
});

it("shows the unsupported notice when neither target can be restarted", () => {
mockUseSystemRestart();
render(<SystemCard />);

expect(screen.getByText("cardSystem.unsupportedNotice")).toBeInTheDocument();
expect(screen.queryByText("cardSystem.restartServerButton")).not.toBeInTheDocument();
expect(screen.queryByText("cardSystem.restartPhoenixdButton")).not.toBeInTheDocument();
});

it("shows only the server button when only server restart is supported", () => {
mockUseSystemRestart({ serverRestartSupported: true });
render(<SystemCard />);

expect(screen.getByText("cardSystem.restartServerButton")).toBeInTheDocument();
expect(screen.queryByText("cardSystem.restartPhoenixdButton")).not.toBeInTheDocument();
});

it("shows both buttons when both targets are supported", () => {
mockUseSystemRestart({ serverRestartSupported: true, phoenixdRestartSupported: true });
render(<SystemCard />);

expect(screen.getByText("cardSystem.restartServerButton")).toBeInTheDocument();
expect(screen.getByText("cardSystem.restartPhoenixdButton")).toBeInTheDocument();
});

it("loads restart capabilities on mount", () => {
const restartState = mockUseSystemRestart();
render(<SystemCard />);

expect(restartState.loadRestartCapabilities).toHaveBeenCalledTimes(1);
});
});

describe("Restarting the server", () => {
it("opens the confirmation modal when the restart server button is pressed", () => {
mockUseSystemRestart({ serverRestartSupported: true });
render(<SystemCard />);

fireEvent.click(screen.getByText("cardSystem.restartServerButton"));

expect(screen.getByTestId("restart-confirm-modal")).toBeInTheDocument();
expect(screen.getByText("cardSystem.confirmServerTitle")).toBeInTheDocument();
});

it("calls restartServer and shows a success toast on confirm", async () => {
const restartState = mockUseSystemRestart({ serverRestartSupported: true });
const { addToast } = require("@heroui/react");
render(<SystemCard />);

fireEvent.click(screen.getByText("cardSystem.restartServerButton"));
await act(async () => {
fireEvent.click(screen.getByText("confirm"));
});

expect(restartState.restartServer).toHaveBeenCalledTimes(1);
expect(addToast).toHaveBeenCalledWith(
expect.objectContaining({ color: "success", description: "cardSystem.restartServerSuccess" }),
);
expect(screen.queryByTestId("restart-confirm-modal")).not.toBeInTheDocument();
});

it("shows a danger toast when the restart fails", async () => {
const restartState = mockUseSystemRestart({
serverRestartSupported: true,
restartServer: jest.fn().mockResolvedValue(false),
});
const { addToast } = require("@heroui/react");
render(<SystemCard />);

fireEvent.click(screen.getByText("cardSystem.restartServerButton"));
await act(async () => {
fireEvent.click(screen.getByText("confirm"));
});

expect(restartState.restartServer).toHaveBeenCalledTimes(1);
expect(addToast).toHaveBeenCalledWith(
expect.objectContaining({ color: "danger", description: "cardSystem.restartServerError" }),
);
});

it("closes the modal without restarting when cancelled", () => {
const restartState = mockUseSystemRestart({ serverRestartSupported: true });
render(<SystemCard />);

fireEvent.click(screen.getByText("cardSystem.restartServerButton"));
fireEvent.click(screen.getByText("cancel"));

expect(restartState.restartServer).not.toHaveBeenCalled();
expect(screen.queryByTestId("restart-confirm-modal")).not.toBeInTheDocument();
});
});

describe("Restarting phoenixd", () => {
it("calls restartPhoenixd on confirm", async () => {
const restartState = mockUseSystemRestart({ phoenixdRestartSupported: true });
render(<SystemCard />);

fireEvent.click(screen.getByText("cardSystem.restartPhoenixdButton"));
await act(async () => {
fireEvent.click(screen.getByText("confirm"));
});

expect(restartState.restartPhoenixd).toHaveBeenCalledTimes(1);
});
});
});
21 changes: 21 additions & 0 deletions client/src/components/pages/Store/Settings/System/locales/en.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const systemEn = {
cardSystem: {
title: "System",
description: "Restart the Ambrosia server or the Lightning node when they need to pick up a change.",
restartServerButton: "Restart server",
restartPhoenixdButton: "Restart phoenixd",
unsupportedNotice: "Restart is not available for this installation.",
confirmServerTitle: "Restart the server?",
confirmServerDescription: "The Ambrosia server will restart. Open sessions will be briefly interrupted.",
confirmPhoenixdTitle: "Restart phoenixd?",
confirmPhoenixdDescription: "The Lightning node will restart. Wallet operations will be briefly unavailable.",
cancelButton: "Cancel",
confirmButton: "Restart",
restartServerSuccess: "The server is restarting.",
restartServerError: "Could not restart the server.",
restartPhoenixdSuccess: "phoenixd is restarting.",
restartPhoenixdError: "Could not restart phoenixd.",
},
};

export default systemEn;
21 changes: 21 additions & 0 deletions client/src/components/pages/Store/Settings/System/locales/es.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const systemEs = {
cardSystem: {
title: "Sistema",
description: "Reinicia el servidor de Ambrosia o el nodo Lightning cuando necesiten aplicar un cambio.",
restartServerButton: "Reiniciar servidor",
restartPhoenixdButton: "Reiniciar phoenixd",
unsupportedNotice: "El reinicio no está disponible para esta instalación.",
confirmServerTitle: "¿Reiniciar el servidor?",
confirmServerDescription: "El servidor de Ambrosia se reiniciará. Las sesiones abiertas se interrumpirán brevemente.",
confirmPhoenixdTitle: "¿Reiniciar phoenixd?",
confirmPhoenixdDescription: "El nodo Lightning se reiniciará. Las operaciones de la billetera no estarán disponibles brevemente.",
cancelButton: "Cancelar",
confirmButton: "Reiniciar",
restartServerSuccess: "El servidor se está reiniciando.",
restartServerError: "No se pudo reiniciar el servidor.",
restartPhoenixdSuccess: "phoenixd se está reiniciando.",
restartPhoenixdError: "No se pudo reiniciar phoenixd.",
},
};

export default systemEs;
2 changes: 2 additions & 0 deletions client/src/components/pages/Store/Settings/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import phoenixdRemoteCardEn from "../PhoenixdRemote/locales/en";
import printersEn from "../Printers/locales/en";
import seedEn from "../Seed/locales/en";
import storeInfoEn from "../StoreInfo/locales/en";
import systemEn from "../System/locales/en";
import ticketTemplatesEn from "../TicketTemplates/locales/en";
import tutorialsEn from "../Tutorials/locales/en";

Expand Down Expand Up @@ -94,6 +95,7 @@ const settingsEn = {
errorMessage: "Failed to save tip settings",
},
...storeInfoEn,
...systemEn,
...printersEn,
...ticketTemplatesEn,
...seedEn,
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/pages/Store/Settings/locales/es.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import phoenixdRemoteCardEs from "../PhoenixdRemote/locales/es";
import printersEs from "../Printers/locales/es";
import seedEs from "../Seed/locales/es";
import storeInfoEs from "../StoreInfo/locales/es";
import systemEs from "../System/locales/es";
import ticketTemplatesEs from "../TicketTemplates/locales/es";
import tutorialsEs from "../Tutorials/locales/es";

Expand Down Expand Up @@ -94,6 +95,7 @@ const settingsEs = {
errorMessage: "No se pudo guardar la configuración de propinas",
},
...storeInfoEs,
...systemEs,
...printersEs,
...ticketTemplatesEs,
...seedEs,
Expand Down
Loading
Loading