Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 48 additions & 0 deletions create-a-container/client/src/app/AppFooter.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<footer className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 border-t border-(--color-border,#e5e7eb) px-4 py-2 text-xs text-(--color-muted,#6b7280)">
{version && (
<a
href={version.url}
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
aria-label={`Version ${version.display} — view commit on GitHub`}
>
Version {version.display} ({version.date})
</a>
)}
<a
href={bugReportUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 hover:underline"
aria-label="Report a bug on GitHub"
>
<Bug className="size-3.5" aria-hidden="true" />
<span>Report a bug</span>
</a>
</footer>
);
}
2 changes: 2 additions & 0 deletions create-a-container/client/src/app/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -16,6 +17,7 @@ export function AppLayout() {
<main className="flex-1 overflow-y-auto overflow-x-hidden px-4 py-6 sm:px-6 lg:px-8">
<Outlet />
</main>
<AppFooter />
</div>
<CommandPalette placeholder="Search…" />
</div>
Expand Down
12 changes: 12 additions & 0 deletions create-a-container/client/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import {
CardHeader,
CardTitle,
Input,
Modal,
ModalBody,
ModalClose,
ModalFooter,
ModalHeader,
ModalTitle,
Select,
Spinner,
Switch,
Expand Down Expand Up @@ -226,6 +232,10 @@ export function ContainerFormPage() {

const [metadataMsg, setMetadataMsg] = useState<string | null>(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<FormData | null>(null);
const metadataMutation = useMutation({
mutationFn: (image: string) => queries.containerMetadata(siteId!, image),
onSuccess: (meta: ContainerMetadata) => {
Expand Down Expand Up @@ -326,6 +336,7 @@ export function ContainerFormPage() {
containerId: number;
jobId: number | null;
message: string;
pendingRestart?: boolean;
dnsWarnings: string[];
};
type SaveResult = UpdateResult | ContainerCreateResult;
Expand All @@ -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'
Comment on lines +352 to +356
: '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
Expand All @@ -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 (
<div className="flex justify-center p-12">
Expand All @@ -381,7 +410,7 @@ export function ContainerFormPage() {
];

return (
<form onSubmit={handleSubmit((v) => mutation.mutate(v))} noValidate>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<FormPageHeader
icon={<Container className="size-6" />}
Expand Down Expand Up @@ -552,7 +581,7 @@ export function ContainerFormPage() {
{isEdit && (
<Switch
label="Restart container after saving"
description="Required if you change environment variables or entrypoint"
description="Off by default. Environment variable and entrypoint changes are saved either way and take effect on the next restart."
checked={!!restart}
onCheckedChange={(c) => setValue('restart', c)}
/>
Expand Down Expand Up @@ -823,6 +852,41 @@ export function ContainerFormPage() {
</Alert>
)}
</div>

<Modal open={confirmRestartOpen} onOpenChange={setConfirmRestartOpen}>
<ModalHeader>
<ModalTitle>Restart container?</ModalTitle>
<ModalClose />
</ModalHeader>
<ModalBody>
<p className="text-sm">
Saving will stop and start <strong>{container?.hostname}</strong>, interrupting
anything currently running in it.
</p>
</ModalBody>
<ModalFooter>
<Button
type="button"
variant="ghost"
className="cursor-pointer"
onClick={() => setConfirmRestartOpen(false)}
>
Cancel
</Button>
<Button
type="button"
variant="primary"
className="cursor-pointer"
isLoading={mutation.isPending}
onClick={() => {
if (pendingValuesRef.current) mutation.mutate(pendingValuesRef.current);
setConfirmRestartOpen(false);
}}
>
Save &amp; restart
</Button>
</ModalFooter>
</Modal>
</form>
);
}
9 changes: 8 additions & 1 deletion create-a-container/client/src/pages/jobs/JobDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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!),
Expand Down Expand Up @@ -113,7 +118,9 @@ export function JobDetailPage() {
subtitle={job.command}
icon={<Terminal className="size-6" />}
actions={
<ButtonLink as={Link} to=".." relative="path" variant="ghost" leftIcon={<ArrowLeft className="size-4" />}>Back</ButtonLink>
<ButtonLink as={Link} to={backTo} variant="ghost" leftIcon={<ArrowLeft className="size-4" />}>
{currentSiteId ? 'Back to containers' : 'Back to sites'}
</ButtonLink>
}
/>

Expand Down
5 changes: 3 additions & 2 deletions create-a-container/openapi.v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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 }
Comment on lines 875 to 879
'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' } } } }
Expand Down
13 changes: 11 additions & 2 deletions create-a-container/routers/api/v1/containers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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',
});
}),
);
Expand Down
4 changes: 3 additions & 1 deletion create-a-container/routers/api/v1/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
});
}),
);
Expand Down
Loading