fix(images): 服务器镜像在创建时固定到仓库 digest,更换镜像需确认备份,安装器重建前先固定旧服并推送不可变版本标签
This commit is contained in:
29 files changed
+1149
-29
No files matched your search
@@ -20,6 +20,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { splitImageRef } from "@/lib/format";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import type { AutostartPolicy } from "@/lib/types";
|
||||
|
||||
@@ -36,6 +37,22 @@ function idleLabel(t: (key: string, opts?: Record<string, unknown>) => string, s
|
||||
return t("idle_stop_seconds", { count: seconds });
|
||||
}
|
||||
|
||||
/** ImageLabel shows an image ref as the tag it was picked by, plus the short id of
|
||||
* the build a pinned ref is locked to. */
|
||||
function ImageLabel({ imageRef, t }: { imageRef: string; t: (key: string, opts?: Record<string, unknown>) => string }) {
|
||||
const { tag, short } = splitImageRef(imageRef);
|
||||
return (
|
||||
<>
|
||||
{tag}
|
||||
{short && (
|
||||
<span className="ml-2 font-mono text-xs text-muted-foreground">
|
||||
{t("edit_server_image_build", { id: short })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] {
|
||||
return [
|
||||
{ value: "ownerOnly", label: t("create_server_policy_owner") },
|
||||
@@ -96,6 +113,9 @@ export function EditServerDialog({
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// An image change moves the world to another build for good, so saving one waits
|
||||
// for this acknowledgement (the API refuses it with image_change_unconfirmed).
|
||||
const [imageConfirmed, setImageConfirmed] = useState(false);
|
||||
|
||||
// Sync form state when dialog opens or current values change from server status
|
||||
useEffect(() => {
|
||||
@@ -109,11 +129,13 @@ export function EditServerDialog({
|
||||
idleStop: currentIdleStop,
|
||||
});
|
||||
setError(null);
|
||||
setImageConfirmed(false);
|
||||
}
|
||||
}, [open, currentDisplayName, currentPolicy, currentImage, currentMemory, currentCpu, currentIdleStop]);
|
||||
|
||||
function set<K extends keyof EditServerForm>(k: K, v: EditServerForm[K]) {
|
||||
setForm((f) => ({ ...f, [k]: v }));
|
||||
if (k === "image") setImageConfirmed(false);
|
||||
}
|
||||
|
||||
const enabledImages = (images.data ?? []).filter((i) => i.enabled);
|
||||
@@ -127,7 +149,9 @@ export function EditServerDialog({
|
||||
form.cpu !== currentCpu ||
|
||||
form.idleStop !== currentIdleStop;
|
||||
|
||||
const canSubmit = hasChanges && !submitting;
|
||||
const imageChanged = form.image !== currentImage;
|
||||
const pinnedBuild = splitImageRef(currentImage).short;
|
||||
const canSubmit = hasChanges && !submitting && (!imageChanged || imageConfirmed);
|
||||
|
||||
async function submit() {
|
||||
setSubmitting(true);
|
||||
@@ -141,8 +165,9 @@ export function EditServerDialog({
|
||||
if (form.autostartPolicy !== currentPolicy) {
|
||||
payload.autostartPolicy = form.autostartPolicy;
|
||||
}
|
||||
if (form.image !== currentImage) {
|
||||
if (imageChanged) {
|
||||
payload.image = form.image;
|
||||
payload.confirmImageChange = imageConfirmed;
|
||||
}
|
||||
if (form.memory !== currentMemory) {
|
||||
payload.memory = form.memory;
|
||||
@@ -222,7 +247,15 @@ export function EditServerDialog({
|
||||
<SelectContent>
|
||||
{/* Fallback to display the current image even if not in the whitelist options list */}
|
||||
{form.image && !enabledImages.some((img) => img.image_ref === form.image) && (
|
||||
<SelectItem value={form.image}>{form.image}</SelectItem>
|
||||
<SelectItem value={form.image}>
|
||||
<ImageLabel imageRef={form.image} t={t} />
|
||||
</SelectItem>
|
||||
)}
|
||||
{currentImage && form.image !== currentImage &&
|
||||
!enabledImages.some((img) => img.image_ref === currentImage) && (
|
||||
<SelectItem value={currentImage}>
|
||||
<ImageLabel imageRef={currentImage} t={t} />
|
||||
</SelectItem>
|
||||
)}
|
||||
{enabledImages.map((img) => (
|
||||
<SelectItem key={img.image_ref} value={img.image_ref}>
|
||||
@@ -231,6 +264,28 @@ export function EditServerDialog({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!imageChanged && pinnedBuild && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("edit_server_image_pinned_hint", { id: pinnedBuild })}
|
||||
</p>
|
||||
)}
|
||||
{imageChanged && (
|
||||
<div className="grid gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
|
||||
<p className="flex items-start gap-1.5 text-amber-700 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" />
|
||||
{t("edit_server_image_warning")}
|
||||
</p>
|
||||
<label className="flex cursor-pointer items-center gap-2 font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 shrink-0 cursor-pointer accent-primary"
|
||||
checked={imageConfirmed}
|
||||
onChange={(e) => setImageConfirmed(e.target.checked)}
|
||||
/>
|
||||
{t("edit_server_image_confirm")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
Reference in new issue
Block a user