From 69f8f7298c8e3e1b62a4205817997d453d69a068 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Tue, 25 Aug 2026 10:48:38 -0700 Subject: [PATCH 1/4] Never restart a container on save without explicit confirmation (#449) PUT /containers/:id no longer enqueues an implicit restart job when env/entrypoint change - a restart happens only when restart:true is sent. The edit form now shows a confirmation modal before saving with restart enabled, and the response/toast tell the user saved changes apply on the next restart. --- .../pages/containers/ContainerFormPage.tsx | 70 ++++++++++++++++++- create-a-container/openapi.v1.yaml | 5 +- .../routers/api/v1/containers.js | 13 +++- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx index 31c3c76f..3e5fc6e1 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -14,6 +14,12 @@ import { CardHeader, CardTitle, Input, + Modal, + ModalBody, + ModalClose, + ModalFooter, + ModalHeader, + ModalTitle, Select, Spinner, Switch, @@ -226,6 +232,10 @@ export function ContainerFormPage() { const [metadataMsg, setMetadataMsg] = useState(null); const [nvidiaTooltipOpen, setNvidiaTooltipOpen] = useState(false); + // Restart confirmation (issue #449): saving never restarts the container + // until the user explicitly confirms in this modal. + const [confirmRestartOpen, setConfirmRestartOpen] = useState(false); + const pendingValuesRef = useRef(null); const metadataMutation = useMutation({ mutationFn: (image: string) => queries.containerMetadata(siteId!, image), onSuccess: (meta: ContainerMetadata) => { @@ -326,6 +336,7 @@ export function ContainerFormPage() { containerId: number; jobId: number | null; message: string; + pendingRestart?: boolean; dnsWarnings: string[]; }; type SaveResult = UpdateResult | ContainerCreateResult; @@ -337,7 +348,14 @@ export function ContainerFormPage() { }, onSuccess: (result) => { const dnsWarnings = (result as { dnsWarnings?: string[] }).dnsWarnings; - toast.success(isEdit ? 'Container updated' : 'Container queued for creation'); + const pendingRestart = (result as { pendingRestart?: boolean }).pendingRestart; + toast.success( + isEdit + ? pendingRestart + ? 'Container updated — changes take effect on the next restart' + : 'Container updated' + : 'Container queued for creation', + ); // exact:true so we only invalidate the list query and not its prefix // descendants (e.g. the still-mounted containerBootstrap query keyed // ['sites', siteId, 'containers', 'new']), which would otherwise refetch @@ -362,6 +380,17 @@ export function ContainerFormPage() { }, }); + // Saving with restart enabled must be confirmed first — a restart is + // disruptive and should never happen from a plain save (issue #449). + const onSubmit = (values: FormData) => { + if (isEdit && values.restart) { + pendingValuesRef.current = values; + setConfirmRestartOpen(true); + return; + } + mutation.mutate(values); + }; + if ((isEdit && containerLoading) || bootstrapLoading) { return (
@@ -381,7 +410,7 @@ export function ContainerFormPage() { ]; return ( -
mutation.mutate(v))} noValidate> +
} @@ -552,7 +581,7 @@ export function ContainerFormPage() { {isEdit && ( setValue('restart', c)} /> @@ -823,6 +852,41 @@ export function ContainerFormPage() { )}
+ + + + Restart container? + + + +

+ Saving will stop and start {container?.hostname}, interrupting + anything currently running in it. +

+
+ + + + +
); } diff --git a/create-a-container/openapi.v1.yaml b/create-a-container/openapi.v1.yaml index c88603a7..982a8485 100644 --- a/create-a-container/openapi.v1.yaml +++ b/create-a-container/openapi.v1.yaml @@ -837,7 +837,7 @@ paths: put: operationId: update_container tags: [Containers] - summary: Update services/env/entrypoint; enqueues a restart job when needed (owner/admin) + summary: Update services/env/entrypoint; enqueues a restart job only when explicitly requested (owner/admin) requestBody: content: application/json: @@ -860,7 +860,7 @@ paths: items: { $ref: '#/components/schemas/EnvVar' } description: Full replacement set. Omitting it clears all user env vars (unless the request is restart-only). entrypoint: { type: string, nullable: true, description: Omitting/blank clears the entrypoint (unless the request is restart-only) } - restart: { type: boolean, description: 'true forces a restart even with no config changes; alone, it performs a restart-only request' } + restart: { type: boolean, description: 'A restart job is enqueued only when true — config changes alone never restart the container (they apply on the next restart); alone, it performs a restart-only request' } responses: '200': description: Updated, optional restart job @@ -875,6 +875,7 @@ paths: containerId: { type: integer } jobId: { type: integer, nullable: true, description: 'Restart job id, when a restart was enqueued' } dnsWarnings: { type: array, items: { type: string } } + pendingRestart: { type: boolean, description: 'true when env/entrypoint changes were saved but no restart was requested — they apply on the next restart' } message: { type: string } '400': { $ref: '#/components/responses/BadRequest' } '403': { description: 'forbidden — only the owner/admin may edit (collaborators have a read-only view); non-admins may not reassign ownership', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } } diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index 8d1d676b..7e93de6c 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -642,7 +642,10 @@ router.put( const ownerChanged = newOwnerUsername !== null && newOwnerUsername !== container.username; const envChanged = !isRestartOnly && container.environmentVars !== envVarsJson; const entrypointChanged = !isRestartOnly && container.entrypoint !== newEntrypoint; - const needsRestart = forceRestart || envChanged || entrypointChanged; + // Never restart implicitly (issue #449): a restart job is enqueued only + // when the caller explicitly asks for one. Saved env/entrypoint changes + // are applied by reconfigure-container.js on the next restart. + const needsRestart = forceRestart; let restartJob = null; const dnsWarnings = []; @@ -770,11 +773,17 @@ router.put( } } + const pendingRestart = !restartJob && (envChanged || entrypointChanged); return ok(res, { containerId: container.id, jobId: restartJob ? restartJob.id : null, dnsWarnings, - message: restartJob ? 'Container is restarting' : 'Container updated', + pendingRestart, + message: restartJob + ? 'Container is restarting' + : pendingRestart + ? 'Container updated — changes take effect on the next restart' + : 'Container updated', }); }), ); From d13983317de3f92c7d310f60e104bf336b8aa58f Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Tue, 25 Aug 2026 10:50:18 -0700 Subject: [PATCH 2/4] Fix Jobs back button landing on unhandled /jobs route (#453) The Back button linked to '..' which resolves to /jobs (no route). It now returns to the current site's containers list, falling back to /sites. --- .../client/src/pages/jobs/JobDetailPage.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/create-a-container/client/src/pages/jobs/JobDetailPage.tsx b/create-a-container/client/src/pages/jobs/JobDetailPage.tsx index 9d30fd22..21b8c15a 100644 --- a/create-a-container/client/src/pages/jobs/JobDetailPage.tsx +++ b/create-a-container/client/src/pages/jobs/JobDetailPage.tsx @@ -11,6 +11,7 @@ import { import { ArrowLeft, Terminal } from 'lucide-react'; import { ButtonLink } from '@/components/ButtonLink'; import { ApiError } from '@/lib/api'; +import { useCurrentSiteId } from '@/lib/currentSite'; import { keys, queries } from '@/lib/queries'; import type { JobStatusRow } from '@/lib/types'; @@ -37,6 +38,10 @@ function statusVariant(s: string): 'default' | 'success' | 'warning' | 'danger' export function JobDetailPage() { const { id } = useParams<{ id: string }>(); + // /jobs/:id has no parent list route — Back returns to the current site's + // containers (where jobs are launched from), or the sites list as a fallback. + const currentSiteId = useCurrentSiteId(); + const backTo = currentSiteId ? `/sites/${currentSiteId}/containers` : '/sites'; const { data: job, isLoading, error, refetch } = useQuery({ queryKey: keys.job(id!), queryFn: () => queries.getJob(id!), @@ -113,7 +118,9 @@ export function JobDetailPage() { subtitle={job.command} icon={} actions={ - }>Back + }> + {currentSiteId ? 'Back to containers' : 'Back to sites'} + } /> From b38afeb2c27bfe689f34675db86457e90941b913 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Tue, 25 Aug 2026 10:50:25 -0700 Subject: [PATCH 3/4] Add version footer and bug report link to the React SPA (#358) GET /api/v1/health now includes the startup-cached git version info. New AppFooter shows the version (linked to its commit) plus a Report a bug link that pre-fills the GitHub bug template with the current URL, username, and version. --- .../client/src/app/AppFooter.tsx | 48 +++++++++++++++++++ .../client/src/app/AppLayout.tsx | 2 + create-a-container/client/src/lib/auth.ts | 12 +++++ create-a-container/routers/api/v1/index.js | 4 +- 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 create-a-container/client/src/app/AppFooter.tsx diff --git a/create-a-container/client/src/app/AppFooter.tsx b/create-a-container/client/src/app/AppFooter.tsx new file mode 100644 index 00000000..fe45092e --- /dev/null +++ b/create-a-container/client/src/app/AppFooter.tsx @@ -0,0 +1,48 @@ +import { useLocation } from 'react-router'; +import { Bug } from 'lucide-react'; +import { useServerInfo, useSession } from '@/lib/auth'; + +const REPO_URL = 'https://github.com/mieweb/opensource-server'; + +/** + * App-wide footer showing the running version (linked to its commit) and a + * "Report a bug" link that pre-fills the GitHub bug-report template with the + * current URL, username, and version. + */ +export function AppFooter() { + const { data: serverInfo } = useServerInfo(); + const { data: session } = useSession(); + const location = useLocation(); + + const version = serverInfo?.version; + const params = new URLSearchParams({ template: 'bug_report.yml', url: location.pathname }); + if (session?.user) params.set('username', session.user); + if (version) params.set('version', version.display); + const bugReportUrl = `${REPO_URL}/issues/new?${params.toString()}`; + + return ( + + ); +} diff --git a/create-a-container/client/src/app/AppLayout.tsx b/create-a-container/client/src/app/AppLayout.tsx index e0a399de..49edd3c6 100644 --- a/create-a-container/client/src/app/AppLayout.tsx +++ b/create-a-container/client/src/app/AppLayout.tsx @@ -3,6 +3,7 @@ import { Sidebar, CommandPalette } from '@mieweb/ui'; import { AppSidebar } from './Sidebar'; import { AppTopHeader } from './Header'; import { AppBanner } from './Banner'; +import { AppFooter } from './AppFooter'; export function AppLayout() { return ( @@ -16,6 +17,7 @@ export function AppLayout() {
+
diff --git a/create-a-container/client/src/lib/auth.ts b/create-a-container/client/src/lib/auth.ts index c427ddaa..03bcc67a 100644 --- a/create-a-container/client/src/lib/auth.ts +++ b/create-a-container/client/src/lib/auth.ts @@ -6,6 +6,16 @@ export interface SessionUser { isAdmin: boolean; } +/** Git version info captured at server startup (see utils/getVersionInfo). */ +export interface VersionInfo { + hash: string; + date: string; + tag: string | null; + display: string; + /** GitHub URL for the running commit. */ + url: string; +} + export interface ServerInfo { status: string; isDev: boolean; @@ -16,6 +26,8 @@ export interface ServerInfo { * Settings page). Supports [text](url) links. Null/empty hides the banner. */ banner?: string | null; + /** Running application version, shown in the footer. */ + version?: VersionInfo | null; } export const sessionKey = ['session'] as const; diff --git a/create-a-container/routers/api/v1/index.js b/create-a-container/routers/api/v1/index.js index eb83aa46..c6144cc7 100644 --- a/create-a-container/routers/api/v1/index.js +++ b/create-a-container/routers/api/v1/index.js @@ -43,7 +43,7 @@ const { isOidcEnabled } = require('../../../utils/oidc'); const { Setting } = require('../../../models'); router.get( '/health', - asyncHandler(async (_req, res) => { + asyncHandler(async (req, res) => { // The banner is cosmetic — never let a DB hiccup fail the health check. let banner = null; try { @@ -56,6 +56,8 @@ router.get( isDev: process.env.NODE_ENV !== 'production', oidcEnabled: isOidcEnabled(), banner, + // Cached at startup in app.locals (see app.js); the SPA footer shows it. + version: req.app.locals.versionInfo ?? null, }); }), ); From e3cb7adaad4491ad061fc042016b5cbaf839cd0a Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Wed, 2 Sep 2026 08:15:59 -0700 Subject: [PATCH 4/4] Bake the app version into the client build; auto-enable restart on restart-requiring edits Per PR #464 review (runleveldev): - Version: the deployed system has no git checkout, so getVersionInfo (runtime git interrogation) is removed. The packaging build now writes the release version into the client package.json (new 'node' semver format in package-version, applied in the create-a-container Makefile build target) and vite bakes it into the bundle as __APP_VERSION__. The footer shows it (dev builds show 'Development build') and /health no longer returns version. - Restart UX: saving still never restarts implicitly, but when the user edits env vars or the entrypoint the 'Restart after saving' toggle switches on automatically; turning it back off shows an inline warning that the change only applies on the next restart. Also per copilot review: the save toast now uses the server's message so update-status wording lives in one place. --- create-a-container/Makefile | 3 + create-a-container/app.js | 5 -- .../client/src/app/AppFooter.tsx | 26 ++++---- create-a-container/client/src/lib/auth.ts | 12 ---- .../pages/containers/ContainerFormPage.tsx | 61 ++++++++++++++----- create-a-container/client/src/vite-env.d.ts | 3 + create-a-container/client/vite.config.ts | 9 +++ create-a-container/routers/api/v1/index.js | 4 +- create-a-container/utils/index.js | 32 +--------- package-version | 18 +++++- 10 files changed, 93 insertions(+), 80 deletions(-) diff --git a/create-a-container/Makefile b/create-a-container/Makefile index 76334078..c4351231 100644 --- a/create-a-container/Makefile +++ b/create-a-container/Makefile @@ -41,6 +41,9 @@ deps: npm --prefix client ci build: deps + # Bake the release version into the client bundle (vite define, see + # client/vite.config.ts) — the deployed app has no git checkout to ask. + npm --prefix client pkg set version="$$(../package-version node)" npm --prefix client run build # Run the Manager locally against SQLite, with a dummy (mock) hypervisor so diff --git a/create-a-container/app.js b/create-a-container/app.js index 77811ee9..482c8d85 100644 --- a/create-a-container/app.js +++ b/create-a-container/app.js @@ -105,11 +105,6 @@ function buildApp({ const { csrfGuard, jsonErrorHandler } = require('./middlewares/api'); app.use(csrfGuard); - // Set version info once at startup in app.locals - // Note: Version info is cached at startup. Server restart required to update version. - const { getVersionInfo } = require('./utils'); - app.locals.versionInfo = getVersionInfo(); - // --- Mount Routers --- const apiV1Router = require('./routers/api/v1'); diff --git a/create-a-container/client/src/app/AppFooter.tsx b/create-a-container/client/src/app/AppFooter.tsx index fe45092e..a9fcf848 100644 --- a/create-a-container/client/src/app/AppFooter.tsx +++ b/create-a-container/client/src/app/AppFooter.tsx @@ -1,37 +1,41 @@ import { useLocation } from 'react-router'; import { Bug } from 'lucide-react'; -import { useServerInfo, useSession } from '@/lib/auth'; +import { useSession } from '@/lib/auth'; const REPO_URL = 'https://github.com/mieweb/opensource-server'; +// Baked in at build time (vite define): the packaging build writes the release +// version into package.json before `vite build`; dev builds keep 0.0.0. +const VERSION = __APP_VERSION__ === '0.0.0' ? null : __APP_VERSION__; + /** - * App-wide footer showing the running version (linked to its commit) and a - * "Report a bug" link that pre-fills the GitHub bug-report template with the - * current URL, username, and version. + * App-wide footer showing the running version (linked to the GitHub releases) + * and a "Report a bug" link that pre-fills the GitHub bug-report template with + * the current URL, username, and version. */ export function AppFooter() { - const { data: serverInfo } = useServerInfo(); const { data: session } = useSession(); const location = useLocation(); - const version = serverInfo?.version; const params = new URLSearchParams({ template: 'bug_report.yml', url: location.pathname }); if (session?.user) params.set('username', session.user); - if (version) params.set('version', version.display); + params.set('version', VERSION ?? 'dev'); const bugReportUrl = `${REPO_URL}/issues/new?${params.toString()}`; return (