@@ -1,565 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Card, CardContent } from "@kandev/ui/card";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@kandev/ui/dialog";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/settings-card-header";
|
||||
import { SettingsPageHeader, SettingsFieldLabel } from "@/components/settings/settings-typography";
|
||||
import { settingsActionClassName } from "@/components/settings/settings-control";
|
||||
import { serializeSettingsRevision } from "@/components/settings/settings-save-revision";
|
||||
import { useSecrets } from "@/hooks/domains/settings/use-secrets";
|
||||
import {
|
||||
updateExecutorProfile,
|
||||
deleteExecutorProfile,
|
||||
listScriptPlaceholders,
|
||||
} from "@/lib/api/domains/settings-api";
|
||||
import type { ScriptPlaceholder } from "@/lib/api/domains/settings-api";
|
||||
import {
|
||||
upsertExecutorProfile,
|
||||
type SaveStatus,
|
||||
} from "@/components/settings/profile-edit/profile-edit-page-chrome";
|
||||
import {
|
||||
SpritesConnectionCard,
|
||||
SpritesInstancesCard,
|
||||
} from "@/components/settings/sprites-settings";
|
||||
import { ScriptCard } from "@/components/settings/profile-edit/script-card";
|
||||
import type { Executor, ExecutorProfile, ProfileEnvVar } from "@/lib/types/http";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type EnvVarRow = {
|
||||
key: string;
|
||||
mode: "value" | "secret";
|
||||
value: string;
|
||||
secretId: string;
|
||||
};
|
||||
|
||||
function envVarsToRows(envVars?: ProfileEnvVar[]): EnvVarRow[] {
|
||||
if (!envVars || envVars.length === 0) return [];
|
||||
return envVars.map((ev) => ({
|
||||
key: ev.key,
|
||||
mode: ev.secret_id ? "secret" : "value",
|
||||
value: ev.value ?? "",
|
||||
secretId: ev.secret_id ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
function rowsToEnvVars(rows: EnvVarRow[]): ProfileEnvVar[] {
|
||||
return rows
|
||||
.filter((r) => r.key.trim())
|
||||
.map((r) => {
|
||||
if (r.mode === "secret" && r.secretId) {
|
||||
return { key: r.key.trim(), secret_id: r.secretId };
|
||||
}
|
||||
return { key: r.key.trim(), value: r.value };
|
||||
});
|
||||
}
|
||||
|
||||
export default function ProfileDetailPage({
|
||||
executorId,
|
||||
profileId,
|
||||
}: {
|
||||
executorId: string;
|
||||
profileId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const executor = useAppStore(
|
||||
(state) => state.executors.items.find((e: Executor) => e.id === executorId) ?? null,
|
||||
);
|
||||
const profile = executor?.profiles?.find((p: ExecutorProfile) => p.id === profileId) ?? null;
|
||||
|
||||
if (!executor || !profile) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">{t("executors:profileNotFound")}</p>
|
||||
<Button
|
||||
className="mt-4 cursor-pointer"
|
||||
onClick={() => router.push(`/settings/executor/${executorId}`)}
|
||||
>
|
||||
{t("executors:backToExecutor")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return <ProfileEditForm key={profile.id} executor={executor} profile={profile} />;
|
||||
}
|
||||
|
||||
function ProfileDetailsCard({
|
||||
name,
|
||||
baselineName,
|
||||
onNameChange,
|
||||
}: {
|
||||
name: string;
|
||||
baselineName: string;
|
||||
onNameChange: (v: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isDirty = name.trim() !== baselineName.trim();
|
||||
return (
|
||||
<SettingsCard isDirty={isDirty}>
|
||||
<SettingsCardHeader title={t("executors:profileDetails")} />
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<SettingsFieldLabel htmlFor="profile-name">{t("executors:name")}</SettingsFieldLabel>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
data-settings-dirty={isDirty}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvVarRow({
|
||||
row,
|
||||
index,
|
||||
secrets,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
baselineRow,
|
||||
}: {
|
||||
row: EnvVarRow;
|
||||
index: number;
|
||||
secrets: { id: string; name: string }[];
|
||||
onUpdate: (index: number, field: keyof EnvVarRow, val: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
baselineRow?: EnvVarRow;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isDirty = !baselineRow || JSON.stringify(row) !== JSON.stringify(baselineRow);
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-2"
|
||||
data-settings-dirty={isDirty}
|
||||
data-settings-dirty-level="container"
|
||||
>
|
||||
<Input
|
||||
value={row.key}
|
||||
onChange={(e) => onUpdate(index, "key", e.target.value)}
|
||||
placeholder="KEY"
|
||||
className="font-mono text-xs flex-[2]"
|
||||
data-settings-dirty={!baselineRow || row.key !== baselineRow.key}
|
||||
/>
|
||||
<Select value={row.mode} onValueChange={(v) => onUpdate(index, "mode", v)}>
|
||||
<SelectTrigger
|
||||
className="w-[100px] text-xs"
|
||||
data-settings-dirty={!baselineRow || row.mode !== baselineRow.mode}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="value">{t("executors:value")}</SelectItem>
|
||||
<SelectItem value="secret">{t("executors:secret")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{row.mode === "value" ? (
|
||||
<Input
|
||||
value={row.value}
|
||||
onChange={(e) => onUpdate(index, "value", e.target.value)}
|
||||
placeholder="value"
|
||||
className="font-mono text-xs flex-[3]"
|
||||
data-settings-dirty={!baselineRow || row.value !== baselineRow.value}
|
||||
/>
|
||||
) : (
|
||||
<Select value={row.secretId} onValueChange={(v) => onUpdate(index, "secretId", v)}>
|
||||
<SelectTrigger
|
||||
className="flex-[3] text-xs"
|
||||
data-settings-dirty={!baselineRow || row.secretId !== baselineRow.secretId}
|
||||
>
|
||||
<SelectValue placeholder={t("executors:selectSecret")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{secrets.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemove(index)}
|
||||
className="cursor-pointer shrink-0"
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvVarsCard({
|
||||
rows,
|
||||
baselineRows,
|
||||
secrets,
|
||||
onAdd,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: {
|
||||
rows: EnvVarRow[];
|
||||
baselineRows: EnvVarRow[];
|
||||
secrets: { id: string; name: string }[];
|
||||
onAdd: () => void;
|
||||
onUpdate: (index: number, field: keyof EnvVarRow, val: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isDirty =
|
||||
JSON.stringify(rowsToEnvVars(rows)) !== JSON.stringify(rowsToEnvVars(baselineRows));
|
||||
return (
|
||||
<SettingsCard isDirty={isDirty}>
|
||||
<SettingsCardHeader
|
||||
title={t("executors:environmentVariables")}
|
||||
description={t("executors:injectedIntoTheExecutionEnvironmentVariables")}
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onAdd}
|
||||
className={settingsActionClassName("cursor-pointer")}
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5 mr-1" />
|
||||
{t("executors:add")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardContent className="space-y-3">
|
||||
{rows.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("executors:noEnvironmentVariablesConfigured")}
|
||||
</p>
|
||||
)}
|
||||
{rows.map((row, idx) => (
|
||||
<EnvVarRow
|
||||
key={idx}
|
||||
row={row}
|
||||
baselineRow={baselineRows[idx]}
|
||||
index={idx}
|
||||
secrets={secrets}
|
||||
onUpdate={onUpdate}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileActions({
|
||||
executorId,
|
||||
onRequestDelete,
|
||||
}: {
|
||||
executorId: string;
|
||||
onRequestDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={onRequestDelete}
|
||||
className={settingsActionClassName("cursor-pointer")}
|
||||
>
|
||||
<IconTrash className="h-4 w-4 mr-1" />
|
||||
{t("executors:deleteProfile")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/settings/executor/${executorId}`)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteProfileDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onDelete,
|
||||
deleting,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
onDelete: () => void;
|
||||
deleting: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("executors:deleteProfile")}</DialogTitle>
|
||||
<DialogDescription>{t("executors:areYouSureYouWantTo")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onDelete} disabled={deleting}>
|
||||
{deleting ? t("executors:deleting") : t("executors:delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function useProfilePersistence(executor: Executor, profile: ExecutorProfile) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const executors = useAppStore((state) => state.executors.items);
|
||||
const setExecutors = useAppStore((state) => state.setExecutors);
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const save = useCallback(
|
||||
async (data: {
|
||||
name: string;
|
||||
prepare_script: string;
|
||||
cleanup_script: string;
|
||||
env_vars: ProfileEnvVar[];
|
||||
}) => {
|
||||
setSaveStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await updateExecutorProfile(executor.id, profile.id, data);
|
||||
setSaveStatus("success");
|
||||
toast({ title: t("executors:profileSaved"), variant: "success" });
|
||||
setExecutors(upsertExecutorProfile(executors, executor, updated));
|
||||
window.setTimeout(() => setSaveStatus("idle"), 1500);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : t("executors:failedToSaveProfile");
|
||||
setError(message);
|
||||
setSaveStatus("error");
|
||||
toast({
|
||||
title: t("executors:failedToSaveProfile"),
|
||||
description: message,
|
||||
variant: "error",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[executor, profile.id, executors, setExecutors, toast],
|
||||
);
|
||||
|
||||
const remove = useCallback(async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteExecutorProfile(executor.id, profile.id);
|
||||
setExecutors(
|
||||
executors.map((e: Executor) =>
|
||||
e.id === executor.id
|
||||
? { ...e, profiles: e.profiles?.filter((p) => p.id !== profile.id) }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
runWithNavigationBlockerBypassed(() => router.push(`/settings/executor/${executor.id}`));
|
||||
} catch {
|
||||
setDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
}, [executor.id, profile.id, executors, setExecutors, router]);
|
||||
|
||||
return { saveStatus, error, deleting, deleteDialogOpen, setDeleteDialogOpen, save, remove };
|
||||
}
|
||||
|
||||
function useProfileFormState(executor: Executor, profile: ExecutorProfile) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(profile.name);
|
||||
const [prepareScript, setPrepareScript] = useState(profile.prepare_script ?? "");
|
||||
const [cleanupScript, setCleanupScript] = useState(profile.cleanup_script ?? "");
|
||||
const [envVarRows, setEnvVarRows] = useState<EnvVarRow[]>(() => envVarsToRows(profile.env_vars));
|
||||
const [placeholders, setPlaceholders] = useState<ScriptPlaceholder[]>([]);
|
||||
|
||||
const isRemote =
|
||||
executor.type === "sprites" ||
|
||||
executor.type === "local_docker" ||
|
||||
executor.type === "remote_docker";
|
||||
const isSprites = executor.type === "sprites";
|
||||
|
||||
const spritesSecretId = useMemo(() => {
|
||||
const tokenVar = envVarRows.find((r) => r.key === "SPRITES_API_TOKEN" && r.mode === "secret");
|
||||
return tokenVar?.secretId;
|
||||
}, [envVarRows]);
|
||||
|
||||
useEffect(() => {
|
||||
listScriptPlaceholders()
|
||||
.then((res) => setPlaceholders(res.placeholders ?? []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const addEnvVar = useCallback(() => {
|
||||
setEnvVarRows((prev) => [...prev, { key: "", mode: "value", value: "", secretId: "" }]);
|
||||
}, []);
|
||||
const removeEnvVar = useCallback((index: number) => {
|
||||
setEnvVarRows((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
const updateEnvVar = useCallback((index: number, field: keyof EnvVarRow, val: string) => {
|
||||
setEnvVarRows((prev) => prev.map((row, i) => (i === index ? { ...row, [field]: val } : row)));
|
||||
}, []);
|
||||
|
||||
// `{{` is the editor's placeholder-completion trigger, not an interpolation
|
||||
// token, so it is interpolated as a value rather than written into the
|
||||
// catalog — i18next would otherwise try to resolve it.
|
||||
const prepareDesc = isRemote
|
||||
? t("executors:prepareScriptDescriptionRemote", { trigger: "{{" })
|
||||
: t("executors:prepareScriptDescriptionLocal");
|
||||
|
||||
return {
|
||||
name,
|
||||
setName,
|
||||
prepareScript,
|
||||
setPrepareScript,
|
||||
cleanupScript,
|
||||
setCleanupScript,
|
||||
envVarRows,
|
||||
addEnvVar,
|
||||
removeEnvVar,
|
||||
updateEnvVar,
|
||||
placeholders,
|
||||
isRemote,
|
||||
isSprites,
|
||||
spritesSecretId,
|
||||
prepareDesc,
|
||||
};
|
||||
}
|
||||
|
||||
function ProfileEditHeader({ executor, profileName }: { executor: Executor; profileName: string }) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<SettingsPageHeader
|
||||
title={profileName}
|
||||
description={t("executors:profileForExecutor", { name: executor.name })}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
className={settingsActionClassName("cursor-pointer")}
|
||||
onClick={() => router.push(`/settings/executor/${executor.id}`)}
|
||||
>
|
||||
{t("executors:backToExecutor")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileEditForm({ executor, profile }: { executor: Executor; profile: ExecutorProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const { items: secrets } = useSecrets();
|
||||
const persistence = useProfilePersistence(executor, profile);
|
||||
const form = useProfileFormState(executor, profile);
|
||||
|
||||
const savePayload = {
|
||||
name: form.name.trim(),
|
||||
prepare_script: form.prepareScript,
|
||||
cleanup_script: form.cleanupScript,
|
||||
env_vars: rowsToEnvVars(form.envVarRows),
|
||||
};
|
||||
const saveRevision = serializeSettingsRevision(savePayload);
|
||||
const [savedRevision, setSavedRevision] = useState(saveRevision);
|
||||
const handleSave = async () => {
|
||||
await persistence.save(savePayload);
|
||||
setSavedRevision(saveRevision);
|
||||
};
|
||||
useSettingsSaveContributor({
|
||||
id: `legacy-executor-profile:${profile.id}`,
|
||||
revision: saveRevision,
|
||||
isDirty: saveRevision !== savedRevision,
|
||||
canSave: Boolean(form.name.trim()),
|
||||
invalidReason: form.name.trim() ? undefined : t("executors:profileNameIsRequired"),
|
||||
save: handleSave,
|
||||
discard: () => undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<ProfileEditHeader executor={executor} profileName={profile.name} />
|
||||
<Separator />
|
||||
<ProfileDetailsCard
|
||||
name={form.name}
|
||||
baselineName={profile.name}
|
||||
onNameChange={form.setName}
|
||||
/>
|
||||
{form.isSprites && form.spritesSecretId && (
|
||||
<>
|
||||
<SpritesConnectionCard secretId={form.spritesSecretId} />
|
||||
<SpritesInstancesCard secretId={form.spritesSecretId} />
|
||||
</>
|
||||
)}
|
||||
<EnvVarsCard
|
||||
rows={form.envVarRows}
|
||||
baselineRows={envVarsToRows(profile.env_vars)}
|
||||
secrets={secrets}
|
||||
onAdd={form.addEnvVar}
|
||||
onUpdate={form.updateEnvVar}
|
||||
onRemove={form.removeEnvVar}
|
||||
/>
|
||||
<ScriptCard
|
||||
title={t("executors:prepareScript")}
|
||||
description={form.prepareDesc}
|
||||
value={form.prepareScript}
|
||||
baselineValue={profile.prepare_script ?? ""}
|
||||
onChange={form.setPrepareScript}
|
||||
height="300px"
|
||||
placeholders={form.placeholders}
|
||||
executorType={executor.type}
|
||||
/>
|
||||
{form.isRemote && (
|
||||
<ScriptCard
|
||||
title={t("executors:cleanupScript")}
|
||||
description={t("executors:runsAfterTheAgentSessionEnds")}
|
||||
value={form.cleanupScript}
|
||||
baselineValue={profile.cleanup_script ?? ""}
|
||||
onChange={form.setCleanupScript}
|
||||
height="200px"
|
||||
placeholders={form.placeholders}
|
||||
executorType={executor.type}
|
||||
/>
|
||||
)}
|
||||
{persistence.error && <p className="text-sm text-destructive">{persistence.error}</p>}
|
||||
<ProfileActions
|
||||
executorId={executor.id}
|
||||
onRequestDelete={() => persistence.setDeleteDialogOpen(true)}
|
||||
/>
|
||||
<DeleteProfileDialog
|
||||
open={persistence.deleteDialogOpen}
|
||||
onOpenChange={persistence.setDeleteDialogOpen}
|
||||
onDelete={persistence.remove}
|
||||
deleting={persistence.deleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,11 +58,7 @@ export default function KubernetesExecutorPage({ executorId }: { executorId: str
|
||||
)?.profiles?.[0]?.id ?? null,
|
||||
);
|
||||
if (profileId) {
|
||||
return (
|
||||
<SettingsRedirect
|
||||
to={executorProfileSettingsPath({ id: executorId, type: "k8s" }, profileId)}
|
||||
/>
|
||||
);
|
||||
return <SettingsRedirect to={executorProfileSettingsPath(profileId)} />;
|
||||
}
|
||||
return <KubernetesExecutorRecoveryPage executorId={executorId} />;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function KubernetesCreatePage() {
|
||||
try {
|
||||
const created = await resource.create(payload);
|
||||
runWithNavigationBlockerBypassed(() =>
|
||||
router.push(executorProfileSettingsPath(created.executor, created.profile.id)),
|
||||
router.push(executorProfileSettingsPath(created.profile.id)),
|
||||
);
|
||||
} catch (cause) {
|
||||
setError(cause);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { executorProfileSettingsPath } from "@/lib/settings/executor-settings-routes";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
@@ -407,7 +408,9 @@ function useCreateProfileSave(executorId: string) {
|
||||
e.id === executorId ? { ...e, profiles: [...(e.profiles ?? []), profile] } : e,
|
||||
),
|
||||
);
|
||||
runWithNavigationBlockerBypassed(() => router.push(`/settings/executors/${profile.id}`));
|
||||
runWithNavigationBlockerBypassed(() =>
|
||||
router.push(executorProfileSettingsPath(profile.id)),
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("executors:failedToCreateProfile"));
|
||||
throw err;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { executorProfileSettingsPath } from "@/lib/settings/executor-settings-routes";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -68,7 +69,7 @@ export function SSHCreatePage() {
|
||||
const current = store.getState().executors.items;
|
||||
const merged = current.some((e) => e.id === next.id) ? current : [...current, next];
|
||||
store.getState().setExecutors(merged);
|
||||
router.push(`/settings/executors/${profile.id}`);
|
||||
router.push(executorProfileSettingsPath(profile.id));
|
||||
},
|
||||
[router, store],
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { EXECUTOR_ICON_MAP, getExecutorLabel } from "@/lib/executor-icons";
|
||||
import type { Executor, ExecutorProfile } from "@/lib/types/http";
|
||||
import { KubernetesReadOnlyNotice } from "@/components/settings/kubernetes-read-only-notice";
|
||||
import { settingsActionClassName } from "@/components/settings/settings-control";
|
||||
import { executorProfileSettingsPath } from "@/lib/settings/executor-settings-routes";
|
||||
|
||||
type ProfileWithExecutor = ExecutorProfile & {
|
||||
executor_type: string;
|
||||
@@ -114,7 +115,7 @@ function ProfileCard({
|
||||
return (
|
||||
<Card
|
||||
className="group cursor-pointer transition-colors hover:bg-muted/50"
|
||||
onClick={() => router.push(`/settings/executors/${profile.id}`)}
|
||||
onClick={() => router.push(executorProfileSettingsPath(profile.id))}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<ExecutorIconBadge type={profile.executor_type} />
|
||||
|
||||
@@ -331,13 +331,11 @@ describe("buildAgentsBranch", () => {
|
||||
});
|
||||
|
||||
describe("buildExecutorsBranch", () => {
|
||||
it("uses the executor-scoped profile route, the one with an executor crumb", () => {
|
||||
it("uses the canonical profile route", () => {
|
||||
const [executor] = buildExecutorsBranch(EXECUTORS);
|
||||
|
||||
expect(executor.href).toBe("/settings/executor/exec-1");
|
||||
expect(hrefsOf(executor.children ?? [])).toEqual([
|
||||
"/settings/executor/exec-1/profile/exec-profile-1",
|
||||
]);
|
||||
expect(hrefsOf(executor.children ?? [])).toEqual(["/settings/executors/exec-profile-1"]);
|
||||
});
|
||||
|
||||
it("makes a configured Kubernetes executor disclosure-only and links its profile root", () => {
|
||||
@@ -446,12 +444,12 @@ describe("findActiveNodePath", () => {
|
||||
});
|
||||
|
||||
it("stops at the row when the route has no node — the row stays active", () => {
|
||||
// The install catalogue is not an agent, and the flat profile spelling has
|
||||
// no executor to nest under. Both are the row's own pages.
|
||||
// The install catalogue is not an agent, and an unknown profile has no
|
||||
// executor to nest under. Both are the row's own pages.
|
||||
expect(findActiveNodePath(forest, "/settings/agents/browse")).toEqual([
|
||||
settingsMenuRowKey("/settings/agents"),
|
||||
]);
|
||||
expect(findActiveNodePath(forest, "/settings/executors/exec-profile-1")).toEqual([
|
||||
expect(findActiveNodePath(forest, "/settings/executors/unknown-profile")).toEqual([
|
||||
settingsMenuRowKey(EXECUTORS_HREF),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -316,11 +316,11 @@ export function buildAgentsBranch(
|
||||
/**
|
||||
* One node per executor, holding its profiles.
|
||||
*
|
||||
* Most executors use the scoped legacy spellings so their executor breadcrumb
|
||||
* matches the branch. A configured Kubernetes executor only discloses its
|
||||
* profile children: connection, diagnostics, sessions and workload settings
|
||||
* all live on that profile page. Its standalone connection route remains
|
||||
* reachable only when there is no profile to recover through.
|
||||
* Profile children always open the canonical profile editor. A configured
|
||||
* Kubernetes executor only discloses its profile children: connection,
|
||||
* diagnostics, sessions and workload settings all live on that profile page.
|
||||
* Its standalone connection route remains reachable only when there is no
|
||||
* profile to recover through.
|
||||
*/
|
||||
export function buildExecutorsBranch(executors: ReadonlyArray<BranchExecutor>): SettingsMenuNode[] {
|
||||
return executors.map((executor) => {
|
||||
@@ -337,7 +337,7 @@ export function buildExecutorsBranch(executors: ReadonlyArray<BranchExecutor>):
|
||||
// Same split as agents: the executor ships with kandev, the profiles do not.
|
||||
children: profiles.map((profile) => ({
|
||||
key: `executor:${executor.id}:profile:${profile.id}`,
|
||||
href: executorProfileSettingsPath(executor, profile.id),
|
||||
href: executorProfileSettingsPath(profile.id),
|
||||
label: { text: profile.name },
|
||||
isUserRecord: true,
|
||||
})),
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import ExecutorsHubPage from "@/app/settings/executors/page";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ExecutorEnvironmentDisclosure } from "@/components/task/executor-environment-disclosure";
|
||||
import type { TaskEnvironment } from "@/lib/api/domains/task-environment-api";
|
||||
import type { Executor, ExecutorProfile } from "@/lib/types/http";
|
||||
|
||||
const { push } = vi.hoisted(() => ({ push: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/routing/client-router", () => ({
|
||||
useRouter: () => ({ push }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/task/executor-environment-info", () => ({
|
||||
EnvironmentInfo: ({ kubernetesActions }: { kubernetesActions?: ReactNode }) => (
|
||||
<>{kubernetesActions}</>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
const TIMESTAMP = "2026-08-24T00:00:00Z";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
push.mockReset();
|
||||
});
|
||||
|
||||
function profile(executorId: string): ExecutorProfile {
|
||||
return {
|
||||
id: "profile/primary",
|
||||
executor_id: executorId,
|
||||
executor_type: "local_docker",
|
||||
name: "Primary profile",
|
||||
config: {},
|
||||
prepare_script: "",
|
||||
cleanup_script: "",
|
||||
env_vars: [],
|
||||
created_at: TIMESTAMP,
|
||||
updated_at: TIMESTAMP,
|
||||
};
|
||||
}
|
||||
|
||||
function executor(): Executor {
|
||||
const id = "executor/primary";
|
||||
return {
|
||||
id,
|
||||
name: "Primary executor",
|
||||
type: "local_docker",
|
||||
status: "active",
|
||||
is_system: false,
|
||||
config: {},
|
||||
profiles: [profile(id)],
|
||||
created_at: TIMESTAMP,
|
||||
updated_at: TIMESTAMP,
|
||||
};
|
||||
}
|
||||
|
||||
const kubernetesEnvironment = {
|
||||
id: "environment-1",
|
||||
task_id: "task-1",
|
||||
repository_id: "repository-1",
|
||||
executor_type: "k8s",
|
||||
executor_id: "executor/primary",
|
||||
executor_profile_id: "profile/primary",
|
||||
agent_execution_id: "execution-1",
|
||||
control_port: 0,
|
||||
status: "running",
|
||||
created_at: TIMESTAMP,
|
||||
} satisfies TaskEnvironment;
|
||||
|
||||
describe("executor profile navigation", () => {
|
||||
it("opens the hub profile card in the encoded canonical editor", () => {
|
||||
const record = executor();
|
||||
render(
|
||||
<StateProvider initialState={{ executors: { items: [record] } }}>
|
||||
<ExecutorsHubPage />
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Primary profile"));
|
||||
|
||||
expect(push).toHaveBeenCalledWith("/settings/executors/profile%2Fprimary");
|
||||
});
|
||||
|
||||
it("opens the task disclosure settings action in the encoded canonical editor", () => {
|
||||
render(
|
||||
<ExecutorEnvironmentDisclosure
|
||||
env={kubernetesEnvironment}
|
||||
container={null}
|
||||
ssh={null}
|
||||
kubernetes={null}
|
||||
kubernetesLoaded={false}
|
||||
kubernetesError={null}
|
||||
loading={false}
|
||||
refreshing={false}
|
||||
isResetting={false}
|
||||
onRefresh={async () => {}}
|
||||
onReset={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("executor-settings-link").getAttribute("href")).toBe(
|
||||
"/settings/executors/profile%2Fprimary",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -63,13 +63,11 @@ describe("ExecutorProfilesCard profile navigation", () => {
|
||||
expect(push).toHaveBeenCalledWith("/settings/executors/profile%2Fprimary");
|
||||
});
|
||||
|
||||
it("preserves the scoped legacy profile route for SSH", () => {
|
||||
it("opens an SSH profile in the canonical editor", () => {
|
||||
renderCard(executor("ssh"));
|
||||
|
||||
fireEvent.click(screen.getByText("Primary profile"));
|
||||
|
||||
expect(push).toHaveBeenCalledWith(
|
||||
"/settings/executor/executor%2Fprimary/profile/profile%2Fprimary",
|
||||
);
|
||||
expect(push).toHaveBeenCalledWith("/settings/executors/profile%2Fprimary");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,6 @@ export function ExecutorProfilesCard({ executorId, profiles }: ExecutorProfilesC
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const executors = useAppStore((state) => state.executors.items);
|
||||
const setExecutors = useAppStore((state) => state.setExecutors);
|
||||
const executorType = executors.find((executor) => executor.id === executorId)?.type;
|
||||
|
||||
const refreshProfiles = useCallback(async () => {
|
||||
try {
|
||||
@@ -81,9 +80,9 @@ export function ExecutorProfilesCard({ executorId, profiles }: ExecutorProfilesC
|
||||
const handleProfileCreated = useCallback(
|
||||
(profile: ExecutorProfile) => {
|
||||
refreshProfiles();
|
||||
router.push(executorProfileSettingsPath({ id: executorId, type: executorType }, profile.id));
|
||||
router.push(executorProfileSettingsPath(profile.id));
|
||||
},
|
||||
[executorId, executorType, refreshProfiles, router],
|
||||
[refreshProfiles, router],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
@@ -117,14 +116,7 @@ export function ExecutorProfilesCard({ executorId, profiles }: ExecutorProfilesC
|
||||
<div
|
||||
key={profile.id}
|
||||
className="flex items-center justify-between rounded-md border px-3 py-2 hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
executorProfileSettingsPath(
|
||||
{ id: executorId, type: executorType },
|
||||
profile.id,
|
||||
),
|
||||
)
|
||||
}
|
||||
onClick={() => router.push(executorProfileSettingsPath(profile.id))}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-medium truncate">{profile.name}</span>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
|
||||
import type { Executor, ExecutorProfile } from "@/lib/types/http";
|
||||
import { LegacyExecutorSettingsRoute } from "./legacy-executor-settings-route";
|
||||
|
||||
const LEGACY_EXECUTOR_TESTID = "legacy-executor";
|
||||
const { replace } = vi.hoisted(() => ({ replace: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/routing/client-router", () => ({
|
||||
@@ -13,27 +15,25 @@ vi.mock("@/lib/routing/client-router", () => ({
|
||||
|
||||
vi.mock("@/app/settings/executor/[id]/page", () => ({
|
||||
default: ({ executorId }: { executorId: string }) => (
|
||||
<div data-testid="legacy-executor">{executorId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/settings/executor/[id]/profile/[profileId]/page", () => ({
|
||||
default: ({ profileId }: { profileId: string }) => (
|
||||
<div data-testid="legacy-profile">{profileId}</div>
|
||||
<div data-testid={LEGACY_EXECUTOR_TESTID}>{executorId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const TIMESTAMP = "2026-08-24T00:00:00Z";
|
||||
const PROFILE_ID = "profile/primary";
|
||||
const CANONICAL_PROFILE_PATH = "/settings/executors/profile%2Fprimary";
|
||||
const PROFILE_UNAVAILABLE_TESTID = "executor-profile-unavailable";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
replace.mockReset();
|
||||
window.history.replaceState({}, "", "/settings");
|
||||
});
|
||||
|
||||
function executor(type: Executor["type"]): Executor {
|
||||
const id = "executor/primary";
|
||||
const profile: ExecutorProfile = {
|
||||
id: "profile/primary",
|
||||
id: PROFILE_ID,
|
||||
executor_id: id,
|
||||
executor_type: type,
|
||||
name: "Primary profile",
|
||||
@@ -57,11 +57,17 @@ function executor(type: Executor["type"]): Executor {
|
||||
};
|
||||
}
|
||||
|
||||
function renderRoute(record: Executor, profileId?: string) {
|
||||
function renderRoute(
|
||||
record: Executor | null,
|
||||
profileId?: string,
|
||||
executorId?: string,
|
||||
options: { executorsLoaded?: boolean } = {},
|
||||
) {
|
||||
render(
|
||||
<StateProvider
|
||||
initialState={{
|
||||
executors: { items: [record] },
|
||||
executors: { items: record ? [record] : [] },
|
||||
settingsData: { executorsLoaded: options.executorsLoaded ?? true, agentsLoaded: true },
|
||||
auth: {
|
||||
mode: "enabled",
|
||||
authenticated: true,
|
||||
@@ -75,19 +81,36 @@ function renderRoute(record: Executor, profileId?: string) {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LegacyExecutorSettingsRoute executorId={record.id} profileId={profileId} />
|
||||
<LegacyExecutorSettingsRoute
|
||||
executorId={executorId ?? record?.id ?? "executor/missing"}
|
||||
profileId={profileId}
|
||||
/>
|
||||
</StateProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function HydrateExecutors({ record }: { record: Executor }) {
|
||||
const store = useAppStoreApi();
|
||||
useEffect(() => {
|
||||
store.getState().setExecutors([record]);
|
||||
store.getState().setSettingsData({ executorsLoaded: true });
|
||||
}, [record, store]);
|
||||
return null;
|
||||
}
|
||||
|
||||
it("waits for executor hydration before rendering an unavailable profile state", () => {
|
||||
renderRoute(null, PROFILE_ID, "executor/missing", { executorsLoaded: false });
|
||||
|
||||
expect(screen.queryByTestId(PROFILE_UNAVAILABLE_TESTID)).toBeNull();
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("LegacyExecutorSettingsRoute", () => {
|
||||
it("redirects a member's bookmarked Kubernetes executor before mounting legacy controls", async () => {
|
||||
renderRoute(executor("k8s"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(replace).toHaveBeenCalledWith("/settings/executors/profile%2Fprimary"),
|
||||
);
|
||||
expect(screen.queryByTestId("legacy-executor")).toBeNull();
|
||||
await waitFor(() => expect(replace).toHaveBeenCalledWith(CANONICAL_PROFILE_PATH));
|
||||
expect(screen.queryByTestId(LEGACY_EXECUTOR_TESTID)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an orphaned Kubernetes executor on the connection recovery page", async () => {
|
||||
@@ -97,32 +120,106 @@ describe("LegacyExecutorSettingsRoute", () => {
|
||||
await waitFor(() =>
|
||||
expect(replace).toHaveBeenCalledWith("/settings/executors/k8s/executor%2Fprimary"),
|
||||
);
|
||||
expect(screen.queryByTestId("legacy-executor")).toBeNull();
|
||||
expect(screen.queryByTestId(LEGACY_EXECUTOR_TESTID)).toBeNull();
|
||||
});
|
||||
|
||||
it("redirects a bookmarked Kubernetes profile to the Kubernetes-aware editor", async () => {
|
||||
renderRoute(executor("k8s"), "profile/primary");
|
||||
renderRoute(executor("k8s"), PROFILE_ID);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(replace).toHaveBeenCalledWith("/settings/executors/profile%2Fprimary"),
|
||||
);
|
||||
await waitFor(() => expect(replace).toHaveBeenCalledWith(CANONICAL_PROFILE_PATH));
|
||||
expect(screen.queryByTestId("legacy-profile")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not redirect a Kubernetes executor to a profile owned by another executor", () => {
|
||||
renderRoute(executor("k8s"), "profile/from-another-executor");
|
||||
|
||||
expect(screen.getByTestId("legacy-profile")).toBeTruthy();
|
||||
expect(screen.getByTestId(PROFILE_UNAVAILABLE_TESTID)).toBeTruthy();
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ profileId: undefined, testId: "legacy-executor" },
|
||||
{ profileId: "profile/primary", testId: "legacy-profile" },
|
||||
])("keeps SSH on its legacy scoped page", ({ profileId, testId }) => {
|
||||
renderRoute(executor("ssh"), profileId);
|
||||
"local",
|
||||
"local_docker",
|
||||
"ssh",
|
||||
"sprites",
|
||||
"local_pc",
|
||||
"worktree",
|
||||
"remote_docker",
|
||||
] as const)("redirects a %s profile bookmark to the canonical editor", async (type) => {
|
||||
renderRoute(executor(type), PROFILE_ID);
|
||||
|
||||
expect(screen.getByTestId(testId)).toBeTruthy();
|
||||
await waitFor(() => expect(replace).toHaveBeenCalledWith(CANONICAL_PROFILE_PATH));
|
||||
expect(screen.queryByTestId(LEGACY_EXECUTOR_TESTID)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an executor-only SSH route on the connection editor", () => {
|
||||
renderRoute(executor("ssh"));
|
||||
|
||||
expect(screen.getByTestId(LEGACY_EXECUTOR_TESTID)).toBeTruthy();
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders an unavailable state for a missing profile without choosing another profile", () => {
|
||||
const record = executor("local_docker");
|
||||
record.profiles = [
|
||||
{
|
||||
...record.profiles![0],
|
||||
id: "profile/other",
|
||||
},
|
||||
];
|
||||
|
||||
renderRoute(record, PROFILE_ID);
|
||||
|
||||
expect(screen.getByTestId(PROFILE_UNAVAILABLE_TESTID)).toBeTruthy();
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders an unavailable state for a missing executor", () => {
|
||||
renderRoute(null, PROFILE_ID, "executor/missing");
|
||||
|
||||
expect(screen.getByTestId(PROFILE_UNAVAILABLE_TESTID)).toBeTruthy();
|
||||
expect(replace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves query parameters and fragments while replacing a valid bookmark", async () => {
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
"/settings/executor/executor%2Fprimary/profile/profile%2Fprimary?tab=advanced#docker",
|
||||
);
|
||||
renderRoute(executor("ssh"), PROFILE_ID);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(replace).toHaveBeenCalledWith(
|
||||
"/settings/executors/profile%2Fprimary?tab=advanced#docker",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("retries a valid bookmark when executor state hydrates after the route mounts", async () => {
|
||||
const record = executor("ssh");
|
||||
render(
|
||||
<StateProvider
|
||||
initialState={{
|
||||
executors: { items: [] },
|
||||
auth: {
|
||||
mode: "enabled",
|
||||
authenticated: true,
|
||||
user: {
|
||||
id: "member-1",
|
||||
email: "member@example.com",
|
||||
display_name: "Member",
|
||||
role: "member",
|
||||
status: "active",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LegacyExecutorSettingsRoute executorId={record.id} profileId={PROFILE_ID} />
|
||||
<HydrateExecutors record={record} />
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(replace).toHaveBeenCalledWith(CANONICAL_PROFILE_PATH));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import ExecutorEditPage from "@/app/settings/executor/[id]/page";
|
||||
import ProfileDetailPage from "@/app/settings/executor/[id]/profile/[profileId]/page";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Card, CardContent } from "@kandev/ui/card";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import {
|
||||
executorConnectionSettingsPath,
|
||||
executorProfileSettingsPath,
|
||||
} from "@/lib/settings/executor-settings-routes";
|
||||
import type { Executor } from "@/lib/types/http";
|
||||
import { SettingsRedirect } from "@/src/settings-route-helpers";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const EXECUTORS_ROUTE = "/settings/executors";
|
||||
|
||||
function ProfileUnavailableState({ executor }: { executor: Executor | null }) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const destination = executor ? executorConnectionSettingsPath(executor) : EXECUTORS_ROUTE;
|
||||
|
||||
return (
|
||||
<Card data-testid="executor-profile-unavailable">
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">{t("executors:profileNotFound")}</p>
|
||||
<Button className="mt-4 cursor-pointer" onClick={() => router.push(destination)}>
|
||||
{executor ? t("executors:backToExecutor") : t("executors:backToExecutors")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegacyExecutorSettingsRoute({
|
||||
executorId,
|
||||
@@ -19,23 +42,23 @@ export function LegacyExecutorSettingsRoute({
|
||||
const executor = useAppStore(
|
||||
(state) => state.executors.items.find((item) => item.id === executorId) ?? null,
|
||||
);
|
||||
const executorsLoaded = useAppStore((state) => state.settingsData.executorsLoaded);
|
||||
|
||||
if (profileId !== undefined) {
|
||||
if (!executorsLoaded) return null;
|
||||
const profile = executor?.profiles?.find(
|
||||
(candidate) => candidate.id === profileId && candidate.executor_id === executorId,
|
||||
);
|
||||
if (!executor || !profile) return <ProfileUnavailableState executor={executor} />;
|
||||
return <SettingsRedirect to={executorProfileSettingsPath(profile.id)} />;
|
||||
}
|
||||
|
||||
if (executor?.type === "k8s") {
|
||||
const destinationProfileId = profileId
|
||||
? executor.profiles?.find((profile) => profile.id === profileId)?.id
|
||||
: executor.profiles?.[0]?.id;
|
||||
if (profileId && !destinationProfileId) {
|
||||
return <ProfileDetailPage executorId={executorId} profileId={profileId} />;
|
||||
}
|
||||
const destination = destinationProfileId
|
||||
? executorProfileSettingsPath(executor, destinationProfileId)
|
||||
const destination = executor.profiles?.[0]?.id
|
||||
? executorProfileSettingsPath(executor.profiles[0].id)
|
||||
: executorConnectionSettingsPath(executor);
|
||||
return <SettingsRedirect to={destination} />;
|
||||
}
|
||||
|
||||
return profileId ? (
|
||||
<ProfileDetailPage executorId={executorId} profileId={profileId} />
|
||||
) : (
|
||||
<ExecutorEditPage executorId={executorId} />
|
||||
);
|
||||
return <ExecutorEditPage executorId={executorId} />;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { JiraTicket } from "@/lib/types/jira";
|
||||
import type { LinearIssue } from "@/lib/types/linear";
|
||||
import type { TaskCreateLaunchPreview } from "@/components/task-create-dialog-launch-preview";
|
||||
import { RUNNER_INELIGIBLE_REASON_KEYS } from "@/components/task-create-dialog-helpers";
|
||||
import { executorProfileSettingsPath } from "@/lib/settings/executor-settings-routes";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SelectorOption = {
|
||||
@@ -89,7 +90,7 @@ type AgentColumnProps = Pick<
|
||||
>;
|
||||
|
||||
function credentialsHref(executorProfileId: string): string {
|
||||
return executorProfileId ? `/settings/executors/${executorProfileId}` : "/settings/executors";
|
||||
return executorProfileId ? executorProfileSettingsPath(executorProfileId) : "/settings/executors";
|
||||
}
|
||||
|
||||
function useExecutorTarget(executorProfileName: string | null): string {
|
||||
|
||||
@@ -93,10 +93,7 @@ function KubernetesActions({
|
||||
touch ? "h-11 w-11" : "h-10 w-10",
|
||||
);
|
||||
const settingsPath = env.executor_profile_id
|
||||
? executorProfileSettingsPath(
|
||||
{ id: env.executor_id, type: env.executor_type },
|
||||
env.executor_profile_id,
|
||||
)
|
||||
? executorProfileSettingsPath(env.executor_profile_id)
|
||||
: kubernetesExecutorSettingsPath(env.executor_id);
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { waitForHttp } from "../../helpers/causal-waits";
|
||||
import { setSettingsMenuMode } from "../../helpers/settings-menu";
|
||||
|
||||
const INITIAL_IMAGE_TAG = "kandev/e2e:routing-initial";
|
||||
const EDITED_IMAGE_TAG = "kandev/e2e:routing-edited";
|
||||
const DRAFT_IMAGE_TAG = "kandev/e2e:routing-draft";
|
||||
const DOCKERFILE = "FROM busybox\nWORKDIR /workspace\n";
|
||||
|
||||
function canonicalProfilePath(profileId: string): string {
|
||||
return `/settings/executors/${encodeURIComponent(profileId)}`;
|
||||
}
|
||||
|
||||
async function expectDockerProfileEditor(testPage: Page, profileId: string) {
|
||||
await expect(testPage).toHaveURL(canonicalProfilePath(profileId));
|
||||
await expect(testPage.locator("#image-tag")).toBeVisible();
|
||||
await expect(testPage.getByText("Dockerfile", { exact: true })).toBeVisible();
|
||||
await expect(testPage.getByRole("button", { name: "Build Image" })).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe("executor profile routing", () => {
|
||||
test("converges desktop entry points on the complete Docker profile editor", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const executor = await apiClient.createExecutor("E2E profile routing executor", "local_docker");
|
||||
const profile = await apiClient.createExecutorProfile(executor.id, {
|
||||
name: "E2E profile routing Docker",
|
||||
config: { dockerfile: DOCKERFILE, image_tag: INITIAL_IMAGE_TAG },
|
||||
prepare_script: "",
|
||||
cleanup_script: "",
|
||||
env_vars: [],
|
||||
});
|
||||
|
||||
await testPage.route("**/api/v1/docker/build", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/x-ndjson",
|
||||
body: JSON.stringify({ stream: "Successfully built\n" }) + "\n",
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await testPage.goto("/settings/executors");
|
||||
await testPage.getByText(profile.name, { exact: true }).click();
|
||||
await expectDockerProfileEditor(testPage, profile.id);
|
||||
|
||||
const buildResponse = waitForHttp(testPage, "POST", /^\/api\/v1\/docker\/build$/, {
|
||||
predicate: (response) => response.ok(),
|
||||
});
|
||||
await testPage.getByRole("button", { name: "Build Image" }).click();
|
||||
await buildResponse;
|
||||
await expect(testPage.getByText("Success", { exact: true })).toBeVisible();
|
||||
|
||||
await testPage.locator("#image-tag").fill(EDITED_IMAGE_TAG);
|
||||
const floatingSave = testPage.getByTestId("settings-floating-save");
|
||||
const saveResponse = waitForHttp(
|
||||
testPage,
|
||||
"PATCH",
|
||||
/^\/api\/v1\/executors\/[^/]+\/profiles\/[^/]+$/,
|
||||
{ predicate: (response) => response.ok() },
|
||||
);
|
||||
await floatingSave.getByRole("button", { name: "Save changes" }).click();
|
||||
await saveResponse;
|
||||
await expect(testPage.getByText("Profile saved")).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await apiClient.getExecutorProfile(executor.id, profile.id)).config?.image_tag,
|
||||
)
|
||||
.toBe(EDITED_IMAGE_TAG);
|
||||
|
||||
await testPage.reload();
|
||||
await expect(testPage.locator("#image-tag")).toHaveValue(EDITED_IMAGE_TAG);
|
||||
|
||||
await setSettingsMenuMode(testPage, "accordion");
|
||||
const settingsTree = testPage.getByTestId("app-sidebar-settings-mode");
|
||||
const expandExecutors = settingsTree.getByRole("button", { name: /Expand Executors/i });
|
||||
if (await expandExecutors.count()) await expandExecutors.click();
|
||||
const expandExecutor = settingsTree.getByRole("button", {
|
||||
name: `Expand ${executor.name}`,
|
||||
});
|
||||
if (await expandExecutor.count()) await expandExecutor.click();
|
||||
const profileLink = settingsTree.locator(`a[href="${canonicalProfilePath(profile.id)}"]`);
|
||||
await expect(profileLink).toBeVisible({ timeout: 10_000 });
|
||||
await profileLink.click();
|
||||
await expectDockerProfileEditor(testPage, profile.id);
|
||||
|
||||
await testPage.goto(`/settings/executor/${encodeURIComponent(executor.id)}`);
|
||||
await testPage.getByText(profile.name, { exact: true }).click();
|
||||
await expectDockerProfileEditor(testPage, profile.id);
|
||||
|
||||
const legacyBookmark =
|
||||
`/settings/executor/${encodeURIComponent(executor.id)}/profile/${encodeURIComponent(profile.id)}` +
|
||||
"?tab=advanced#docker";
|
||||
await testPage.goto(legacyBookmark);
|
||||
await expect(testPage).toHaveURL(`${canonicalProfilePath(profile.id)}?tab=advanced#docker`);
|
||||
await expect(testPage.locator("#image-tag")).toHaveValue(EDITED_IMAGE_TAG);
|
||||
await expect(testPage.getByRole("button", { name: "Build Image" })).toBeVisible();
|
||||
|
||||
await testPage.locator("#image-tag").fill(DRAFT_IMAGE_TAG);
|
||||
const executorsLink = settingsTree.locator('a[href="/settings/executors"]');
|
||||
await executorsLink.click();
|
||||
const leaveDialog = testPage.getByRole("alertdialog");
|
||||
await expect(leaveDialog).toBeVisible();
|
||||
await leaveDialog.getByRole("button", { name: "Continue editing" }).click();
|
||||
await expect(testPage).toHaveURL(`${canonicalProfilePath(profile.id)}?tab=advanced#docker`);
|
||||
await expect(testPage.locator("#image-tag")).toHaveValue(DRAFT_IMAGE_TAG);
|
||||
|
||||
await executorsLink.click();
|
||||
await expect(leaveDialog).toBeVisible();
|
||||
await leaveDialog.getByRole("button", { name: "Discard and leave" }).click();
|
||||
await expect(testPage).toHaveURL(/\/settings\/executors$/);
|
||||
|
||||
await testPage.goto(canonicalProfilePath(profile.id));
|
||||
await expect(testPage.locator("#image-tag")).toHaveValue(EDITED_IMAGE_TAG);
|
||||
} finally {
|
||||
await apiClient.deleteExecutor(executor.id).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { waitForHttp } from "../../helpers/causal-waits";
|
||||
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
||||
|
||||
const INITIAL_IMAGE_TAG = "kandev/e2e:mobile-routing-initial";
|
||||
const EDITED_IMAGE_TAG = "kandev/e2e:mobile-routing-edited";
|
||||
const DOCKERFILE = "FROM busybox\nWORKDIR /workspace\n";
|
||||
|
||||
function canonicalProfilePath(profileId: string): string {
|
||||
return `/settings/executors/${encodeURIComponent(profileId)}`;
|
||||
}
|
||||
|
||||
async function expectTouchTarget(locator: Locator, label: string) {
|
||||
const box = await locator.boundingBox();
|
||||
expect(box, `${label} must have geometry`).not.toBeNull();
|
||||
expect(box!.height, `${label} must be at least 44px tall`).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
test("phone profile navigation reaches the complete Docker editor", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const executor = await apiClient.createExecutor(
|
||||
"E2E mobile profile routing executor",
|
||||
"local_docker",
|
||||
);
|
||||
const profile = await apiClient.createExecutorProfile(executor.id, {
|
||||
name: "E2E mobile profile routing Docker",
|
||||
config: { dockerfile: DOCKERFILE, image_tag: INITIAL_IMAGE_TAG },
|
||||
prepare_script: "",
|
||||
cleanup_script: "",
|
||||
env_vars: [],
|
||||
});
|
||||
|
||||
await testPage.route("**/api/v1/docker/build", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/x-ndjson",
|
||||
body: JSON.stringify({ stream: "Successfully built\n" }) + "\n",
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await testPage.goto("/settings/executors");
|
||||
const profileCard = testPage.getByText(profile.name, { exact: true });
|
||||
await expect(profileCard).toBeVisible();
|
||||
await profileCard.tap();
|
||||
await expect(testPage).toHaveURL(canonicalProfilePath(profile.id));
|
||||
|
||||
await expect(testPage.locator("#image-tag")).toBeVisible();
|
||||
await expect(testPage.getByText("Dockerfile", { exact: true })).toBeVisible();
|
||||
const buildButton = testPage.getByRole("button", { name: "Build Image" });
|
||||
await expect(buildButton).toBeVisible();
|
||||
await expectTouchTarget(buildButton, "Mobile Docker build button");
|
||||
const buildResponse = waitForHttp(testPage, "POST", /^\/api\/v1\/docker\/build$/, {
|
||||
predicate: (response) => response.ok(),
|
||||
});
|
||||
await buildButton.tap();
|
||||
await buildResponse;
|
||||
await expect(testPage.getByText("Success", { exact: true })).toBeVisible();
|
||||
|
||||
await testPage.locator("#image-tag").fill(EDITED_IMAGE_TAG);
|
||||
const floatingSave = testPage.getByTestId("settings-floating-save");
|
||||
const saveButton = floatingSave.getByRole("button", { name: "Save changes" });
|
||||
await expectTouchTarget(saveButton, "Mobile profile save button");
|
||||
const saveResponse = waitForHttp(
|
||||
testPage,
|
||||
"PATCH",
|
||||
/^\/api\/v1\/executors\/[^/]+\/profiles\/[^/]+$/,
|
||||
{ predicate: (response) => response.ok() },
|
||||
);
|
||||
await saveButton.tap();
|
||||
await saveResponse;
|
||||
await expect(testPage.getByText("Profile saved")).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await apiClient.getExecutorProfile(executor.id, profile.id)).config?.image_tag,
|
||||
)
|
||||
.toBe(EDITED_IMAGE_TAG);
|
||||
|
||||
await testPage.reload();
|
||||
await expect(testPage.locator("#image-tag")).toHaveValue(EDITED_IMAGE_TAG);
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile canonical executor profile editor");
|
||||
|
||||
const legacyBookmark =
|
||||
`/settings/executor/${encodeURIComponent(executor.id)}/profile/${encodeURIComponent(profile.id)}` +
|
||||
"?tab=advanced#docker";
|
||||
await testPage.goto(legacyBookmark);
|
||||
await expect(testPage).toHaveURL(`${canonicalProfilePath(profile.id)}?tab=advanced#docker`);
|
||||
await expect(testPage.locator("#image-tag")).toHaveValue(EDITED_IMAGE_TAG);
|
||||
await expect(testPage.getByRole("button", { name: "Build Image" })).toBeVisible();
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile legacy executor profile bookmark");
|
||||
} finally {
|
||||
await apiClient.deleteExecutor(executor.id).catch(() => {});
|
||||
}
|
||||
});
|
||||
@@ -197,9 +197,13 @@ describe("resolveSettingsDiscovery dynamic entries", () => {
|
||||
],
|
||||
executors: [
|
||||
{
|
||||
type: "docker",
|
||||
type: "local_docker",
|
||||
profiles: [{ id: "executor / one", name: "Docker Local" }],
|
||||
},
|
||||
{
|
||||
type: "ssh",
|
||||
profiles: [{ id: "ssh profile / two", name: "SSH Remote" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -212,6 +216,9 @@ describe("resolveSettingsDiscovery dynamic entries", () => {
|
||||
expect(resolved.find((entry) => entry.id === "executor-profile:executor / one")?.href).toBe(
|
||||
"/settings/executors/executor%20%2F%20one",
|
||||
);
|
||||
expect(resolved.find((entry) => entry.id === "executor-profile:ssh profile / two")?.href).toBe(
|
||||
"/settings/executors/ssh%20profile%20%2F%20two",
|
||||
);
|
||||
expect(resolved.find((entry) => entry.id === "workspace:workspace / one:name")?.href).toBe(
|
||||
"/settings/workspaces/workspace%20%2F%20one#setting-workspace-workspace%20%2F%20one-name",
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
executorProfileDiscoveryTarget,
|
||||
workspaceDiscoveryTarget,
|
||||
} from "./dynamic-targets";
|
||||
import { executorProfileSettingsPath } from "../settings/executor-settings-routes";
|
||||
|
||||
function workspaceDefinitions(
|
||||
workspaces: SettingsDiscoveryContext["workspaces"],
|
||||
@@ -147,7 +148,7 @@ function executorProfileDefinitions(
|
||||
for (const [executorIndex, executor] of executors.entries()) {
|
||||
for (const [profileIndex, profile] of (executor.profiles ?? []).entries()) {
|
||||
const profileId = `executor-profile:${profile.id}`;
|
||||
const href = `/settings/executors/${encodeURIComponent(profile.id)}`;
|
||||
const href = executorProfileSettingsPath(profile.id);
|
||||
const order = 30_000 + executorIndex * 100 + profileIndex * 10;
|
||||
entries.push({
|
||||
id: profileId,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { executorProfileSettingsPath } from "./executor-settings-routes";
|
||||
|
||||
describe("executorProfileSettingsPath", () => {
|
||||
// @covers AC-EXECUTORS-PROFILE-EDITOR-001.1
|
||||
it("routes every profile to the canonical editor", () => {
|
||||
expect(executorProfileSettingsPath("profile/primary")).toBe(
|
||||
"/settings/executors/profile%2Fprimary",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,11 +13,6 @@ export function executorConnectionSettingsPath(executor: ExecutorRouteIdentity):
|
||||
: `/settings/executor/${encodeURIComponent(executor.id)}`;
|
||||
}
|
||||
|
||||
export function executorProfileSettingsPath(
|
||||
executor: ExecutorRouteIdentity,
|
||||
profileId: string,
|
||||
): string {
|
||||
return executor.type === "k8s"
|
||||
? `/settings/executors/${encodeURIComponent(profileId)}`
|
||||
: `${executorConnectionSettingsPath(executor)}/profile/${encodeURIComponent(profileId)}`;
|
||||
export function executorProfileSettingsPath(profileId: string): string {
|
||||
return `/settings/executors/${encodeURIComponent(profileId)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user