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 new file mode 100644 index 00000000..a9fcf848 --- /dev/null +++ b/create-a-container/client/src/app/AppFooter.tsx @@ -0,0 +1,52 @@ +import { useLocation } from 'react-router'; +import { Bug } from 'lucide-react'; +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 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: session } = useSession(); + const location = useLocation(); + + const params = new URLSearchParams({ template: 'bug_report.yml', url: location.pathname }); + if (session?.user) params.set('username', session.user); + params.set('version', VERSION ?? 'dev'); + 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/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx index 31c3c76f..ef6e909e 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useFieldArray, useForm } from 'react-hook-form'; @@ -14,6 +14,12 @@ import { CardHeader, CardTitle, Input, + Modal, + ModalBody, + ModalClose, + ModalFooter, + ModalHeader, + ModalTitle, Select, Spinner, Switch, @@ -183,6 +189,28 @@ export function ContainerFormPage() { const debouncedHostname = useDebouncedValue(hostname || '', 500); const customTemplate = watch('customTemplate'); const collaborators = watch('collaborators') || []; + const watchedEntrypoint = watch('entrypoint'); + const watchedEnvVars = watch('environmentVars'); + + // True when the form's env vars or entrypoint differ from the saved + // container — the changes that only take effect after a restart (#449). + const requiresRestart = useMemo(() => { + if (!isEdit || !container) return false; + if ((watchedEntrypoint || '') !== (container.entrypoint || '')) return true; + const saved = new Map(Object.entries(container.environmentVars || {})); + const current = (watchedEnvVars || []).filter((e) => e.key.trim()); + if (current.length !== saved.size) return true; + return current.some((e) => saved.get(e.key) !== e.value); + }, [isEdit, container, watchedEntrypoint, watchedEnvVars]); + + // The restart toggle follows restart-requiring edits (auto-on, so the user + // isn't left wondering why changes didn't apply) until the user overrides + // it manually — then their choice wins. + const restartTouchedRef = useRef(false); + useEffect(() => { + if (!isEdit || restartTouchedRef.current) return; + setValue('restart', requiresRestart); + }, [requiresRestart, isEdit, setValue]); useEffect(() => { if (container && isEdit && !initializedRef.current) { @@ -226,6 +254,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 +358,7 @@ export function ContainerFormPage() { containerId: number; jobId: number | null; message: string; + pendingRestart?: boolean; dnsWarnings: string[]; }; type SaveResult = UpdateResult | ContainerCreateResult; @@ -337,7 +370,10 @@ export function ContainerFormPage() { }, onSuccess: (result) => { const dnsWarnings = (result as { dnsWarnings?: string[] }).dnsWarnings; - toast.success(isEdit ? 'Container updated' : 'Container queued for creation'); + // Prefer the server's message so update-status wording (restarting / + // pending restart / updated) lives in one place. + const message = (result as { message?: string }).message; + toast.success(message || (isEdit ? '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 +398,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 +428,7 @@ export function ContainerFormPage() { ]; return ( -
mutation.mutate(v))} noValidate> +
} @@ -550,12 +597,25 @@ export function ContainerFormPage() {
{isEdit && ( - setValue('restart', c)} - /> +
+ { + restartTouchedRef.current = true; + setValue('restart', c); + }} + /> + {requiresRestart && !restart && ( + + + This change requires a restart, but none will be performed — it takes + effect the next time the container restarts. + + + )} +
)} @@ -823,6 +883,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/client/src/vite-env.d.ts b/create-a-container/client/src/vite-env.d.ts index 11f02fe2..c92e03da 100644 --- a/create-a-container/client/src/vite-env.d.ts +++ b/create-a-container/client/src/vite-env.d.ts @@ -1 +1,4 @@ /// + +/** App version baked in at build time (vite define); 0.0.0 in dev builds. */ +declare const __APP_VERSION__: string; diff --git a/create-a-container/client/vite.config.ts b/create-a-container/client/vite.config.ts index 033ed665..84f7bf30 100644 --- a/create-a-container/client/vite.config.ts +++ b/create-a-container/client/vite.config.ts @@ -1,12 +1,21 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; +import { readFileSync } from 'node:fs'; import path from 'node:path'; const EXPRESS_TARGET = process.env.VITE_API_TARGET || 'http://localhost:3000'; +// Baked-in app version for the footer: the packaging build sets package.json's +// version from the release tag (create-a-container/Makefile `build`); dev +// builds keep 0.0.0, which the UI renders as a development build. +const pkg = JSON.parse(readFileSync(path.resolve(__dirname, 'package.json'), 'utf8')); + export default defineConfig({ plugins: [react(), tailwindcss()], + define: { + __APP_VERSION__: JSON.stringify(pkg.version || '0.0.0'), + }, resolve: { alias: { '@': path.resolve(__dirname, './src'), 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/utils/index.js b/create-a-container/utils/index.js index ea691c5d..0844e153 100644 --- a/create-a-container/utils/index.js +++ b/create-a-container/utils/index.js @@ -1,4 +1,4 @@ -const { spawn, execSync } = require('child_process'); +const { spawn } = require('child_process'); const ProxmoxApi = require('./proxmox-api'); function run(cmd, args, opts) { @@ -26,35 +26,6 @@ function run(cmd, args, opts) { }); } -/** - * Get version information from git - * @returns {Object} Version information with hash, date, and tag - */ -function getVersionInfo() { - try { - const commitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf8', shell: true }).trim(); - const commitDate = execSync('git log -1 --format=%ad --date=short', { encoding: 'utf8', shell: true }).trim(); - const tag = execSync('git describe --tags --exact-match 2>/dev/null || echo ""', { encoding: 'utf8', shell: true }).trim(); - - return { - hash: commitHash, - date: commitDate, - tag: tag || null, - display: tag ? `${tag} (${commitHash})` : commitHash, - url: `https://github.com/mieweb/opensource-server/commit/${commitHash}` - }; - } catch (error) { - console.error('Error getting version info:', error); - return { - hash: 'unknown', - date: new Date().toISOString().split('T')[0], - tag: null, - display: 'development', - url: 'https://github.com/mieweb/opensource-server' - }; - } -} - /** * Helper to validate that a redirect URL is a safe relative path. * @param {string} url - the URL to validate @@ -117,6 +88,5 @@ module.exports = { run, isSafeRelativeUrl, isSafeRedirectUrl, - getVersionInfo, formatSequelizeError }; diff --git a/package-version b/package-version index 77b54abc..60631832 100755 --- a/package-version +++ b/package-version @@ -2,7 +2,7 @@ # Print the package version string for a given packaging format, derived from # the current git state. # -# Usage: ./package-version +# Usage: ./package-version # # The version is parsed from `git describe --tags --long --dirty`: # VERSION base version, leading 'v' stripped, 0.0.0 if there is no tag @@ -21,9 +21,9 @@ set -eu packager=${1:-} case "$packager" in - deb | rpm | apk) ;; + deb | rpm | apk | node) ;; *) - echo "usage: $0 " >&2 + echo "usage: $0 " >&2 exit 2 ;; esac @@ -92,6 +92,18 @@ case "$packager" in [ -n "$prerelease" ] && v="${v}_$prerelease" [ "$commits" != "0" ] && v="${v}_git$commits" ;; + node) + # Semver, for baking into the client bundle (npm package.json version): + # prerelease keeps its hyphen, snapshot/dirty go in build metadata. + v=$version + [ -n "$prerelease" ] && v="$v-$prerelease" + if [ "$commits" != "0" ]; then + v="$v+$commits.$hash" + [ "$dirty" = "1" ] && v="$v.dirty" + elif [ "$dirty" = "1" ]; then + v="$v+dirty" + fi + ;; esac printf '%s\n' "$v"