properly handling patch updates
Some checks failed
CI / Clippy (push) Failing after 3m6s
CI / Rustfmt (push) Failing after 3m9s
CI / Cargo Audit & Deny (push) Successful in 5m2s
CI / Tests (push) Successful in 8m15s
CI / Security Blocking Checks (push) Successful in 10s
CI / Web Advisory Checks (push) Successful in 1m4s
CI / Web Blocking Checks (push) Failing after 4m52s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 2s
CI / Security Advisory Checks (push) Successful in 1m31s
Publish Images And Chart / Publish init-user (push) Failing after 30s
Publish Images And Chart / Publish init-packs (push) Failing after 1m41s
Publish Images And Chart / Publish migrations (push) Failing after 10s
Publish Images And Chart / Publish web (push) Failing after 11s
Publish Images And Chart / Publish sensor (push) Failing after 32s
Publish Images And Chart / Publish worker (push) Failing after 11s
Publish Images And Chart / Publish executor (push) Failing after 11s
Publish Images And Chart / Publish notifier (push) Failing after 9s
Publish Images And Chart / Publish api (push) Failing after 31s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
Some checks failed
CI / Clippy (push) Failing after 3m6s
CI / Rustfmt (push) Failing after 3m9s
CI / Cargo Audit & Deny (push) Successful in 5m2s
CI / Tests (push) Successful in 8m15s
CI / Security Blocking Checks (push) Successful in 10s
CI / Web Advisory Checks (push) Successful in 1m4s
CI / Web Blocking Checks (push) Failing after 4m52s
Publish Images And Chart / Resolve Publish Metadata (push) Successful in 2s
CI / Security Advisory Checks (push) Successful in 1m31s
Publish Images And Chart / Publish init-user (push) Failing after 30s
Publish Images And Chart / Publish init-packs (push) Failing after 1m41s
Publish Images And Chart / Publish migrations (push) Failing after 10s
Publish Images And Chart / Publish web (push) Failing after 11s
Publish Images And Chart / Publish sensor (push) Failing after 32s
Publish Images And Chart / Publish worker (push) Failing after 11s
Publish Images And Chart / Publish executor (push) Failing after 11s
Publish Images And Chart / Publish notifier (push) Failing after 9s
Publish Images And Chart / Publish api (push) Failing after 31s
Publish Images And Chart / Publish Helm Chart (push) Has been skipped
This commit is contained in:
347
web/src/components/forms/RuntimeForm.tsx
Normal file
347
web/src/components/forms/RuntimeForm.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { usePacks } from "@/hooks/usePacks";
|
||||
import { useCreateRuntime, useUpdateRuntime } from "@/hooks/useRuntimes";
|
||||
import {
|
||||
NullableJsonPatch,
|
||||
NullableStringPatch,
|
||||
type PackSummary,
|
||||
type RuntimeResponse,
|
||||
} from "@/api";
|
||||
|
||||
interface RuntimeFormProps {
|
||||
initialData?: RuntimeResponse;
|
||||
isEditing?: boolean;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, any>;
|
||||
|
||||
function prettyJson(value: unknown): string {
|
||||
return JSON.stringify(value ?? {}, null, 2);
|
||||
}
|
||||
|
||||
function validateObjectJson(label: string, raw: string): JsonObject {
|
||||
if (!raw.trim()) {
|
||||
throw new Error(`${label} is required`);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`${label} must be a JSON object`);
|
||||
}
|
||||
return parsed as JsonObject;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`${label} must be valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateJsonValue(label: string, raw: string, required = true): any {
|
||||
if (!raw.trim()) {
|
||||
if (required) {
|
||||
throw new Error(`${label} is required`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as any;
|
||||
} catch {
|
||||
throw new Error(`${label} must be valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
export default function RuntimeForm({
|
||||
initialData,
|
||||
isEditing = false,
|
||||
onCancel,
|
||||
}: RuntimeFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const { data: packsData } = usePacks({ page: 1, pageSize: 200 });
|
||||
const packs = useMemo(() => packsData?.data || [], [packsData?.data]);
|
||||
const createRuntime = useCreateRuntime();
|
||||
const updateRuntime = useUpdateRuntime();
|
||||
|
||||
const [ref, setRef] = useState("");
|
||||
const [packRef, setPackRef] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [distributions, setDistributions] = useState("{}");
|
||||
const [installation, setInstallation] = useState("");
|
||||
const [executionConfig, setExecutionConfig] = useState("{}");
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialData) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRef(initialData.ref);
|
||||
setPackRef(initialData.pack_ref ?? "");
|
||||
setName(initialData.name);
|
||||
setDescription(initialData.description ?? "");
|
||||
setDistributions(prettyJson(initialData.distributions));
|
||||
setInstallation(
|
||||
initialData.installation == null ? "" : prettyJson(initialData.installation),
|
||||
);
|
||||
setExecutionConfig(prettyJson(initialData.execution_config));
|
||||
}, [initialData]);
|
||||
|
||||
const canEditRef = !isEditing;
|
||||
const isSubmitting = createRuntime.isPending || updateRuntime.isPending;
|
||||
const selectedPackExists =
|
||||
!packRef || packs.some((pack: PackSummary) => pack.ref === packRef);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!ref.trim()) {
|
||||
newErrors.ref = "Reference is required";
|
||||
}
|
||||
if (!name.trim()) {
|
||||
newErrors.name = "Name is required";
|
||||
}
|
||||
if (packRef && !selectedPackExists) {
|
||||
newErrors.pack_ref = "Selected pack does not exist";
|
||||
}
|
||||
|
||||
let parsedDistributions: JsonObject | undefined;
|
||||
let parsedExecutionConfig: JsonObject | undefined;
|
||||
let parsedInstallation: any = null;
|
||||
|
||||
try {
|
||||
parsedDistributions = validateObjectJson("Distributions", distributions);
|
||||
} catch (error) {
|
||||
newErrors.distributions =
|
||||
error instanceof Error ? error.message : "Invalid distributions JSON";
|
||||
}
|
||||
|
||||
try {
|
||||
parsedExecutionConfig = validateObjectJson(
|
||||
"Execution config",
|
||||
executionConfig,
|
||||
);
|
||||
} catch (error) {
|
||||
newErrors.execution_config =
|
||||
error instanceof Error ? error.message : "Invalid execution config JSON";
|
||||
}
|
||||
|
||||
try {
|
||||
parsedInstallation = validateJsonValue("Installation", installation, false);
|
||||
} catch (error) {
|
||||
newErrors.installation =
|
||||
error instanceof Error ? error.message : "Invalid installation JSON";
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEditing && initialData) {
|
||||
await updateRuntime.mutateAsync({
|
||||
ref: initialData.ref,
|
||||
data: {
|
||||
description: description.trim()
|
||||
? { op: NullableStringPatch.op.SET, value: description.trim() }
|
||||
: null,
|
||||
name: name.trim(),
|
||||
distributions: parsedDistributions,
|
||||
installation:
|
||||
installation.trim().length > 0
|
||||
? { op: NullableJsonPatch.op.SET, value: parsedInstallation }
|
||||
: null,
|
||||
execution_config: parsedExecutionConfig,
|
||||
},
|
||||
});
|
||||
navigate(`/runtimes/${encodeURIComponent(initialData.ref)}`);
|
||||
} else {
|
||||
const response = await createRuntime.mutateAsync({
|
||||
ref: ref.trim(),
|
||||
pack_ref: packRef.trim() || null,
|
||||
description: description.trim() || null,
|
||||
name: name.trim(),
|
||||
distributions: parsedDistributions,
|
||||
installation: installation.trim().length > 0 ? parsedInstallation : null,
|
||||
execution_config: parsedExecutionConfig,
|
||||
});
|
||||
navigate(`/runtimes/${encodeURIComponent(response.data.ref)}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const axiosErr = error as { response?: { data?: { message?: string } } };
|
||||
setErrors({
|
||||
submit:
|
||||
axiosErr?.response?.data?.message ||
|
||||
(error instanceof Error ? error.message : "Failed to save runtime"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 p-6 max-w-5xl">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
{isEditing ? "Edit Runtime" : "Create Runtime"}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Configure the metadata and execution contract for a runtime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errors.submit && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">
|
||||
{errors.submit}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Basics</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Runtime Ref
|
||||
</label>
|
||||
<input
|
||||
value={ref}
|
||||
onChange={(e) => setRef(e.target.value)}
|
||||
disabled={!canEditRef}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm disabled:bg-gray-100"
|
||||
placeholder="core.python"
|
||||
/>
|
||||
{errors.ref && <p className="mt-1 text-sm text-red-600">{errors.ref}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Python"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Pack
|
||||
</label>
|
||||
<select
|
||||
value={packRef}
|
||||
onChange={(e) => setPackRef(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">No pack</option>
|
||||
{packs.map((pack: PackSummary) => (
|
||||
<option key={pack.id} value={pack.ref}>
|
||||
{pack.ref}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.pack_ref && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.pack_ref}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-amber-900">Patch Semantics</h3>
|
||||
<p className="mt-2 text-sm text-amber-800">
|
||||
Saving an existing runtime sends explicit patch operations for nullable
|
||||
fields. Blank description or installation clears the stored value.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<JsonField
|
||||
label="Distributions"
|
||||
value={distributions}
|
||||
onChange={setDistributions}
|
||||
error={errors.distributions}
|
||||
/>
|
||||
<JsonField
|
||||
label="Installation"
|
||||
value={installation}
|
||||
onChange={setInstallation}
|
||||
error={errors.installation}
|
||||
placeholder='Leave blank to clear or omit, e.g. { "method": "system" }'
|
||||
/>
|
||||
<JsonField
|
||||
label="Execution Config"
|
||||
value={executionConfig}
|
||||
onChange={setExecutionConfig}
|
||||
error={errors.execution_config}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : isEditing ? "Save Runtime" : "Create Runtime"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (onCancel ? onCancel() : navigate("/runtimes"))}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function JsonField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{label}</label>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-mono"
|
||||
placeholder={placeholder ?? "{\n \n}"}
|
||||
/>
|
||||
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
KeyRound,
|
||||
Home,
|
||||
FolderArchive,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
// Color mappings for navigation items — defined outside component for stable reference
|
||||
@@ -73,6 +74,12 @@ const navSections = [
|
||||
// Component Management - Cool colors (cyan -> blue -> violet)
|
||||
items: [
|
||||
{ to: "/actions", label: "Actions", icon: SquarePlay, color: "cyan" },
|
||||
{
|
||||
to: "/runtimes",
|
||||
label: "Runtimes",
|
||||
icon: TerminalSquare,
|
||||
color: "blue",
|
||||
},
|
||||
{ to: "/rules", label: "Rules", icon: SquareArrowRight, color: "blue" },
|
||||
{
|
||||
to: "/triggers",
|
||||
|
||||
Reference in New Issue
Block a user