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